Elements

Video

A video view plays a video file or stream with the platform’s player: AVPlayer on iOS and Mac Catalyst, MediaPlayer on Android. Playback is controlled with props (paused, muted, rate, …), so the player follows your state like any other view. Rust makes one with video(url), TSX with <Video> from nsh.

el-video.tsx
el-video.tsxLive
import { mount, signal, Text, Video, type VideoRef, View } from "nsh";
import { windowSize } from "nsh";
import { button, buttonText, ui } from "./palette";

// A short CC0 clip from MDN's examples.
const FLOWER = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4";

function Toggle(props: { label: string; on: boolean; onPress: () => void }) {
  return (
    <View
      onPress={props.onPress}
      {...button("secondary", { on: () => props.on, size: "sm" })}
    >
      <Text style={buttonText("secondary", { on: () => props.on })}>{props.label}</Text>
    </View>
  );
}

function App() {
  const paused = signal(true);
  const muted = signal(true);
  const repeat = signal(true);
  const controls = signal(false);
  const fast = signal(false);
  const time = signal(0);
  const duration = signal(0);
  const ratio = signal(16 / 9);
  const status = signal("loading");
  const ends = signal(0);
  let video!: VideoRef;

  const wide = () => windowSize().width >= 700;

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>video</Text>
      <View style={{ flex: 1, flexDirection: wide() ? "row" : "column", gap: 16 }}>
        <View style={{ flex: wide() ? 1 : undefined, justifyContent: "center" }}>
          <Video
            ref={video}
            src={FLOWER}
            paused={paused()}
            muted={muted()}
            repeat={repeat()}
            controls={controls()}
            rate={fast() ? 2 : 1}
            progressUpdateInterval={100}
            style={{ width: "100%", aspectRatio: ratio(), borderRadius: 14, overflow: "hidden", backgroundColor: "#000000" }}
            onLoadStart={() => status.set("onLoadStart")}
            onLoad={(e) => {
              duration.set(e.duration);
              ratio.set(e.size.width / e.size.height);
              status.set(`onLoad ${e.size.width} × ${e.size.height}`);
            }}
            onProgress={(e) => time.set(e.currentTime)}
            onBuffer={(e) => status.set(e.isBuffering ? "buffering" : "playing")}
            onEnd={() => ends.update((n) => n + 1)}
            onError={(e) => status.set(`onError ${e.message}`)}
          />
        </View>

        <View style={{ gap: 12, width: wide() ? 300 : undefined, justifyContent: "center" }}>
          <View style={{ ...ui().inset, height: 6, borderRadius: 3, overflow: "hidden" }}>
            <View style={{ height: 6, width: `${duration() ? (time() / duration()) * 100 : 0}%`, backgroundColor: ui().accent }} />
          </View>
          <Text style={{ color: ui().text, fontFamily: "monospace" }}>
            onProgress {time().toFixed(1)} / {duration().toFixed(1)} s
          </Text>
          <Text style={{ color: ui().muted, fontFamily: "monospace", fontSize: 13 }}>
            {status()} · onEnd {ends()}×
          </Text>
          <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
            <Toggle label={paused() ? "Play" : "Pause"} on={!paused()} onPress={() => paused.set(!paused())} />
            <Toggle label="muted" on={muted()} onPress={() => muted.set(!muted())} />
            <Toggle label="repeat" on={repeat()} onPress={() => repeat.set(!repeat())} />
            <Toggle label="rate 2" on={fast()} onPress={() => fast.set(!fast())} />
            <Toggle label="controls" on={controls()} onPress={() => controls.set(!controls())} />
            <Toggle label="seek(0)" on={false} onPress={() => video.seek(0)} />
          </View>
        </View>
      </View>
    </View>
  );
}

mount(App);
use nsh_core::prelude::*;

fn player() -> impl IntoElement {
    let paused = signal(true);
    let time = signal(String::new());
    let player = element_ref();
    view()
        .gap_3()
        .child(
            video("https://example.com/clip.mp4")
                .element_ref(&player)
                .paused(paused)
                .repeat(true)
                .object_fit(ResizeMode::Contain)
                .h(px(220.0))
                .bg(black())
                .on_progress(move |p: &VideoProgress| {
                    time.set(format!("{:.1} / {:.1} s", p.current_time, p.duration))
                }),
        )
        .child(
            view()
                .flex_row()
                .gap_4()
                .child(
                    text(move || if paused.get() { "Play" } else { "Pause" })
                        .on_press(move || paused.update(|p| *p = !*p)),
                )
                .child(text("Restart").on_press(move || player.seek(0.0)))
                .child(text(move || time.get())),
        )
}

In TSX src is required (see the Video API). See the web view and video guide.

#Props

A video takes the props of a view (it has no children) plus:

PropRustTypeDefaultDescription
srcvideo(url)stringThe video’s URL (in Rust, an asset’s uri too: video(CLIP.uri)).
paused.paused(..)booleanfalsePlayback is paused.
muted.muted(..)booleanfalseNo sound.
volume.volume(..)number10..1.
rate.rate(..)number1The playback speed.
repeat.repeat(..)booleanfalsePlays again from the start at the end.
controls.controls(..)booleanfalseShows the platform’s playback controls.
objectFit (style), object-* classes.object_fit(ResizeMode::Contain)"cover" | "contain" | "fill" | "none" | "scale-down""contain"How the video fits its box, as CSS’s object-fit (class="object-cover"). iOS, macOS and Android play "none" and "scale-down" as "contain", Linux both as "scale-down".
progressUpdateInterval.progress_interval(ms)number250Milliseconds between onProgress events.

Give the video a size through its styles; its size is the one its styles give it. Use the size onLoad reports to set an aspect ratio when you want the video’s own proportions.

#Events

EventRustPayloadWhen
onLoadStart.on_load_startnoneLoading began.
onLoad.on_load{ duration, size } (VideoInfo: duration, size)The video is ready: its duration in seconds and its size in pixels.
onProgress.on_progress{ currentTime, duration } (VideoProgress: current_time, duration)Every progressUpdateInterval while playing (seconds).
onBuffer.on_buffer{ isBuffering } (bool)Playback stalled for data / resumed.
onEnd.on_endnonePlayback reached the end (with repeat, at the end of each loop).
onError.on_error{ message, code? } (&str, the message)Loading or playback failed; code is "ERR_ACCESS_DENIED" for a source the app’s capabilities do not grant.
let ratio = signal(16.0_f32 / 9.0);

video(url)
    .w_full()
    .aspect_ratio(ratio)
    .on_load(move |info: &VideoInfo| {
        ratio.set(info.size.width.0 as f32 / info.size.height.0 as f32)
    })

#Ref methods

MethodRustDescription
seek(seconds)seek(seconds)Jumps to a time.

#Capabilities

When the app declares capabilities, video sources load from the hosts network allows; a source on another host shows an empty view and reports onError with code "ERR_ACCESS_DENIED".

#Platform notes

  • iOS draws the video in an AVPlayerLayer, or in the system player view with controls; AVFoundation is loaded the first time a video is created.
  • Android plays with MediaPlayer on a TextureView, with a MediaController for controls.