它展示了什么
Playing video inside the app — and what a native surface costs.
VideoPlayer is the third native surface in the framework, after the map and the web
view, and the three share every consequence: the picture is a platform view layered over
the GL canvas, so it does not clip to a scrolling parent, it does not take part in a window
transition, and it sits on top of anything the app paints in the same place. That is why the
player below is on a page of its own rather than inside a list.
The capability is asked first, as always. When this recipe was written only the web
had it, and this paragraph said so; since 26/09/2026 all five play video in-app (MediaPlayer
on Android, AVPlayer on iOS and macOS, Media Foundation on
Windows, javafx.media on Linux, a on the web). The refusal branch below stays: asking is what keeps
the screen honest on whatever platform comes next.
Failure is loud, on purpose. A video that shows black is indistinguishable from a video
that is still loading, so onError is wired below and its message is the
platform's own words. Two of them are worth recognising: a browser refusing to autoplay (the
page had no user gesture, and muting buys the right), and "source not supported", which is nearly
always a 404 or a codec rather than a broken player.
在你的机器上运行
每个示例都是普通的 Window,启动器只需要它的名字。
gradlew lwjgl3:run --args="classname dev.cf.recipe.R32Video"Java 是提前编译的,浏览器无法自行重建你的修改 —— 这里的代码在发布前已由 TeaVM 转成 JavaScript。把文件复制到项目里,它在四个平台上原样运行;上面那条命令就是全部准备工作。
它用到了什么
本示例触及的框架类型。带链接的有参考页。
源码
完整无删减。这就是那个文件,不是片段。
package dev.cf.recipe;
import dev.cf.Tones;
import dev.crossfly.Capability;
import dev.crossfly.PlatformUtils;
import dev.crossfly.util.Debug;
import dev.crossfly.ui.*;
import dev.crossfly.ui.color.*;
import dev.crossfly.ui.graphics.MaterialIcon;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;
import static dev.crossfly.ui.Widgets.*;
/**
* Playing video inside the app — and what a native surface costs.
*
* <p>{@link VideoPlayer} is the third native surface in the framework, after the map and the web
* view, and the three share every consequence: the picture is a platform view layered <b>over</b>
* the GL canvas, so it does not clip to a scrolling parent, it does not take part in a window
* transition, and it sits on top of anything the app paints in the same place. That is why the
* player below is on a page of its own rather than inside a list.</p>
*
* <p>The capability is asked first, as always. When this recipe was written only the <b>web</b>
* had it, and this paragraph said so; since 26/09/2026 all five play video in-app ({@code MediaPlayer}
* on Android, {@code AVPlayer} on iOS and macOS, Media Foundation on
* Windows, {@code javafx.media} on Linux, a {@code <video>} on the web). The refusal branch below stays: asking is what keeps
* the screen honest on whatever platform comes next.</p>
*
* <p><b>Failure is loud, on purpose.</b> A video that shows black is indistinguishable from a video
* that is still loading, so {@link VideoPlayer#onError} is wired below and its message is the
* platform's own words. Two of them are worth recognising: a browser refusing to autoplay (the
* page had no user gesture, and muting buys the right), and "source not supported", which is nearly
* always a 404 or a codec rather than a broken player.</p>
*/
public class R32Video extends Recipe
{
private Label answer;
private VideoPlayer player;
private Button playPause, mute, loop;
private Label time;
private Slider seek;
private com.badlogic.gdx.utils.Timer.Task ticker;
private int duration;
private boolean muted, looping;
/** Material Icons: play_arrow, pause, volume_up, volume_off, repeat, repeat_one (loop on). */
private static final int PLAY = 0xE037, PAUSE = 0xE034, SOUND_ON = 0xE050, SOUND_OFF = 0xE04F,
REPEAT = 0xE040, REPEAT_ON = 0xE041;
/**
* The framework's own test clip, 640x360 [email protected] with sound: a bar sweeps, a counter runs and a
* beep marks each second, so a frozen picture or a silent track shows at once. Made by
* tools/MakeVideoTestClip.java and shipped in assets/media (311 KB). Three borrowed files failed
* first, each with the player in the right (13/10/2026): no audio track, an audio track of zeros,
* and a 1080p [email protected] picture that JavaFX on Windows froze on. It used to be fetched from the
* site, until the site's address stopped serving it and the recipe had nothing to play (on the
* iPhone, 17/10/2026). {@code -Dcrossfly.video.source} points the recipe elsewhere.
*/
private static final String CLIP = "media/crossfly-video-test.mp4";
/**
* Where the clip comes from: {@code -Dcrossfly.video.source} on the desktop, the
* {@code videosource} launch extra on a phone ({@code adb shell am start ... -e videosource /path}),
* which has no system properties to set; otherwise the clip in the assets.
*
* <p>A native player cannot read inside the jar, the APK or the bundle, so the clip is copied to
* the app's own folder once and played from there. The web has no folder: its {@code <video>}
* fetches the asset from the server, where the bundle keeps it out of the start-up download.</p>
*/
private static String source()
{
String s = System.getProperty("crossfly.video.source");
Object extra = dev.crossfly.Main.cmdline == null ? null : dev.crossfly.Main.cmdline.get("videosource");
if (s == null && extra != null)
s = String.valueOf(extra);
if (s != null)
return s;
if (dev.crossfly.device.Device.homeFolder == null)
return "assets/" + CLIP;
java.io.File out = new java.io.File(dev.crossfly.device.Device.homeFolder, "crossfly-video-test.mp4");
byte[] bytes = dev.crossfly.util.Utils.readBytes(CLIP);
if (bytes == null)
return CLIP; // the player's error then names it, instead of a black box
if (out.length() != bytes.length)
try (java.io.FileOutputStream f = new java.io.FileOutputStream(out))
{
f.write(bytes);
}
catch (java.io.IOException e)
{
return CLIP;
}
return out.getAbsolutePath();
}
public String title()
{
return "Video playback";
}
public String note()
{
return "A native surface over the canvas, like the map. Ask the capability first: all five"
+ " platforms have it now, and one that does not says so instead of showing black.";
}
public void build(Component body) throws Exception
{
boolean has = PlatformUtils.isSupported(Capability.VIDEO_PLAYBACK);
answer = label(has ? "Ready." : "This platform cannot play video in-app yet.", 1f, false,
has ? Styles.captionColor : Tones.danger(), Position.START);
answer.testTag("video.answer");
// Pinned below the scrolling body, so the answer stays in view (see Recipe.pin).
pin(answer);
if (!has)
{
// The whole point of the screen when the answer is no: say which platform and why, rather
// than building a player that would refuse in its constructor.
Card c = new Card();
c.setLayout(new VerticalLayout(Position.START, Position.START));
c.add(LAY_AUTO, label("No VIDEO_PLAYBACK on " + dev.crossfly.device.Device.platform, 1.2f,
true, Styles.textColor, Position.START));
c.add(LAY_AUTO, label("The engine exists on every platform — ExoPlayer, AVPlayer, the"
+ " system web engine — and nothing is wired to it here yet. An app asks first and"
+ " offers a download instead.", 1f, false, Styles.captionColor, Position.START));
body.add(LAY_AUTO, c);
return;
}
player = new VideoPlayer();
player.testTag("video.player");
player.onError(msg -> said("Could not play: " + msg));
player.onReady(ms ->
{
said("Ready.");
duration = ms;
refresh();
});
player.onEnded(() ->
{
said("Ended.");
refresh();
});
// A FRACTION, never LAY_AUTO. A native surface has no intrinsic height -- it is positioned by
// the layout and drawn by the platform -- so auto-height resolves to zero and takes the whole
// body down with it. Measured on Android on 18/09/2026, the first day any platform reaching
// this branch had video at all: "recipe.body COLLAPSED 0x0, video.answer COLLAPSED 0x0".
body.add(.5f, player);
// The control bar of any player: play/pause in one button, the time, a seek bar, sound and
// repeat. Each toggle shows its STATE in its icon, so the screen answers "is it muted?" without
// a status line.
playPause = iconButton(PLAY, "video.play", true, () ->
{
if (player.isPlaying())
player.pause();
else
{
// At the end, Play starts over, as every player does.
if (duration > 0 && player.position() >= duration - 250)
player.seekTo(0);
player.play();
}
refresh();
});
time = label("0:00 / 0:00", 1f, false, Styles.captionColor, Position.CENTER);
time.testTag("video.time");
seek = new Slider(0, 1, 0);
seek.testTag("video.seek");
seek.onAction(c ->
{
// The timer below moves the bar by writing its value, which fires no action: only a person
// dragging or tapping it gets here.
if (duration > 0)
player.seekTo(Math.round(seek.value * duration));
refresh();
});
mute = iconButton(SOUND_ON, "video.mute", false, () ->
{
// Muted is not the same as volume 0 to a browser: only muted earns the right to start
// without a user gesture. setVolume(0) asks for both.
muted = !muted;
player.setVolume(muted ? 0 : 1);
refresh();
});
loop = iconButton(REPEAT, "video.loop", false, () ->
{
looping = !looping;
player.setLooping(looping);
refresh();
});
Component bar = new Component();
bar.setLayout(new GridLayout(Position.START, Position.CENTER));
bar.add(0f, 0f, 1f, 0f, 0f, playPause, outgap(time, .5f, 0, .5f, 0), seek, outgap(mute, .5f, 0, 0, 0), outgap(loop, .3f, 0, 0, 0));
body.add(LAY_AUTO, outgap(bar, 0, .6f, 0, 0));
// Loaded with the screen: a player shows its first frame and its length before anyone asks.
// Playing still waits for a tap -- a browser refuses sound without a gesture, and a video
// that starts talking because a screen opened is a surprise nobody wants.
player.load(source());
said("Loading…");
// About every frame: at four updates a second the bar visibly jumped along (13/10/2026).
ticker = addTimer(this::refresh, 1 / 30f);
}
public void onRemove()
{
super.onRemove();
if (ticker != null)
ticker.cancel();
}
/** Brings the bar in line with the player: the icons, the time and the position. */
private void refresh()
{
if (player == null)
return;
boolean playing = player.isPlaying();
setIcon(playPause, playing ? PAUSE : PLAY);
setIcon(mute, muted ? SOUND_OFF : SOUND_ON);
setIcon(loop, looping ? REPEAT_ON : REPEAT);
int pos = Math.max(0, player.position());
if (duration <= 0)
duration = player.duration();
time.setText(clock(pos) + " / " + clock(duration));
if (duration > 0)
{
seek.value = Math.min(1f, pos / (float) duration);
seek.setValidLayout(false);
}
Screen.repaint();
}
private static String clock(int ms)
{
int s = Math.max(0, ms) / 1000;
return s / 60 + ":" + (s % 60 < 10 ? "0" : "") + s % 60;
}
/**
* A round icon button: ComponentStyle.circle sizes it square and rounds it whole, which is the
* shape every player's controls have. All three the SAME size: the bar's row takes the height of
* its tallest child and stretches the others, so a larger play button turned the two beside it
* into tall rounded rectangles (13/10/2026). The main one is filled instead.
*/
private Button iconButton(int codepoint, String tag, boolean main, Runnable go) throws Exception
{
Button b = new Button(MaterialIcon.of(codepoint, 1.4f));
ButtonStyle st = dev.crossfly.ui.skin.Skin.get()
.button()
.pressable()
.circle(1.3f);
if (main)
st.backColor(Styles.primaryColor)
.iconColor(0xFFFFFFFF)
.borderColor(0);
b.style(st);
b.testTag(tag);
b.onAction(e -> go.run());
return b;
}
private static void setIcon(Button b, int codepoint)
{
b.icon(MaterialIcon.of(codepoint, 1.4f), b.textPos);
b.setValidLayout(false);
}
private void said(String what)
{
// To the log as well as to the label, for the same reason R31Files does it: this is a screen
// people drive by hand to find out whether a native capability actually worked, and a verdict
// that exists only as pixels is unreadable over ssh or on a device.
Debug.log("[video] " + what);
answer.setText(what);
answer.invalidateMeasure();
Screen.repaint();
}
}package dev.cf.recipe;
import dev.cf.*;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.skin.*;
import dev.crossfly.ui.style.*;
import static dev.crossfly.ui.Widgets.*;
/**
* Base of a <b>recipe</b>: one screen that shows one thing, and nothing else.
*
* <p>A recipe is not a sample. A sample shows a component with all its knobs; a recipe answers a
* question somebody actually has — "how do I put a filter sheet over a list?", "how do I make this
* follow the theme?" — in as few lines as it takes, so the answer can be read in one screen and
* copied without editing.</p>
*
* <p>That is the whole design constraint: <b>the boilerplate lives here, the substance lives in the
* subclass.</b> A recipe that spends twenty lines building a title bar teaches the title bar.</p>
*
* <p>Write one as:</p>
*
* <pre>
* public class MyRecipe extends Recipe
* {
* public String title() { return "What this shows"; }
* public String note() { return "The one sentence worth knowing."; }
*
* public void build(Component body) throws Exception
* {
* body.add(LAY_AUTO, new Button("Tap"));
* }
* }
* </pre>
*/
public abstract class Recipe extends SampleWindow implements Described
{
@Override
public abstract String title();
/**
* The one thing a reader should take away — printed above the recipe, in caption tone.
*
* @return the note, or null for none
*/
@Override
public String note()
{
return null;
}
/**
* Builds the recipe.
*
* @param body a vertical, scrollable container with sensible padding — add to it and stop
* @throws Exception when building fails
*/
public abstract void build(Component body) throws Exception;
@Override
public void onAdd(boolean firstTime) throws Exception
{
addTopBar(title());
// The page, not the surface. addTopBar just set fullscreenColor to the surface,
// and on a white page a white card has no edges — see Tones.page(). Re-derived on restyle so
// a dark-mode flip does not leave the light page behind.
fullscreenColor = Tones.page();
onRestyle(() -> fullscreenColor = Tones.page());
contents.setLayout(new VerticalLayout(Position.START, Position.START));
contents.getStyle()
.ingap(Gap.ofFont(1f, 1f, 1f, 1f));
String n = note();
if (n != null)
contents.add(LAY_AUTO, outgap(nota(n), 0, 0, 0, 1f));
Component body = new Component();
body.setLayout(new VerticalLayout(Position.START, Position.START));
body.setScrollable(ScrollDirection.VERTICAL);
body.testTag("recipe.body");
contents.add(LAY_FILL, body);
build(body);
if (pinned != null)
{
// Measured again once its labels have sizes: the band's AUTO height is taken in the first
// layout, before them, and on the iPhone it showed only its first line - the odometer's
// "No text recognizer on this device." was set and never seen (17/10/2026).
final Component band = pinned;
onUI(band::invalidateMeasure);
}
if (pinned != null)
body.add(LAY_AUTO, new Component()
{
@Override
public int getAutoHeight()
{
// Room for the last row to scroll clear of the floating band.
return pinned.getAutoHeight();
}
});
}
/**
* The note, as a tinted band rather than three lines of grey.
*
* <p>It is the one sentence the reader is meant to leave with, and it used to be the least
* legible thing on the screen: caption-coloured, caption-sized, indistinguishable from the
* captions further down that are only labels. A tone and a glyph cost nothing here — nobody has
* to read the band to know what it is.</p>
*
* @param n the note text
* @return the band
* @throws Exception when building fails
*/
/**
* Pins {@code c} to the bottom of the screen, in a band that FLOATS over the scrolling body: it
* stays on screen however far the body is scrolled, and the body scrolls underneath it. That is the
* place a recipe says what just happened. An answer at the top of the body went out of view as soon
* as the person scrolled down to the button that produced it (14/10/2026).
* <p>
* The band is 80% OPAQUE (the surface at alpha 0xCC), as the user asked; it floats rather than
* sitting below the body because a transparency with nothing behind it cannot be seen -- the first
* version had the band under the body, and the person saw a plain bar. The body gets a spacer as
* tall as the band at its end, so its last row can still scroll clear of it.
*
* @param c the component to pin, usually the answer label
* @return {@code c}, to chain
* @throws Exception if the layout refuses
*/
protected <T extends Component> T pin(T c) throws Exception
{
Component band = new Component();
band.setLayout(new VerticalLayout(Position.START, Position.START));
band.style(Skin.get()
.card()
.backColor(Styles.backColor.withAlpha(0xCC))
.borderColor(Styles.borderColor.withAlpha(0x33))
.borderRadius(0)
.shadow(0, 0, 0, 0)
.ingap(Gap.ofFont(1f, 1f, 1f, 1.2f))
.outgap(Gap.ofFont(0)));
band.add(LAY_AUTO, c);
// Floated: positioned at the bottom, full width, over whatever the body has there.
band.setRelativeBounds(Relative.START, Relative.END, Relative.FULL, Relative.AUTO);
contents.add(LAY_AUTO, band);
pinned = band;
return c;
}
/** The floating band {@link #pin} made, or null; the body ends in a spacer as tall as it. */
private Component pinned;
private Component nota(String n) throws Exception
{
Component band = new Component();
band.setLayout(new GridLayout(Position.START, Position.START));
band.style(Skin.get()
.card()
.backColor(Tones.accent().withAlpha(0x16))
.borderColor(Tones.accent().withAlpha(0x4A))
.shadow(0, 0, 0, 0)
.outgap(Gap.ofFont(0)));
// 0xE88E is the outlined "info". The glyph column hugs; the text takes the rest and wraps.
// The weights are GridLayout's own — 0f hugs, 1f takes what is left. LAY_FILL belongs to the
// vertical and horizontal layouts and means nothing here: it collapsed the text to nothing.
band.add(LAY_AUTO, 1f, matglyph(0xE88E, Tones.accent(), 1.05f),
outgap(label(n, 1f, false, secondary(), Position.START), .5f, 0, 0, 0));
return band;
}
}package dev.cf;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;
import static dev.cf.sample.CrossFlySamplesColors.*;
/**
* Base class for all CrossFly sample windows.
* <p>
* Provides common infrastructure:
* <ul>
* <li>Shared color palette and styles from {@link dev.cf.sample.CrossFlySamplesColors}</li>
* <li>{@link #addTopBar(String, Button...)} — standard deep-indigo top bar with back button</li>
* <li>{@link #barButton(String, Runnable)} — helper to create icon buttons for the bar</li>
* </ul>
*
* <h3>Usage</h3>
* <pre>
* public class MySample extends SampleWindow {
* public void onAdd(boolean firstTime) throws Exception {
* if (!firstTime) return;
* initWindow();
* addTopBar("My Sample");
* // ... add content
* }
* }
* </pre>
*/
public abstract class SampleWindow extends Window
{
/** The main content area of the window, added by {@link #addTopBar(String, Button...)}, for subclasses to add their content into. */
protected Component contents;
// ─── Top bar ─────────────────────────────────────────────────────────────
/**
* Builds and adds a standard top bar with a back (←) button on the left,
* a centered title, and any optional extra buttons on the right.
*
* @param title the text shown in the bar
* @param rightButtons zero or more icon buttons placed aligned to the right
* @throws Exception if component construction fails
*/
protected void addTopBar(String title, Button... rightButtons) throws Exception
{
fullscreenColor = Styles.backColor.color();
setLayout(new VerticalLayout());
add(0f, buildTopBar(title, rightButtons));
add(LAY_FILL, contents = new Component());
contents.getStyle()
.ingap(Gap.ofFont(0, .5f, 0, .5f));
}
/**
* Same as {@link #addTopBar(String, Button...)}, but returns the bar component
* instead of adding it, for callers that need to position it manually.
*
* @param title the bar title
* @param rightButtons optional right-side icon buttons
* @return the assembled top-bar {@link Component}
* @throws Exception if component construction fails
*/
protected Component buildTopBar(String title, Button... rightButtons) throws Exception
{
Button btBack = barButton("\ue5c4", this::hide); // arrow_back
btBack.testTag("sample.back");
// Padded on every side, like the bar's right buttons: with none above and below, the target
// was the arrow itself, too small to hit on a small phone (iPhone 5s, 17/10/2026).
btBack.getStyle()
.ingap(Gap.ofFont(.75f))
.outgap(Gap.ofFont(0, 0, .25f, 0));
Component bar = new Component().style(barStyle);
bar.setLayout(new GridLayout(Position.START, Position.CENTER));
int n = 2 + rightButtons.length;
Object[] params = new Object[n * 2];
params[0] = 0f;
params[1] = 1f;
for (int i = 0; i < rightButtons.length; i++)
params[2 + i] = 0f;
params[n] = btBack;
params[n + 1] = new Label(title).style(barTitleStyle);
for (int i = 0; i < rightButtons.length; i++)
{
params[n + 2 + i] = rightButtons[i];
rightButtons[i].getStyle()
.ingap(Gap.ofFont(.75f));
}
bar.add(params);
return bar;
}
/**
* Creates a standard bar icon button (transparent, white icon, pressed feedback).
*
* @param materialIcon the Material Icon unicode codepoint string
* @param action the action to run when the button is tapped
* @return the configured {@link Button}
*/
protected Button barButton(String materialIcon, Runnable action)
{
return new Button(new MaterialIcon(materialIcon)).style(barButtonNormal)
.pressedStyle(barButtonPressed)
.onAction(e -> action.run());
}
}本页的机器可读版本,让模型直接读取示例集,而不是猜测 API。