Saltar al contenido
CrossFly es 100% open sourceSíguenos en GitHub
CrossFlyCrossFly
Empezar
Todos los ejemplos
RecetasDispositivomedia284 líneas · 1 archivos

Video playback

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.

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"
Editarlo

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.

Ejecutándose aquíCrossFly 0.1.32
Iniciando la aplicación…

Compilado a JavaScript por TeaVM: es el mismo código, no un vídeo de él. · Todos los ejemplos

Qué usa

Tipos del framework que toca este ejemplo. Los enlazados tienen página de referencia.

ButtonButtonStyleCapabilityCardComponentDebugDeviceGridLayoutLabelMainMaterialIconPlatformUtilsScreenSkinSliderStylesUtilsVerticalLayoutVideoPlayerWidgets

El código

Completo, sin recortes. Es el archivo, no un fragmento.

Crudo
dev/cf/recipe/R32Video.java
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();
   }
}
Para una IA

Versiones de esta página legibles por máquina, para que un modelo lea el estante en vez de adivinar la API.

Este ejemplo, en JSONEste ejemplo, en Markdown
AnteriorFiles, sharing and printingSiguienteEmbedded web page