the one video element: when it plays, what it rests on, captions, one sound at a time
npx shadcn add @ag/videoAlready installed? Preview an update
npx shadcn add @ag/video --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-13
- The poster is one
Image:optimizePostergoes through the site's loader,posterArrival="instant"ispriority, and the blur is drawn under it either way.
also called subtitles
shelf media
uses image, clip, timebase, playback, sound-source, audible, material, backdrop, water
used by glass-display, film-mosaic, macos, tweet, telegram-chat, lightbox
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { PlaylistProvider, usePlaylist } from "@/registry/base-nova/ui/playback"
import {
PlayerBar,
PlayerCaptions,
PlayerChrome,
PlayerFoot,
PlayerFullscreen,
PlayerGestures,
PlayerKeys,
PlayerKeysCard,
PlayerPlaylist,
PlayerPlaylistButton,
PlayerSound,
PlayerSpeed,
PlayerTransport,
PlayerZoomFinder,
} from "@/registry/base-nova/ui/player"
import { PreviewPicker } from "@/registry/base-nova/ui/preview-picker"
import { TooltipHint } from "@/registry/base-nova/ui/tooltip"
import { Video, type VideoPlay } from "./video"
import { VideoPicture, VideoPlayback } from "./video-playback"
/** One choice, its legend above it; an option that needs it explains itself on hover. */
function Choice<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: readonly {
value: T
label: string
hint?: string
disabled?: boolean
}[]
onChange: (value: T) => void
}) {
return (
<div className="flex min-w-0 flex-col items-start gap-1">
<span className="font-mono text-[11px] text-muted-foreground">
{label}
</span>
<PreviewPicker
label={label}
value={value}
options={options}
onChange={onChange}
size="compact"
/>
</div>
)
}
// #region player
/** What it plays: a film with no words, or a speech captioned in two languages (a
* WebVTT file each, English shown to whoever never chose). */
const PLAYED = {
bunny: {
src: "/bunny.mp4",
poster: "/video-poster.jpg",
width: 1280,
height: 720,
fps: 25,
label: "Big Buck Bunny",
},
moon: {
src: "/kennedy/moon.mp4",
poster: "/kennedy/moon.jpg",
width: 640,
height: 480,
label: "We choose to go to the Moon",
captions: [
{
src: "/kennedy/moon.en.vtt",
lang: "en",
label: "English",
default: true,
},
{ src: "/kennedy/moon.es.vtt", lang: "es", label: "español" },
],
},
}
/** The player, every choice it offers one row above it: what it plays, what it is
* made of, where its controls sit, the transport, what a swipe does, how the time
* first reads, and whether it starts by itself. */
function ThePlayer() {
const [film, setFilm] = React.useState<keyof typeof PLAYED>("bunny")
const [material, setMaterial] = React.useState<"plain" | "glass">("plain")
const [controls, setControls] = React.useState<"over" | "under">("over")
const [transport, setTransport] = React.useState<"on" | "off">("on")
const [gestures, setGestures] = React.useState<"off" | "scrub" | "all">(
"scrub",
)
const [starts, setStarts] = React.useState<"manual" | "visible">("manual")
const [zoom, setZoom] = React.useState<
"off" | "finder over" | "finder beside"
>("finder over")
// Glass and the transport belong over the picture: under it, on the page, there is
// nothing behind the glass and no middle to hold a transport.
const over = controls === "over"
const face = over ? material : "plain"
const played = PLAYED[film]
// Its parts take the material of the chrome they sit in (over), else the page's.
const bar = (
<PlayerBar
label={played.label}
chapters="hover"
playButton={!(over && transport === "on")}
>
<PlayerCaptions />
<PlayerSpeed />
<PlayerSound />
<PlayerFullscreen />
</PlayerBar>
)
return (
<div className="space-y-5">
<div className="flex flex-wrap gap-x-5 gap-y-2">
<Choice
label="film"
value={film}
onChange={setFilm}
options={[
{ value: "bunny", label: "bunny", hint: "a film with no words" },
{
value: "moon",
label: "kennedy",
hint: "a speech captioned in English and Spanish: the captions button picks one or none, C turns them on and off, and the choice is kept for the next video",
},
]}
/>
<Choice
label="material"
value={over ? material : "plain"}
onChange={setMaterial}
options={[
{
value: "plain",
label: "plain",
hint: "plain controls on a scrim",
},
{
value: "glass",
label: "glass",
hint: "glass controls; only over the picture, where there is something behind the glass",
disabled: !over,
},
]}
/>
<Choice
label="controls"
value={controls}
onChange={setControls}
options={[
{
value: "over",
label: "over",
hint: "over the picture, shown on hover and while paused",
},
{
value: "under",
label: "under",
hint: "under the picture, always there",
},
]}
/>
<Choice
label="centre"
value={over ? transport : "off"}
onChange={setTransport}
options={[
{
value: "on",
label: "buttons",
hint: "back 10 s, play, forward 10 s in the middle of the picture (and the chapters, when there are some)",
disabled: !over,
},
{ value: "off", label: "none" },
]}
/>
<Choice
label="gestures"
value={gestures}
onChange={setGestures}
options={[
{ value: "off", label: "off" },
{
value: "scrub",
label: "sideways",
hint: "swipe sideways to scrub, pinch for fullscreen",
},
{
value: "all",
label: "all",
hint: "sideways too, and up and down: volume on the left half, speed on the right",
},
]}
/>
<Choice
label="starts"
value={starts}
onChange={setStarts}
options={[
{ value: "manual", label: "press", hint: "the person starts it" },
{
value: "visible",
label: "auto",
hint: "plays by itself while on screen, muted until someone turns the sound on",
},
]}
/>
<Choice
label="zoom"
value={zoom}
onChange={setZoom}
options={[
{ value: "off", label: "off" },
{
value: "finder over",
label: "finder over",
hint: "pinch or ctrl-scroll at a point, + and - step; while zoomed, drag or scroll to look around. The finder shows where, over the corner",
},
{
value: "finder beside",
label: "finder beside",
hint: "the same zoom, its finder on the page under the player, always there",
},
]}
/>
<span className="flex items-center gap-1 self-start font-mono text-[11px] text-muted-foreground">
keys
<TooltipHint
card
label="the player's keys"
content={
<PlayerKeysCard
zoom={zoom !== "off"}
captions={"captions" in played}
/>
}
/>
</span>
</div>
<VideoPlayback
key={`${film}-${starts}-${zoom !== "off"}`}
{...played}
play={starts}
loop
zoom={zoom !== "off"}
>
{/* Controls over it: the picture alone goes fullscreen, its bar inside it. */}
<VideoPicture fullscreen={over}>
{gestures !== "off" && (
<PlayerGestures vertical={gestures === "all"} />
)}
{zoom === "finder over" && <PlayerZoomFinder material={face} />}
{controls === "over" && (
<PlayerChrome material={face}>
{transport === "on" && <PlayerTransport />}
<PlayerFoot>{bar}</PlayerFoot>
</PlayerChrome>
)}
</VideoPicture>
{controls === "under" && bar}
{zoom === "finder beside" && (
<PlayerZoomFinder floating={false} always share={0.3} />
)}
<PlayerKeys />
</VideoPlayback>
</div>
)
}
// #endregion
// #region playlist
const FILMS = [
{
label: "Big Buck Bunny",
src: "/bunny.mp4",
poster: "/video-poster.jpg",
duration: 8000,
width: 1280,
height: 720,
fps: 25,
},
{
label: "Adrian Galilea",
src: "/adriangalilea.mp4",
poster: "/adriangalilea.jpg",
duration: 2733,
width: 304,
height: 304,
},
// An item carries its own captions: the player offers them while it plays.
{
label: "Ask not",
src: "/kennedy/ask-not.mp4",
poster: "/kennedy/ask-not.jpg",
duration: 17_518,
width: 640,
height: 480,
captions: [
{
src: "/kennedy/ask-not.en.vtt",
lang: "en",
label: "English",
default: true,
},
{ src: "/kennedy/ask-not.es.vtt", lang: "es", label: "español" },
],
},
]
/** The player keyed by the item, so each film starts fresh; it plays at once when the
* person got to it by watching, and its end goes on to the next. A 16:9 stage, so a
* square film fits inside it whole and the player keeps its size between films. */
function Films({ list }: { list: "side" | "card" | "glass" }) {
const playlist = usePlaylist<(typeof FILMS)[number]>()
if (!playlist) throw new Error("Films: place it inside a PlaylistProvider")
const film = playlist.item
const face = list === "glass" ? "glass" : "plain"
const bar = (
<PlayerBar label={film.label} playButton={false}>
{list !== "side" && <PlayerPlaylistButton />}
<PlayerCaptions />
<PlayerSpeed />
<PlayerSound />
<PlayerFullscreen />
</PlayerBar>
)
return (
<VideoPlayback
key={playlist.index}
{...film}
autoPlay={playlist.continuing}
onEnded={playlist.advance}
zoom
>
<VideoPicture aspect="16 / 9">
<PlayerZoomFinder material={face} />
<PlayerChrome material={face}>
<PlayerTransport />
<PlayerFoot>{bar}</PlayerFoot>
</PlayerChrome>
</VideoPicture>
</VideoPlayback>
)
}
/** Where the playlist shows: a list beside the player, or a card from the bar. */
function ThePlaylist() {
const [list, setList] = React.useState<"side" | "card" | "glass">("side")
return (
<div className="space-y-5">
<Choice
label="list"
value={list}
onChange={setList}
options={[
{ value: "side", label: "beside" },
{ value: "card", label: "card" },
{ value: "glass", label: "glass card" },
]}
/>
<PlaylistProvider items={FILMS}>
<div
className={
list === "side" ? "grid gap-4 md:grid-cols-[1fr_15rem]" : undefined
}
>
<Films list={list} />
{list === "side" && <PlayerPlaylist />}
</div>
</PlaylistProvider>
</div>
)
}
// #endregion
// #region element
/** The bare element, every `play` and `rest` it knows: when it plays by itself, and
* what it shows when it stops. "page" hands the decision to a button of the page's. */
function TheElement() {
const [play, setPlay] = React.useState<VideoPlay>("hover")
const [rest, setRest] = React.useState<"hold" | "poster">("hold")
const [playing, setPlaying] = React.useState(false)
return (
<div className="space-y-5">
<div className="flex flex-wrap gap-x-5 gap-y-2">
<Choice
label="play"
value={play}
onChange={setPlay}
options={[
{
value: "hover",
label: "hover",
hint: "plays while looked at (on touch, while on screen)",
},
{
value: "visible",
label: "visible",
hint: "plays while on screen",
},
{
value: "once",
label: "once",
hint: "plays once on arrival, then again on a fresh hover",
},
{
value: "follow",
label: "follow",
hint: "plays when the page says so (the choice on the right), muted until someone turns the sound on",
},
{
value: "manual",
label: "manual",
hint: "the person decides, with the browser's own controls",
},
]}
/>
<Choice
label="rest"
value={rest}
onChange={setRest}
options={[
{
value: "hold",
label: "hold",
hint: "stopped, it holds the frame it reached",
},
{
value: "poster",
label: "poster",
hint: "stopped, it goes back to the start and the poster",
},
]}
/>
<Choice
label="the page says"
value={playing ? "play" : "hold"}
onChange={(v) => setPlaying(v === "play")}
options={[
{
value: "hold",
label: "hold",
disabled: play !== "follow",
},
{
value: "play",
label: "play",
hint: "what a page's own clock or button would say; only under follow",
disabled: play !== "follow",
},
]}
/>
</div>
<Video
key={`${play}-${rest}`}
src="/bunny.mp4"
poster="/video-poster.jpg"
width={1280}
height={720}
play={play}
playing={playing}
rest={rest}
loop
pauseButton={play !== "manual" && play !== "follow"}
label="Big Buck Bunny"
className="w-full rounded-xl"
/>
</div>
)
}
// #endregion
export default function Demo() {
return (
<div className="space-y-8">
<Sample name="player" glance="large" title="the player" with="player">
<ThePlayer />
</Sample>
<Sample name="playlist" title="a playlist" with="playlist">
<ThePlaylist />
</Sample>
<Sample name="element" title="the element" with="element">
<TheElement />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// The one video element. Every `<video>` the registry draws is this one, so the three
// questions every video answers are answered once:
//
// WHEN IT PLAYS: `play`.
// "manual" the person decides (the browser's bar with `controls`, or a player
// around it). The default.
// "hover" while the pointer or focus is on it (or on `interactionRef`, the card
// it belongs to); on touch, while it is on screen.
// "visible" while it is on screen.
// "once" on arriving on screen, then again only on a fresh hover or focus.
// "follow" the page decides: `playing` says whether, and `at` (ms), when given,
// the instant it must show (a stage whose clock moves the video). It is
// held unseen until it shows that instant, the poster standing in.
// Automatic plays ("hover", "visible", "once") wait out reduced motion and a
// hidden tab, load ahead before they are seen, and a pause from `pauseButton`
// survives them. The sound of every video the person did not start (automatic or
// followed) is one decision for the page: they start muted, and unmuting one (its
// `controls`) turns the sound on for all of them; with sound on, one pauses when
// the window loses focus.
// WHAT STANDS IN BEFORE ITS FIRST FRAME: the poster, at the frame's aspect when
// `width` and `height` are given (the space is reserved), the video fading in once
// frames arrive. Without them the frame fills a parent the caller sizes.
// WHAT IT SHOWS WHEN IT STOPS: `rest`. "hold" stays on the frame it reached; "poster"
// goes back to the start and the poster.
// WHAT PART OF IT PLAYS: all of it, or `span`: it starts at the span's start and goes
// back there at its end, round and round (a tile of a film under the hand), the
// start being where it rests under "poster" too.
//
// And what every video owes the page: one voice at a time (lib/audible: a video that
// starts with sound claims the page's one floor, and whatever held it, another video,
// a gallery cell, a reading, is silenced; `voice` names the voice a video speaks for,
// so a reading and its own video claim as one), and it releases its media when it leaves
// (pause, drop the source, reload empty), because a detached element that was
// playing keeps its sound by the spec's own rule. The source is set by an effect, not
// by the attribute, so a remount (React's dev double mount) puts back what the
// release took.
import {
type ComponentProps,
type RefObject,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { cn } from "@/lib/utils"
import { claim, type VoiceKey } from "@/registry/base-nova/lib/audible"
import type { Span } from "@/registry/base-nova/lib/clip"
import { msOf, seekSeconds } from "@/registry/base-nova/lib/timebase"
import { Image } from "@/registry/base-nova/ui/image"
import { useMediaIntent } from "@/registry/base-nova/ui/image-intent"
import { useWater } from "@/registry/base-nova/ui/water"
export type VideoPlay = "manual" | "hover" | "visible" | "once" | "follow"
export type VideoProps = Omit<
ComponentProps<"video">,
"width" | "height" | "className" | "autoPlay" | "src" | "controls" | "poster"
> & {
src: string
/** The accessible name. */
label: string
/** When it plays (see the header). Default "manual". */
play?: VideoPlay
/** "follow": whether the page wants it playing. */
playing?: boolean
/** "follow": the instant it must show, ms. */
at?: number
/** What it shows when it stops: the frame it reached, or the poster. Default "hold". */
rest?: "hold" | "poster"
/** Where it starts, ms. */
start?: number
/** The part it plays, ms: from the span's start, back there at its end, round and
* round. Not with `start` (the span's start is where it starts) or `at` (the page
* holds the instant then). */
span?: Span
/** The browser's own bar. Default on for "manual". */
controls?: boolean
/** A small play/pause button over an automatic play: the person can always stop it. */
pauseButton?: boolean
/** The frame's intrinsic size: the space is reserved at its aspect before it loads. */
width?: number
height?: number
poster?: string
/** A tiny picture of the poster, drawn blurred under it from the first paint
* until its pixels arrive (Image's placeholder). */
blurDataURL?: string
/** Responsive poster width, using the same sizes syntax as Image. */
sizes?: string
/** Pass the poster through the site's image loader (default). False loads it as
* it is, whatever the loader would do (a CDN frame already at its size). */
optimizePoster?: boolean
/** How the poster arrives: "fade" in once decoded (the registry's picture
* arrival), or "instant", asked for first and in the first paint (a still that
* must stand for the video from the server's HTML on, like a stage resting
* mid-clip; Image's `priority`). */
posterArrival?: "fade" | "instant"
className?: string
videoClassName?: string
/** The element whose hover and focus count as looking at this video (its card). */
interactionRef?: RefObject<HTMLElement | null>
/** The voice it speaks for on the page's one floor (lib/audible): a key shared with
* what it plays for (a reading's), so the two are silenced together, never each
* other. Default: the video alone. */
voice?: VoiceKey
}
/** How far the video may be from `at` and still be showing it, ms. */
const FOLLOW_TOLERANCE = 150
/** The pauses and plays a Video makes itself, marked just before it makes them. */
type Own = { pause: boolean; play: boolean }
/** Marks a pause as ours, only when it will pause something (a mark never outlives
* its event). */
function ownPause(own: Own, el: HTMLVideoElement) {
if (!el.paused) own.pause = true
}
/** Marks a play as ours, only when it will start something. */
function ownPlay(own: Own, el: HTMLVideoElement) {
if (el.paused) own.play = true
}
/** Videos the page silenced for another voice: their pause is the page's, not the
* person's, so they still play again when the person comes back to them. */
const silenced = new WeakSet<HTMLVideoElement>()
/** Whether the page's automatic videos speak: ONE decision for the page. Unmuting any
* of them turns the sound on for all (scrolling on to the next keeps it, without
* asking again), muting any turns it off for all. Which one is heard is the floor's
* (lib/audible): the one that starts with sound silences the rest. */
let pageSound = false
const soundFollowers = new Set<(on: boolean) => void>()
function setPageSound(on: boolean) {
if (pageSound === on) return
pageSound = on
for (const follow of soundFollowers) follow(on)
}
/** How far ahead of the viewport an automatic video starts loading: a play() on a cold
* file drops the poster before a frame is decoded and flashes a half-drawn picture. */
const WARM_MARGIN = "1200px 0px"
/** A refused or interrupted play is the next decision, not an error: a pause landing
* while play() is pending rejects it (AbortError), a low-power mode refuses quiet
* playback (NotAllowedError). Anything else is real and throws. */
function tolerate(error: unknown) {
const expected =
error instanceof DOMException &&
(error.name === "AbortError" || error.name === "NotAllowedError")
if (!expected) throw error
}
/** Plays a page-driven video. Refused with sound (a page load before any press), the
* page's sound turns off until the person turns it on and it plays muted; refused muted
* too (a low-power mode), `refused` hands it to the person, the poster standing in. */
function playOwn(own: Own, el: HTMLVideoElement, refused: () => void) {
ownPlay(own, el)
el.play().catch((e: unknown) => {
tolerate(e)
if (!(e instanceof DOMException && e.name === "NotAllowedError")) return
if (el.muted) return refused()
setPageSound(false)
ownPlay(own, el)
el.play().catch((again: unknown) => {
tolerate(again)
refused()
})
})
}
/** Stops a video the way `rest` says, "poster" going back to `home` (seconds: its
* start, or its span's). True when it went back to the poster. */
function stopAt(
el: HTMLVideoElement,
rest: "hold" | "poster",
home: number,
): boolean {
el.pause()
if (rest !== "poster" || el.readyState === 0 || el.ended) return false
el.currentTime = home
return true
}
export function Video({ src, ...props }: VideoProps) {
return <VideoSource key={src} src={src} {...props} />
}
function VideoSource({
src,
label,
play = "manual",
playing: wanted,
at,
rest = "hold",
start,
span,
controls = play === "manual",
pauseButton = false,
width,
height,
poster,
blurDataURL,
sizes = "100vw",
optimizePoster = true,
posterArrival = "fade",
className,
videoClassName,
interactionRef,
voice,
children,
onPlaying,
onPause,
onEnded,
onError,
ref,
...props
}: VideoProps) {
const frame = useRef<HTMLDivElement>(null)
const video = useRef<HTMLVideoElement>(null)
useImperativeHandle(ref, () => video.current as HTMLVideoElement, [])
// Its claim on the page's one floor while it sounds: given back when it leaves, or
// dropped when another voice takes the floor (it pauses then, a pause that is the
// page's, not the person's).
const floor = useRef<(() => void) | null>(null)
const speak = (el: HTMLVideoElement) => {
floor.current?.()
floor.current = claim(voice ?? el, () => {
floor.current = null
if (el.paused) return
silenced.add(el)
el.pause()
})
}
const automatic = play === "hover" || play === "visible" || play === "once"
const follows = play === "follow"
// Played by something other than the person: its sound is the page's decision.
const pageDriven = automatic || follows
if (follows && wanted === undefined)
throw new Error(`Video "${label}": play="follow" needs \`playing\``)
if (span && (start !== undefined || at !== undefined))
throw new Error(
`Video "${label}": \`span\` says where it starts and what it plays; not with \`start\` or \`at\``,
)
if (span && !(span.to > span.from && span.from >= 0))
throw new Error(
`Video "${label}": span ${span.from}..${span.to} is no part of a video`,
)
// Where it starts and rests, seconds: the span's start, else the top.
const home = span ? seekSeconds(span.from) : 0
const spanFrom = span?.from
const spanTo = span?.to
const intent = useMediaIntent(frame, interactionRef)
const { setManual } = intent
const [playing, setPlaying] = useState(false)
const [failed, setFailed] = useState(false)
// A frame has been shown: from then on, "hold" keeps the video over the poster.
const [shown, setShown] = useState(false)
// The video knows its size (the empty frame's ground can go), and has decoded a
// frame (the poster can go, unless the video is held unseen).
const [sized, setSized] = useState(false)
const [loaded, setLoaded] = useState(false)
// "follow" with `at`: the video has shown an instant asked of it.
const [onInstant, setOnInstant] = useState(false)
const [completedAt, setCompletedAt] = useState<number | null>(null)
// What the automatic plays want right now.
const autoWants = useRef(false)
// The pauses and plays this component makes itself, marked just before it makes
// them (only when the call will change anything, so a mark never outlives its
// event). One it did not make is the person's, and an automatic video takes it as
// the person's decision: a pause holds (nothing automatic plays it again, not a
// return to the window, not scrolling back) until they play it again.
const own = useRef<Own>({ pause: false, play: false })
// Whether the play under way was ours.
const lastPlayOwn = useRef(false)
// The source is the effect's to set and to release (see the header).
useEffect(() => {
const el = video.current
if (!el) throw new Error(`Video "${label}" rendered no element`)
el.src = src
return () => {
floor.current?.()
floor.current = null
el.pause()
el.removeAttribute("src")
el.load()
}
}, [src, label])
// Automatic plays: hover, visible, once.
const trigger =
play === "once"
? completedAt === null
? "visible"
: "intent"
: play === "visible"
? "visible"
: "intent"
const autoPlay =
automatic &&
intent.active(trigger) &&
(completedAt === null || intent.activation > completedAt)
useEffect(() => {
if (!automatic) return
const el = video.current
if (!el) return
autoWants.current = autoPlay
if (autoPlay) {
setFailed(false)
playOwn(own.current, el, () => {
setFailed(true)
setManual(false)
})
} else {
ownPause(own.current, el)
if (stopAt(el, rest, home)) setShown(false)
}
return () => {
autoWants.current = false
}
}, [automatic, autoPlay, setManual, rest, home])
// A span plays round and round: at its end, back to its start. Read on the frames
// the browser presents, so it never shows one past the end; `timeupdate` (four a
// second) would let a quarter of a second of the next part through.
useEffect(() => {
const el = video.current
if (!el || spanFrom === undefined || spanTo === undefined) return
let handle = 0
const watch = () => {
if (msOf(el.currentTime) >= spanTo) el.currentTime = seekSeconds(spanFrom)
handle = el.requestVideoFrameCallback(watch)
}
handle = el.requestVideoFrameCallback(watch)
return () => el.cancelVideoFrameCallback(handle)
}, [spanFrom, spanTo])
// What every page-driven video (automatic or followed) owes the page: the page's one
// sound decision (the muted property set here, synchronously, since React's `muted`
// prop is not reflected and an autoplay policy reads the property), its buffer warmed
// before it is seen, and no voice talking over another window: with sound on, it
// pauses when the window loses focus and plays on when it returns, if it still should.
useEffect(() => {
if (!pageDriven) return
const el = video.current
if (!el) return
let applying = false
const apply = (on: boolean) => {
applying = true
el.muted = !on
queueMicrotask(() => {
applying = false
})
}
apply(pageSound)
soundFollowers.add(apply)
const onVolume = () => {
if (!applying) setPageSound(!el.muted)
}
const warm = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return
el.preload = "auto"
warm.disconnect()
},
{ rootMargin: WARM_MARGIN },
)
warm.observe(el)
// Only what the window's blur paused plays on when it returns: a video the
// person paused stays paused.
let blurred = false
const onBlur = () => {
if (el.paused || el.muted) return
blurred = true
ownPause(own.current, el)
el.pause()
}
const onFocus = () => {
const resume = blurred && autoWants.current && el.paused
blurred = false
if (!resume) return
ownPlay(own.current, el)
el.play().catch(tolerate)
}
el.addEventListener("volumechange", onVolume)
window.addEventListener("blur", onBlur)
window.addEventListener("focus", onFocus)
return () => {
soundFollowers.delete(apply)
warm.disconnect()
el.removeEventListener("volumechange", onVolume)
window.removeEventListener("blur", onBlur)
window.removeEventListener("focus", onFocus)
}
}, [pageDriven])
// The page's word: play, hold, and (with `at`) the instant to show.
useEffect(() => {
if (!follows) return
const el = video.current
if (!el) return
autoWants.current = wanted === true
if (at !== undefined) {
const ms = msOf(el.currentTime)
// Once it has shown an asked instant it stays seen while it seeks to the
// next: the poster is one still, and flashing it back mid-scrub would show
// the wrong picture.
if (Math.abs(ms - at) > FOLLOW_TOLERANCE) el.currentTime = seekSeconds(at)
else if (el.readyState >= 2) setOnInstant(true)
}
if (wanted && el.paused) playOwn(own.current, el, () => setFailed(true))
if (!wanted && !el.paused && stopAt(el, rest, home)) setShown(false)
})
const onFrameCheck = () => {
const el = video.current
if (!el || at === undefined) return
if (
el.readyState >= 2 &&
Math.abs(msOf(el.currentTime) - at) <= FOLLOW_TOLERANCE
)
setOnInstant(true)
}
// Unseen until it can honestly stand for the picture: automatic plays until frames
// come (and again at rest under "poster"), a followed instant until it shows it.
const hidden =
follows && at !== undefined
? !onInstant
: (automatic || follows) && !playing && !shown
// Asked to play (by the hand, by sight, by the page it follows) and no frame up yet:
// its poster lies under falling water (the registry's water) until the picture
// comes in over it. A manual video's own controls say it is loading.
const asked = automatic ? autoPlay : follows ? wanted === true : false
const water = useWater<HTMLImageElement>(
poster && asked && hidden && !failed ? "falling" : null,
)
return (
<div
ref={frame}
data-slot="video"
data-playing={playing}
// Nothing stays under a painted video: whatever does shows through its
// anti-aliased rounded edge as a light, stepped rim. The ground is for an empty
// frame with no poster, until the video knows its size; the poster (below)
// only while it stands in.
className={cn(
"not-prose relative isolate block max-w-full overflow-hidden",
width && height ? "aspect-(--video-aspect)" : "size-full",
width && height && !poster && !sized && "bg-foreground/4",
className,
)}
style={
width && height
? ({
"--video-aspect": `${width} / ${height}`,
} as React.CSSProperties)
: undefined
}
>
{poster && (hidden || !loaded) && (
<Image
ref={water.ref}
style={water.style}
src={poster}
alt=""
fill
sizes={sizes}
unoptimized={!optimizePoster}
priority={posterArrival === "instant"}
blurDataURL={blurDataURL}
className={cn(
"pointer-events-none rounded-[inherit] object-cover",
videoClassName,
)}
/>
)}
{water.filter}
<video
{...props}
ref={video}
width={width}
height={height}
aria-label={label}
playsInline
// Focus is how a keyboard asks a hover or once video to play; a video that
// plays on sight alone takes no stop in the tab order.
tabIndex={play === "hover" || play === "once" ? 0 : undefined}
muted={pageDriven || props.muted}
controls={controls}
loop={
play === "once"
? completedAt !== null && autoPlay && props.loop
: props.loop
}
preload={props.preload ?? (pageDriven ? "none" : "metadata")}
// Rounded on its own layer: Chrome clips a composited video to a parent's
// rounded corners without anti-aliasing (a stepped corner), and rounds an
// element's own corners smoothly.
className={cn(
"relative block size-full rounded-[inherit] object-cover transition-[opacity,filter] duration-300 ease-out motion-reduce:transition-none",
// Only where scripts run: without them nothing would ever reveal it.
hidden &&
"[@media(scripting:enabled)]:opacity-0 motion-safe:[@media(scripting:enabled)]:blur-sm",
videoClassName,
)}
onLoadedMetadata={(e) => {
setSized(true)
if (start !== undefined || span)
e.currentTarget.currentTime = seekSeconds(start ?? span?.from ?? 0)
props.onLoadedMetadata?.(e)
}}
onLoadedData={(e) => {
setLoaded(true)
onFrameCheck()
props.onLoadedData?.(e)
}}
onSeeked={(e) => {
onFrameCheck()
props.onSeeked?.(e)
}}
onPlay={(e) => {
// Whose play this is: ours, or the person's (their decision, which an
// automatic video now follows).
lastPlayOwn.current = own.current.play
if (automatic && !own.current.play) setManual(true)
own.current.play = false
props.onPlay?.(e)
}}
onPlaying={(e) => {
const el = e.currentTarget
// A start we asked for that landed after the policy changed its mind.
if (automatic && !autoWants.current && lastPlayOwn.current) {
ownPause(own.current, el)
el.pause()
return
}
if (!el.muted) speak(el)
setPlaying(true)
setShown(true)
onPlaying?.(e)
}}
onPause={(e) => {
const el = e.currentTarget
// Ours, the page silencing it for another voice, or the video reaching its
// end (the browser fires a pause there too): none of them the person's.
const machine = own.current.pause || silenced.delete(el) || el.ended
own.current.pause = false
if (automatic && !machine) setManual(false)
setPlaying(false)
onPause?.(e)
}}
onEnded={(e) => {
setPlaying(false)
if (play === "once") setCompletedAt(intent.activation)
onEnded?.(e)
}}
onError={(e) => {
setFailed(true)
setPlaying(false)
onError?.(e)
}}
>
{children}
</video>
{automatic && pauseButton && (
<button
type="button"
data-slot="video-pause"
aria-label={`${playing ? "Pause" : "Play"} ${label}`}
aria-pressed={playing}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (!playing) setCompletedAt(null)
setManual(!playing)
}}
className="absolute right-2 bottom-2 rounded-full bg-black/60 px-3 py-2 text-white text-xs"
>
{failed ? "retry" : playing ? "pause" : "play"}
</button>
)}
</div>
)
}"use client"
// A video's decoder as the owner of a timebase (lib/timebase): the other owner beside
// ui/playback's clip clock, read by the same followers. `VideoPlayback` owns it;
// `VideoPicture` is where the picture goes, anywhere inside; ui/player's PlayerBar,
// PlayerKeys, PlayerGestures, PlayerTransport, PlayerSpeed and PlayerSound move and
// read it like any other.
//
// <VideoPlayback src="/film.mp4" label="the film" fps={25}>
// <VideoPicture><PlayerGestures /></VideoPicture>
// <PlayerBar label="the film"><PlayerSpeed /><PlayerSound /></PlayerBar>
// <PlayerKeys />
// </VideoPlayback>
//
// The element's ownership (the clock, coalesced seeks, steps, previews, the loop, the
// remembered volume, a refused autoplay) is ui/media-timebase's, shared with
// ui/audio's AudioPlayback; what is a video's alone lives here: the instant is the
// frame the browser PRESENTED (requestVideoFrameCallback), never the clock's coarse
// `timeupdate`, so a follower that says "frame 112" means the frame on screen; the
// picture's stage; and its zoom.
import * as React from "react"
import { cn } from "@/lib/utils"
import { type Captions, linesAt } from "@/registry/base-nova/lib/captions"
import {
type Chapter,
type Outline,
outlineOf,
type Span,
} from "@/registry/base-nova/lib/clip"
import { zoomAt } from "@/registry/base-nova/lib/lightbox-motion"
import {
centreOn,
clampView,
FIT_VIEW,
fitted,
frameView,
type PinchRung,
pinchRaw,
pinchRung,
pinchScale,
rungTaken,
type Size,
settle,
stepIn,
stepOut,
stopsOf,
type View,
zoomLabel,
} from "@/registry/base-nova/lib/picture-zoom"
import { msOf, shownAt } from "@/registry/base-nova/lib/timebase"
import { useBackdropSource } from "@/registry/base-nova/ui/backdrop"
import {
BackdropScope,
MaterialProvider,
} from "@/registry/base-nova/ui/material"
import {
Caption,
CaptionLines,
CaptionsProvider,
useCaptions,
useMediaCaptions,
} from "@/registry/base-nova/ui/media-captions"
import {
useMediaTimebase,
useStageAwake,
useStageClicks,
} from "@/registry/base-nova/ui/media-timebase"
import { pinchInput } from "@/registry/base-nova/ui/pinch-input"
import {
OutlineProvider,
onScreenChange,
type PictureZoom,
PictureZoomProvider,
type PictureZoomState,
PLAYER_FLASH,
PlayerLayer,
screenHolder,
TimebaseProvider,
toggleFullscreen,
usePictureZoomState,
useTimebase,
useTimebaseState,
} from "@/registry/base-nova/ui/playback"
import {
soundReadable,
TimebaseSoundSource,
useSoundTap,
} from "@/registry/base-nova/ui/sound-source"
import { Video, type VideoProps } from "@/registry/base-nova/ui/video"
type PictureProps = Pick<
VideoProps,
| "src"
| "label"
| "poster"
| "blurDataURL"
| "sizes"
| "optimizePoster"
| "width"
| "height"
| "muted"
| "loop"
| "preload"
| "crossOrigin"
| "voice"
> & {
/** When it plays by itself, ui/video's automatic policies: "visible" (a feature
* demo that plays on sight), "hover", "once". Default "manual": the person decides.
* Whichever starts it, the player's controls work the same. */
play?: "manual" | "hover" | "visible" | "once"
}
/** How far a pinch may carry the scale past the ladder's ends before it settles back:
* under fit and over the most, the hand still feels the picture move. */
const PINCH_UNDER = 0.8
const PINCH_OVER = 1.15
/** The zoom of one picture: lib/picture-zoom's ladder over a view, measured by
* VideoPicture, moved by gestures, keys and a finder. */
function createPictureZoom(
element: () => HTMLVideoElement | null,
): PictureZoom & { measure: (stage: Size, natural: Size) => void } {
let state: PictureZoomState = {
view: FIT_VIEW,
box: null,
stage: null,
fill: 1,
max: 1,
moving: false,
said: null,
}
const listeners = new Set<() => void>()
let natural: Size | null = null
let said = 0
// The raw stretch a pinch began at (lib/picture-zoom's pinchRaw).
let pinchBase = 1
const set = (patch: Partial<PictureZoomState>) => {
state = { ...state, ...patch }
for (const l of listeners) l()
}
// A new view, kept in bounds, its change named when the name changes (a camera's
// `quiet` move is not the person zooming, and names nothing).
const show = (view: View, moving: boolean, quiet = false) => {
const { box, stage, fill } = state
if (!box || !stage) return
const next = clampView(view, box, stage)
const before = zoomLabel(state.view.s, fill)
const after = zoomLabel(next.s, fill)
set({
view: next,
moving,
said: quiet || before === after ? state.said : { text: after, n: ++said },
})
}
return {
get: () => state,
subscribe: (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
measure: (stage, size) => {
const box = fitted(size, stage)
const { fill, max } = stopsOf(box, stage)
// Another picture (a playlist's next) starts whole; the same one on a resized
// stage keeps its scale.
const same =
natural !== null && natural.w === size.w && natural.h === size.h
natural = size
const view = same ? clampView(state.view, box, stage) : FIT_VIEW
set({ box, stage, fill, max: Math.max(max, 1), view })
},
pinch: (stretch, at, how = {}) => {
if (how.start) pinchBase = pinchRaw(state.view.s, state.fill)
const scale = Math.min(
Math.max(pinchScale(pinchBase * stretch, state.fill), PINCH_UNDER),
state.max * PINCH_OVER,
)
show(zoomAt(state.view, scale, at), true, how.quiet)
},
pan: (dx, dy) =>
show({ ...state.view, x: state.view.x + dx, y: state.view.y + dy }, true),
step: (direction, at = { x: 0, y: 0 }) => {
const s = state.view.s
const scale =
direction > 0
? stepIn(s, state.fill, state.max)
: stepOut(s, state.fill)
show(zoomAt(state.view, scale, at), false)
},
fit: (how = {}) => show(FIT_VIEW, false, how.quiet),
centreOn: (point) => {
const { box, stage } = state
if (box && stage) show(centreOn(point, state.view, box, stage), true)
},
frame: (rect, how = {}) => {
const { box, stage } = state
if (box && stage)
show(
frameView(rect, box, stage, state.max, how.margin ?? 0.2),
// Not eased: placed as asked, for a path already smooth (a camera), and
// quiet: a camera's move is not the person zooming.
how.eased === false,
how.eased === false,
)
},
end: () => {
const scale = settle(state.view.s, state.max)
show(zoomAt(state.view, scale, { x: 0, y: 0 }), false)
},
picture: element,
}
}
const PictureContext = React.createContext<{
picture: PictureProps
attach: (el: HTMLVideoElement | null) => void
zoom: ReturnType<typeof createPictureZoom> | null
/** The decoder once a VideoPicture has attached it: what a VideoCrop draws from. */
element: HTMLVideoElement | null
} | null>(null)
export interface VideoPlaybackProps extends PictureProps {
/** Frames per second when known; otherwise measured from the frames presented while
* it plays at 1×, and stepping waits until it is known. */
fps?: number
/** The video's chapters, the first level of its outline. */
chapters?: Chapter[]
/** Loop this span, ms. */
range?: Span
/** Where it starts, ms. */
start?: number
autoPlay?: boolean
/** Remember volume, mute and speed across visits: the watching player's ergonomic
* (a film picks up at the volume you left it). Default: on when the person decides
* when it plays, off for a video that plays itself (a feature demo starts muted
* and playing on every load, its sound the page's decision). */
remember?: boolean
/** It reached its end (not under `loop`): a playlist's `advance`. */
onEnded?: () => void
/** The picture zooms (lib/picture-zoom): a pinch or ctrl-wheel at the hand, + - 0
* with PlayerKeys, a drag or scroll pans while zoomed, PlayerZoomFinder shows where. */
zoom?: boolean
/** The languages it is captioned in, a WebVTT file each (lib/captions): drawn over
* the picture in the shared caption look, rising above the chrome's foot while it
* shows; PlayerCaptions picks one or none, C turns them on and off (PlayerKeys).
* Another origin needs `crossOrigin` and CORS headers, as for the video. */
captions?: readonly Captions[]
/** Its sound, for a drawing inside (ui/sound-source): the element is tapped at the
* first play a press asks for and its spectrum handed down, so a sound-ribbon (a
* halo round a talking head) reads it with no wiring. Off by default: a tapped
* element's sound runs through Web Audio for the rest of its life. Another origin
* needs `crossOrigin` and CORS headers. */
sound?: boolean
className?: string
children: React.ReactNode
}
export function VideoPlayback({
fps,
chapters,
range,
start = 0,
autoPlay = false,
remember: rememberProp,
onEnded,
zoom: zooms = false,
sound = false,
captions: offered = NO_CAPTIONS,
className,
children,
...picture
}: VideoPlaybackProps) {
const [el, setEl] = React.useState<HTMLVideoElement | null>(null)
const readable = soundReadable(picture.src, picture.crossOrigin)
if (sound && !readable)
throw new Error(
`VideoPlayback "${picture.label}": \`sound\` needs the video's spectrum, and this src is on another origin; pass \`crossOrigin\` (its server must send CORS headers)`,
)
const { tap, ...heard } = useSoundTap(sound ? el : null, readable)
const video = React.useRef<HTMLVideoElement | null>(null)
video.current = el
const zoom = React.useMemo(
() => (zooms ? createPictureZoom(() => video.current) : null),
[zooms],
)
// On when the person decides when it plays, off for a video that plays itself.
const remember = rememberProp ?? (picture.play ?? "manual") === "manual"
const { timebase, root } = useMediaTimebase(el, {
fps,
range,
start,
autoPlay,
remember,
onEnded,
beforePlay: sound ? tap : undefined,
})
const captions = useMediaCaptions(el, offered, remember)
const outline = React.useMemo<Outline>(
() => outlineOf({ duration: 1, chapters: chapters ?? [] }),
[chapters],
)
// A plain value: VideoPlayback re-renders only when its props change or its element
// attaches; the instant reaches followers through the store, not through here.
const pictureValue = { picture, attach: setEl, zoom, element: el }
return (
<div
ref={root}
data-slot="video-playback"
className={cn("relative flex flex-col gap-2", className)}
>
<PlayerLayer>
<TimebaseProvider timebase={timebase}>
<OutlineProvider outline={outline}>
<PictureZoomProvider zoom={zoom}>
<CaptionsProvider captions={captions}>
<PictureContext.Provider value={pictureValue}>
{sound ? (
<TimebaseSoundSource sound={heard}>
{children}
</TimebaseSoundSource>
) : (
children
)}
</PictureContext.Provider>
</CaptionsProvider>
</PictureZoomProvider>
</OutlineProvider>
</TimebaseProvider>
</PlayerLayer>
</div>
)
}
const NO_CAPTIONS: readonly Captions[] = []
/** The lines being said over the picture, at the shown instant (a preview on the bar
* shows that instant's line), each in the shared caption look, the file's own line
* breaks kept. Low over the picture while it plays untouched; risen above the
* chrome's foot while the chrome shows (awake, focused, paused, previewing), by the
* foot's own measured height (`--ag-player-foot`, set by PlayerFoot). Takes no
* pointer. */
function PictureCaptions() {
const captions = useCaptions()
if (!captions?.shown) throw new Error("PictureCaptions: no captions shown")
const { lines, shown } = captions
// The lines at the shown instant, as a key: re-renders only when they change.
const key = useTimebaseState((s) =>
linesAt(lines, shownAt(s))
.map((l) => lines.indexOf(l))
.join(","),
)
const raised = useTimebaseState((s) => !s.playing || s.preview !== null)
const showing = key ? key.split(",").map((i) => lines[Number(i)]) : []
return (
<CaptionLines
data-slot="video-picture-captions"
data-raised={raised || undefined}
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 z-10 px-[4%] pb-[3%]",
"transition-[translate] duration-200 ease-out motion-reduce:transition-none",
"data-raised:translate-y-[calc(-1*var(--ag-player-foot,0px))] group-focus-within/picture:translate-y-[calc(-1*var(--ag-player-foot,0px))] group-data-[awake]/picture:translate-y-[calc(-1*var(--ag-player-foot,0px))]",
)}
>
{showing.map(
(line) =>
line && (
<Caption
key={`${line.span.from}-${line.text}`}
lang={shown.lang}
className="whitespace-pre-line"
>
{line.voice && <span className="sr-only">{line.voice}: </span>}
{line.text}
</Caption>
),
)}
</CaptionLines>
)
}
/** The picture's zoom from the hand, on the stage: measured (the stage and the
* picture's own size, and again when either changes), a pinch (a trackpad's, which
* arrives as a ctrl-wheel, or two fingers) on the ladder every player shares
* (lib/player-input's pinchRung: in the page a spread fills the screen; filling it, a
* spread zooms at the hand and a close at fit leaves; zoomed, a pinch zooms), and while
* zoomed a scroll, a drag or one finger pans. Before a rung is taken the picture
* follows the fingers, growing toward the screen it will fill or shrinking toward the
* page it goes back to. Leaving the screen, however it is left, the picture is whole
* again. PlayerGestures stands down for all of these when the picture zooms. */
function useZoomInput(
zoom: ReturnType<typeof createPictureZoom> | null,
stage: React.RefObject<HTMLDivElement | null>,
el: HTMLVideoElement | null,
panned: React.RefObject<boolean>,
root: () => HTMLElement | null,
) {
React.useEffect(() => {
const box = stage.current
if (!zoom || !box || !el) return
// This player holds the screen (the picture, or a part of the player holding it).
const full = () => screenHolder()?.contains(box) ?? false
// One pinch, trackpad or fingers: its rung, chosen where its direction is first
// known and kept to its end ("spent" once taken: the rest of it moves nothing).
let rung: PinchRung | "spent" | null = null
const begin = (out: boolean) => {
rung = pinchRung({ out, full: full(), zooms: true, zoomed: zoomed() })
}
const pinchTo = (
stretch: number,
at: { x: number; y: number },
start: boolean,
) => {
if (rung === null || rung === "none" || rung === "spent") return
// Only the zoom rung is a zoom: on its way to fullscreen or out of it the
// picture follows the fingers and names nothing.
const following = rung !== "zoom"
if (rungTaken(rung, stretch)) {
toggleFullscreen(root(), rung === "fullscreen")
zoom.fit({ quiet: true })
rung = "spent"
return
}
zoom.pinch(stretch, at, { start, quiet: following })
}
// A pinch let go. One that was only following the fingers toward a rung it never
// took goes back to whole, quietly: settled on the nearest stop instead, a spread
// short of fullscreen left the picture zoomed at its fill, a zoom nobody asked for.
const letGo = () => {
if (rung === "fullscreen" || rung === "leave") zoom.fit({ quiet: true })
else zoom.end()
rung = null
}
// Leaving the screen (a pinch, Esc, F, the button), the picture is whole again: a
// zoom is a way of looking at the screen, not a state the page keeps.
let wasFull = full()
const offScreen = onScreenChange(() => {
const now = full()
if (wasFull && !now) zoom.fit({ quiet: true })
wasFull = now
})
const measure = () => {
const r = box.getBoundingClientRect()
if (!(el.videoWidth > 0 && r.width > 0 && r.height > 0)) return
zoom.measure(
{ w: r.width, h: r.height },
{ w: el.videoWidth, h: el.videoHeight },
)
}
measure()
const resize = new ResizeObserver(measure)
resize.observe(box)
el.addEventListener("loadedmetadata", measure)
el.addEventListener("resize", measure)
// A point of the stage, as px from its centre.
const fromCentre = (clientX: number, clientY: number) => {
const r = box.getBoundingClientRect()
return {
x: clientX - r.left - r.width / 2,
y: clientY - r.top - r.height / 2,
}
}
const zoomed = () => zoom.get().view.s > 1.001
const onChrome = (target: EventTarget | null) =>
target instanceof Element &&
target.closest(
"[data-slot=player-chrome] button, [data-slot=player-chrome] [role=slider], [data-slot=player-bar], [data-slot=player-zoom-finder]",
) !== null
// The hand (ui/pinch-input): a pinch on the ladder at its centre; zoomed, a wheel,
// a drag or one finger pans.
const stopReading = pinchInput(box, {
accepts: (target) => !onChrome(target),
begin: (out) => {
panned.current = true
begin(out)
},
pinch: (stretch, at, start) =>
pinchTo(stretch, fromCentre(at.x, at.y), start),
end: letGo,
wheel: (e) => {
if (!zoomed()) return false
zoom.pan(-e.deltaX, -e.deltaY)
return true
},
wheelEnd: letGo,
// A finger is always read (a pinch may leave it zoomed, its pan going on); a
// mouse only while zoomed. Whole, a drag is the page's.
press: (e) => {
panned.current = false
return e.pointerType === "touch" || zoomed()
},
drag: (dx, dy) => {
if (!zoomed()) return
panned.current = true
zoom.pan(dx, dy)
},
release: () => zoom.end(),
})
// Zoomed, a finger is the picture's on both axes; whole, the page keeps scrolling.
const touch = () => {
box.style.touchAction = zoomed() ? "none" : "pan-y"
}
touch()
const unsubscribe = zoom.subscribe(touch)
return () => {
resize.disconnect()
unsubscribe()
stopReading()
box.style.touchAction = ""
el.removeEventListener("loadedmetadata", measure)
el.removeEventListener("resize", measure)
offScreen()
}
}, [zoom, stage, el, panned, root])
}
/** Where the picture goes. A click plays and pauses; a double click is fullscreen.
* Children sit over the picture (PlayerGestures, a caption, an answering layer), in the
* dark scheme (material's MaterialProvider), so chrome over it is light ink in either
* theme without a part saying so.
* While a pointer moves over it the picture is `data-awake`: what shows "on hover"
* over it reads that (`group-data-[awake]/picture:`), never `:hover`, so it fades a
* moment after the hand stops, fullscreen too; the cursor hides with it while it
* plays.
*
* Its shape: the video's own (`width` and `height`), else 16:9 until the caller sizes
* it, or `aspect`, a stage of one shape whatever plays in it. A playlist wants the
* stage: its videos differ in shape, and the player must not change size between
* them; a video of another shape fits inside, whole, with black on two sides.
*
* `fullscreen`: the picture, its chrome over it, is what fullscreen fills (the button,
* F, a double click, a pinch), not the player's whole root: for a page laid out
* around the player, whose root holds the page. Its popups then open inside it.
*
* `clicks={false}`: the picture is a part of something that answers the hand itself
* (film-wall's tile): no play and pause on a click, no fullscreen on a double click,
* no pointer cursor, and the cursor never hides. */
export function VideoPicture({
aspect,
fullscreen = false,
clicks: clickable = true,
className,
videoClassName,
inPicture,
children,
}: {
/** The stage's shape, as CSS writes it ("16 / 9", "1"). */
aspect?: string
/** The picture is what fullscreen fills. */
fullscreen?: boolean
/** A click plays and pauses, a double click fills the screen. Default true. */
clicks?: boolean
className?: string
videoClassName?: string
/** Drawn on the picture itself, moving and scaling with its zoom: boxes around
* what was found, placed in shares of the frame. Fills the picture's own box inside
* the stage, whatever the stage's shape (a stage, fullscreen). */
inPicture?: React.ReactNode
/** Over the stage, still while the picture zooms: chrome. */
children?: React.ReactNode
}) {
const ctx = React.useContext(PictureContext)
if (!ctx) throw new Error("VideoPicture: place it inside a VideoPlayback")
const stage = React.useRef<HTMLDivElement>(null)
const [el, setEl] = React.useState<HTMLVideoElement | null>(null)
const { attach, zoom } = ctx
const attachHere = React.useCallback(
(node: HTMLVideoElement | null) => {
attach(node)
setEl(node)
},
[attach],
)
// The frame's pixels, read for the surfaces over it, when they can be read: the same
// origin, or CORS with `crossOrigin` (the rule the sound's spectrum keeps). The black
// stage round a letterboxed frame reads as black.
const readable = soundReadable(ctx.picture.src, ctx.picture.crossOrigin)
const backdrop = useBackdropSource(readable ? el : null, {
fit: "contain",
outside: 0,
})
// The picture's own shape, once the video knows it: where `inPicture` is drawn.
const [frame, setFrame] = React.useState<{ w: number; h: number } | null>(
null,
)
React.useEffect(() => {
if (!el) return
const read = () =>
setFrame(
el.videoWidth && el.videoHeight
? { w: el.videoWidth, h: el.videoHeight }
: null,
)
read()
el.addEventListener("loadedmetadata", read)
el.addEventListener("resize", read)
return () => {
el.removeEventListener("loadedmetadata", read)
el.removeEventListener("resize", read)
}
}, [el])
// A drag that panned is not a click.
const panned = React.useRef(false)
const { root } = useTimebase()
useZoomInput(zoom, stage, el, panned, root)
const clicks = useStageClicks(panned)
const view = usePictureZoomState((s) => s.view)
const moving = usePictureZoomState((s) => s.moving)
const said = usePictureZoomState((s) => s.said)
const [saying, setSaying] = React.useState<string | null>(null)
React.useEffect(() => {
if (!said) return
setSaying(said.text)
const t = window.setTimeout(() => setSaying(null), 700)
return () => window.clearTimeout(t)
}, [said])
const zoomed = view.s > 1.001
const awake = useStageAwake(stage)
const captions = useCaptions()
// This picture is the fullscreen element right now.
const [full, setFull] = React.useState(false)
React.useEffect(() => {
if (!fullscreen) return
const read = () => setFull(screenHolder() === stage.current)
read()
return onScreenChange(read)
}, [fullscreen])
const playing = useTimebaseState((s) => s.playing)
// Under a stage the video fills it and fits inside (its own size would give its
// frame its own shape, and the stage would cut it).
const picture = aspect
? { ...ctx.picture, width: undefined, height: undefined }
: ctx.picture
// The stage's clicks are a pointer's shortcut: the bar's play button and PlayerKeys
// are the accessible controls.
return (
<div
ref={stage}
data-slot="video-picture"
data-zoomed={zoomed || undefined}
// What `toggleFullscreen` fills inside the player's root.
data-fullscreen={fullscreen || undefined}
data-awake={awake || undefined}
// `group/picture`: chrome over it (ui/player's PlayerChrome) shows while it is
// awake.
className={cn(
"group/picture relative overflow-hidden rounded-xl bg-black [&:fullscreen]:rounded-none",
clickable && "cursor-pointer",
zoomed && "cursor-grab active:cursor-grabbing",
clickable && playing && !awake && "cursor-none",
!aspect && !(picture.width && picture.height) && "aspect-video",
className,
)}
// The stage states its shape: the view inside sizes itself to the stage
// (`container-type: size`, for what is drawn on the picture), so the video can
// never give the stage its height; without a shape the stage was 0 tall.
style={{
aspectRatio:
aspect ??
(picture.width && picture.height
? `${picture.width} / ${picture.height}`
: undefined),
}}
{...(clickable ? clicks : {})}
>
{/* The view: one transform over the whole picture, from the stage's centre.
Eased when it settles or steps, never under a hand. */}
<div
data-slot="video-picture-view"
className={cn(
"relative size-full origin-center [container-type:size]",
zoom && !moving && "transition-transform duration-200 ease-out",
"motion-reduce:transition-none",
)}
style={
zoom
? {
transform: `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
}
: undefined
}
>
<Video
{...picture}
ref={attachHere}
play={picture.play ?? "manual"}
controls={false}
className="rounded-[inherit]"
videoClassName={cn("object-contain", videoClassName)}
>
{/* Each language's file, for the browser to parse and load; never drawn by
it (ui/media-captions keeps them "hidden"), the picture's own layer
draws the lines. */}
{captions?.offered.map((c) => (
<track
key={c.src}
kind="captions"
src={c.src}
srcLang={c.lang}
label={c.label}
/>
))}
</Video>
{inPicture && frame && (
// The picture's own box, contained in the view as the video is.
<div
data-slot="video-picture-in"
className="pointer-events-none absolute inset-0 m-auto w-(--ag-picture-w) [aspect-ratio:var(--ag-picture-shape)]"
style={
{
"--ag-picture-shape": `${frame.w} / ${frame.h}`,
"--ag-picture-w": `min(100cqw, 100cqh * ${frame.w} / ${frame.h})`,
} as React.CSSProperties
}
>
{inPicture}
</div>
)}
</div>
{captions?.shown && <PictureCaptions />}
{/* What stands over a picture (PlayerChrome and its parts, a zoom finder, their
tooltips and menus) wears the scheme the frame under it asks for, read
live (ui/backdrop): light glass over snow, dark over night, with no part
saying so. A picture whose pixels cannot be read (another origin, no
CORS) stands it all in the dark scheme. Popups open inside the picture
only while it is the fullscreen element (the only place they show then);
otherwise in the player's layer, unclipped by a picture narrower than
they are. */}
<BackdropScope source={backdrop}>
<MaterialProvider scheme={readable ? "backdrop" : "dark"}>
{fullscreen ? (
<PlayerLayer active={full}>{children}</PlayerLayer>
) : (
children
)}
</MaterialProvider>
</BackdropScope>
{zoom && (
<span role="status" className="sr-only">
{said ? `zoom ${said.text}` : ""}
</span>
)}
{saying && (
<span aria-hidden className={PLAYER_FLASH}>
{saying}
</span>
)}
</div>
)
}
/** The video element of the VideoPlayback around, once its VideoPicture has attached
* it; null before. For what acts on the element itself (film-wall gives a tile's its
* voice under the hand); time is the timebase's, never read off it. */
export function useVideoElement(): HTMLVideoElement | null {
const ctx = React.useContext(PictureContext)
if (!ctx) throw new Error("useVideoElement: place it inside a VideoPlayback")
return ctx.element
}
/** Where one person's face is in the picture over a span of time: a shot (ms) and the
* square around the face (its centre and half its side, in the video's own pixels). */
export interface CropShot {
from: number
to: number
x: number
y: number
r: number
}
/** Of one face's shots, the one at `ms`, if the face is in the picture then. */
export function shotAt(
shots: readonly CropShot[],
ms: number,
): CropShot | undefined {
return shots.find((s) => ms >= s.from && ms < s.to)
}
/** How far either side of an instant the framing looks, ms: a face detector's square
* wobbles from sample to sample, and a head does not. */
const FRAMING_MS = 400
/** One shot running straight into the next with the face near where it was: the same
* shot, the head moving. Anything else is a cut. */
const linked = (a: CropShot, b: CropShot) =>
b.from === a.to && Math.hypot(b.x - a.x, b.y - a.y) <= a.r
/** The square round one face at `ms`, following the head: its shots measured every so
* often (a face detector's) are a run of short shots, and within a run the square is
* the samples' path averaged over an instant either side (the head, not the detector's
* jitter); a cut is kept, the average never reaching across one. A pure function of
* time, so a seek, a pause and every crop of the same face agree. */
export function framed(
shots: readonly CropShot[],
ms: number,
): { x: number; y: number; r: number } | undefined {
const i = shots.findIndex((s) => ms >= s.from && ms < s.to)
if (i < 0) return undefined
// The run this instant belongs to.
let lo = i
let hi = i
while (lo > 0 && linked(shots[lo - 1] as CropShot, shots[lo] as CropShot))
lo--
while (
hi < shots.length - 1 &&
linked(shots[hi] as CropShot, shots[hi + 1] as CropShot)
)
hi++
const start = (shots[lo] as CropShot).from
const end = (shots[hi] as CropShot).to - 1
// The samples' path inside the run: from one sample's square to the next's.
const path = (t: number) => {
let j = lo
while (j < hi && t >= (shots[j] as CropShot).to) j++
const s = shots[j] as CropShot
const next = j < hi ? (shots[j + 1] as CropShot) : undefined
if (!next) return s
const f = (t - s.from) / (s.to - s.from)
return {
x: s.x + (next.x - s.x) * f,
y: s.y + (next.y - s.y) * f,
r: s.r + (next.r - s.r) * f,
}
}
// Averaged over the moment around `ms`, the nearer instants weighing more.
const STEPS = 8
let x = 0
let y = 0
let r = 0
let total = 0
for (let k = -STEPS; k <= STEPS; k++) {
const t = Math.min(end, Math.max(start, ms + (k / STEPS) * FRAMING_MS))
const w = STEPS + 1 - Math.abs(k)
const p = path(t)
x += p.x * w
y += p.y * w
r += p.r * w
total += w
}
return { x: x / total, y: y / total, r: r / total }
}
/** Whether a face is in the picture at the instant shown (a preview's too). */
export function useInShot(shots: readonly CropShot[]): boolean {
return useTimebaseState((s) => shotAt(shots, shownAt(s)) !== undefined)
}
/** A piece of the picture, drawn live: one person's face out of a shot that holds
* several, each frame as the decoder presents it (requestVideoFrameCallback), so every
* face in a scene moves, the listening ones too. `shots` say where the face is and
* when; between them nothing is drawn (`useInShot` says so, for whatever stands in: a
* faceless voice). Needs the VideoPicture of the VideoPlayback around it on the page,
* the one element every crop reads. */
export function VideoCrop({
shots,
className,
style,
}: {
shots: readonly CropShot[]
className?: string
style?: React.CSSProperties
}) {
const ctx = React.useContext(PictureContext)
if (!ctx) throw new Error("VideoCrop: place it inside a VideoPlayback")
const el = ctx.element
const canvas = React.useRef<HTMLCanvasElement>(null)
React.useEffect(() => {
const c = canvas.current
const g = c?.getContext("2d")
if (!el || !c || !g) return
const size = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 3)
c.width = Math.round(c.clientWidth * dpr)
c.height = Math.round(c.clientHeight * dpr)
}
const draw = () => {
const s = framed(shots, msOf(el.currentTime))
// Out of the picture: nothing, never the last frame it was in.
if (!s) return g.clearRect(0, 0, c.width, c.height)
if (el.readyState < 2) return
g.clearRect(0, 0, c.width, c.height)
g.drawImage(
el,
s.x - s.r,
s.y - s.r,
2 * s.r,
2 * s.r,
0,
0,
c.width,
c.height,
)
}
const resize = new ResizeObserver(() => {
size()
draw()
})
resize.observe(c)
let frame = 0
const loop = () => {
draw()
frame = el.requestVideoFrameCallback(loop)
}
frame = el.requestVideoFrameCallback(loop)
// A seek while paused presents a frame too; a still page draws it once loaded.
el.addEventListener("seeked", draw)
el.addEventListener("loadeddata", draw)
return () => {
resize.disconnect()
el.cancelVideoFrameCallback(frame)
el.removeEventListener("seeked", draw)
el.removeEventListener("loadeddata", draw)
}
}, [el, shots])
return (
<canvas
ref={canvas}
data-slot="video-crop"
aria-hidden
className={cn("block size-full", className)}
style={style}
/>
)
}