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