Qué muestra esto
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.
Ejecutarlo en tu máquina
Cada ejemplo es una Window normal, así que el lanzador solo necesita su nombre.
gradlew lwjgl3:run --args="classname dev.cf.recipe.R32Video"Java se compila antes de ejecutarse, así que el navegador no puede reconstruir una edición por su cuenta: el código que ves aquí se convirtió en JavaScript con TeaVM antes de publicarse. Copia el archivo a un proyecto y funciona sin cambios en las cuatro plataformas; el comando de arriba es toda la preparación.
Qué usa
Tipos del framework que toca este ejemplo. Los enlazados tienen página de referencia.
El código
Completo, sin recortes. Es el archivo, no un fragmento.
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());
}
}Versiones de esta página legibles por máquina, para que un modelo lea el estante en vez de adivinar la API.