a clip's own clock that plays once in view, and React's side of any timebase
npx shadcn add @ag/playbackAlready installed? Preview an update
npx shadcn add @ag/playback --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-14
- A clip told to play (
playing) plays in development too: React mounts an effect twice there, and the cleared animation frame was still named, so the clock stood still at 0.
shelf motion
uses clip, timebase, key-router, lightbox-motion, portal, remembered
used by video, sound-source, glass-display, terminal, terminal-editor, ide, player, timeline, film-mosaic, macos, browser, telegram-chat
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Playhead, usePlayhead } from "@/registry/base-nova/ui/playback"
// #region reader
/** Any component on the contract: its own progress when given, else the timebase's. */
function Reader({ progress }: { progress?: number }) {
const p = usePlayhead(progress).at
return (
<div className="h-2 w-64 overflow-hidden rounded-full bg-foreground/10">
<div
className="h-full bg-foreground/60"
style={{ width: `${p * 100}%` }}
/>
</div>
)
}
// #endregion
/** A hand-made driver: a range input holds the playhead, the reader draws it. */
export default function PlaybackDemo() {
const [progress, setProgress] = React.useState(0.4)
return (
<Sample
name="held"
glance
title="the page holds the moment"
description="a component reads it"
with="reader"
>
<div className="flex items-center gap-4">
<input
type="range"
min={0}
max={1}
step={0.01}
value={progress}
onChange={(e) => setProgress(Number(e.target.value))}
aria-label="playhead"
/>
<Playhead progress={progress}>
<Reader />
</Playhead>
</div>
</Sample>
)
}the source, every file an install writes
"use client"
// Playback: a clip's own clock, and the React side of a timebase (lib/timebase).
//
// `Playback` OWNS a timebase: a synthetic clock walking `at` toward a `target` at the
// clip's pace, only while the content is on screen and the tab is visible. Every
// driver is a way of setting the target: play aims at the end, pause aims where it
// is, a seek moves both, a scroll stage's act cues a span to be in. So a scroll stage
// and a player's bar move the SAME clock and whichever moved last wins.
//
// Everything inside FOLLOWS the timebase. Content on the clip contract (terminal,
// macos, telegram-chat) reads its moment with `usePlayhead`; a bar, keys or a
// filmstrip move it through `useTimebase`. A media element is the other owner
// (ui/media-timebase, under ui/video's `VideoPlayback` and ui/audio's `AudioPlayback`):
// the same followers work under either. `Playhead` is the third way to hold one: the
// page decides the moment (a range input, a fixed still).
import * as React from "react"
import {
type Clip,
type Outline,
outlineOf,
type Span,
} from "@/registry/base-nova/lib/clip"
import {
clampRate,
frameAt,
frameTime,
initialState,
shownAt,
type Timebase,
type TimebaseState,
TimebaseStore,
} from "@/registry/base-nova/lib/timebase"
import { openLayer } from "@/registry/base-nova/ui/key-router"
import { PortalTarget, usePortalTarget } from "@/registry/base-nova/ui/portal"
const TimebaseContext = React.createContext<Timebase | null>(null)
/** Hands a timebase to everything inside. Owners render it; a page that builds its own
* timebase can too. */
export function TimebaseProvider({
timebase,
children,
}: {
timebase: Timebase
children: React.ReactNode
}) {
return (
<TimebaseContext.Provider value={timebase}>
{children}
</TimebaseContext.Provider>
)
}
/** The timebase around this component, to move it (`controls`) or read it. Outside
* any owner it throws: a bar with nothing to move is a mistake, not an empty bar. */
export function useTimebase(): Timebase {
const timebase = React.useContext(TimebaseContext)
if (!timebase)
throw new Error(
"no timebase: place this inside an owner (Playback, Player, VideoPlayback, AudioPlayback)",
)
return timebase
}
/** The timebase around this component, or null outside any owner: for a follower
* that also stands alone (a film's tiles hand a press to the owner when there is one,
* and play it themselves when there is none). Everything that needs a clock to mean
* anything asks `useTimebase`, which throws. */
export function useOptionalTimebase(): Timebase | null {
return React.useContext(TimebaseContext)
}
/** A slice of the timebase's state; re-renders only when that slice changes:
* `useTimebaseState((s) => s.playing)`. Return a primitive or a stable value. */
export function useTimebaseState<T>(select: (s: TimebaseState) => T): T {
const timebase = useTimebase()
const read = () => select(timebase.get())
return React.useSyncExternalStore(timebase.subscribe, read, read)
}
// ── hover intent: the one rule every timeline scrubs by ─────────────────────────
/** How long the pointer must stay, moving slowly, before hovering a timeline shows
* that moment: long enough that a hand crossing it on the way elsewhere never
* scrubs, short enough that a hand meaning it does not wait. */
const INTENT_MS = 120
/** Faster than this the pointer is passing by, not looking (px per ms). */
const SLOW_PX_PER_MS = 0.6
/** After the page scrolls, how long the pointer is still not a hover: a scroll slides
* the timeline under a still pointer, which chose nothing. */
const SCROLL_QUIET_MS = 200
let lastScroll = 0
let scrollWatchers = 0
const onScroll = () => {
lastScroll = performance.now()
}
/** One passive listener for every timeline, counted so the last to leave removes it. */
function watchScroll() {
if (scrollWatchers++ === 0)
window.addEventListener("scroll", onScroll, {
capture: true,
passive: true,
})
return () => {
if (--scrollWatchers === 0)
window.removeEventListener("scroll", onScroll, { capture: true })
}
}
type Point = { clientX: number; clientY: number }
/** HOVER INTENT, for anything a pointer scrubs (a bar, a card's edge, the words of a
* transcript): hovering shows a moment only once the hand has meant it, resting or
* moving slowly for INTENT_MS, never while it passes over fast or the page scrolls
* it by, so travelling across a timeline never flashes the moments under it. Once
* engaged it follows the hand at once until it leaves. `show` is where the hand is;
* `end` gives the moment back (endPreview). A press is intent whatever came before
* (`engage`). */
export function useHoverIntent(
show: (at: Point) => void,
end: () => void,
): {
move: (e: Point) => void
leave: () => void
engage: (e: Point) => void
} {
const handlers = React.useRef({ show, end })
handlers.current = { show, end }
const intent = React.useRef({ engaged: false, timer: 0, x: 0, y: 0, t: 0 })
React.useEffect(() => watchScroll(), [])
React.useEffect(() => () => window.clearTimeout(intent.current.timer), [])
return React.useMemo(
() => ({
move: (e: Point) => {
const i = intent.current
if (i.engaged) return handlers.current.show(e)
const now = performance.now()
const speed = i.t
? Math.hypot(e.clientX - i.x, e.clientY - i.y) /
Math.max(1, now - i.t)
: 0
i.x = e.clientX
i.y = e.clientY
i.t = now
if (now - lastScroll < SCROLL_QUIET_MS || speed > SLOW_PX_PER_MS) {
window.clearTimeout(i.timer)
i.timer = 0
return
}
if (i.timer) return
i.timer = window.setTimeout(() => {
i.timer = 0
i.engaged = true
handlers.current.show({ clientX: i.x, clientY: i.y })
}, INTENT_MS)
},
leave: () => {
const i = intent.current
window.clearTimeout(i.timer)
const was = i.engaged
intent.current = { engaged: false, timer: 0, x: 0, y: 0, t: 0 }
if (was) handlers.current.end()
},
engage: (e: Point) => {
const i = intent.current
window.clearTimeout(i.timer)
i.timer = 0
i.engaged = true
handlers.current.show(e)
},
}),
[],
)
}
// THE SCREEN, TWO WAYS. The browser grants fullscreen only to a user's press (a click,
// a key, a finger lifted): a trackpad's pinch is a stream of wheel events and no press,
// and asked from one, fullscreen is refused. So a player fills the screen as the
// browser's fullscreen where it is granted, and else fills the WINDOW: the same part,
// fixed over the page edge to edge, the page's scroll held, Escape leaving. Everything
// that asks "is it on the screen?" asks `screenHolder`, and hears both changes with
// `onScreenChange`. A part filling the window is placed in the viewport, so an ancestor
// with a transform (which makes `fixed` its own) keeps it in that ancestor.
const FILLING = "data-filling"
const SCREEN_CHANGE = "ag:screenchange"
/** The properties filling the window sets on the part, each `!important`. */
const FILL: Record<string, string> = {
position: "fixed",
inset: "0",
width: "auto",
height: "auto",
"max-width": "none",
"max-height": "none",
margin: "0",
transform: "none",
"border-radius": "0",
background: "black",
"z-index": "2147483000",
}
/** What holds the screen now: the browser's fullscreen element, else a part filling
* the window; null when nothing does. */
export function screenHolder(): Element | null {
return document.fullscreenElement ?? document.querySelector(`[${FILLING}]`)
}
/** Calls `fn` whenever what holds the screen changes, either way; the unsubscribe. */
export function onScreenChange(fn: () => void): () => void {
document.addEventListener("fullscreenchange", fn)
document.addEventListener(SCREEN_CHANGE, fn)
return () => {
document.removeEventListener("fullscreenchange", fn)
document.removeEventListener(SCREEN_CHANGE, fn)
}
}
/** The window fill's rung on the page's Escape ladder (key-router), while it fills. */
let closeLayer: (() => void) | null = null
function fillWindow(el: HTMLElement) {
for (const [k, v] of Object.entries(FILL))
el.style.setProperty(k, v, "important")
el.setAttribute(FILLING, "")
document.documentElement.style.setProperty("overflow", "hidden")
// Esc leaves it, as the innermost layer: a menu open inside it (the speed, the
// captions) closes on that Esc instead, and only the next one leaves.
closeLayer = openLayer({
root: () => el,
onEscape: () => {
if (!document.fullscreenElement) emptyWindow()
},
})
document.dispatchEvent(new Event(SCREEN_CHANGE))
}
function emptyWindow() {
const el = document.querySelector<HTMLElement>(`[${FILLING}]`)
if (!el) return
for (const k of Object.keys(FILL)) el.style.removeProperty(k)
el.removeAttribute(FILLING)
document.documentElement.style.removeProperty("overflow")
closeLayer?.()
closeLayer = null
document.dispatchEvent(new Event(SCREEN_CHANGE))
}
/** The screen for a timebase's root (`timebase.root()`), or for the part of it marked
* `data-fullscreen` (a VideoPicture given `fullscreen`: the picture alone, where the
* root holds a whole page); out of it when it already holds it. `on` forces a side.
* The browser's fullscreen when a press just granted it, else the window (THE
* SCREEN, TWO WAYS); left in the order taken, the browser's first. */
export function toggleFullscreen(el: HTMLElement | null, on?: boolean) {
if (!el) return
const isOn = screenHolder() !== null
if ((on ?? !isOn) === isOn) return
if (isOn) {
if (document.fullscreenElement) void document.exitFullscreen()
else emptyWindow()
return
}
const part = el.querySelector<HTMLElement>("[data-fullscreen]") ?? el
if (document.fullscreenEnabled && navigator.userActivation.isActive)
part.requestFullscreen().catch(() => fillWindow(part))
else fillWindow(part)
}
const OutlineContext = React.createContext<Outline | null>(null)
/** Hands the medium's outline (how it is cut, coarse to fine) to everything inside:
* the bar divides by it, keys move through it. Beside the timebase, never in it:
* the clock says where, the outline says how the medium is cut there. */
export function OutlineProvider({
outline,
children,
}: {
outline: Outline
children: React.ReactNode
}) {
return (
<OutlineContext.Provider value={outline}>
{children}
</OutlineContext.Provider>
)
}
/** The outline around this component. A medium nobody cut is one level of one
* division (the whole), so a bar always has something to divide by. */
export function useOutline(): Outline {
return React.useContext(OutlineContext) ?? WHOLE
}
const WHOLE: Outline = [
{ name: "chapter", divisions: [{ start: 0, title: "" }] },
]
/** A picture's zoom (lib/picture-zoom): the view over the fitted picture, its stops,
* and the last change said aloud. Beside the timebase, never in it: the clock says
* when, this says where in the frame. */
export interface PictureZoomState {
/** Translation from the stage's centre (px) and scale over the fitted picture. */
view: { x: number; y: number; s: number }
/** The fitted picture and the stage, once both are measured. */
box: { w: number; h: number } | null
stage: { w: number; h: number } | null
fill: number
max: number
/** A hand is on it: the view follows without easing. */
moving: boolean
/** The last change, named ("fill", "2.4×"); `n` counts, so the same name twice is
* two announcements. */
said: { text: string; n: number } | null
}
/** Moves a picture's zoom: what gestures, keys and a finder call. */
export interface PictureZoom {
get(): PictureZoomState
subscribe(listener: () => void): () => void
/** A pinch: to the scale a hand's stretch shows (the stops hold it), keeping `at`
* (px from the stage's centre) still. `start` begins one at the current scale;
* `quiet` names nothing (the picture only following fingers on their way to
* something else, fullscreen, is no zoom anyone asked for). */
pinch(
stretch: number,
at: { x: number; y: number },
how?: { start?: boolean; quiet?: boolean },
): void
/** Panned by px, while zoomed. */
pan(dx: number, dy: number): void
/** One step of the ladder, in (1) or out (-1). */
step(direction: 1 | -1, at?: { x: number; y: number }): void
/** Back to the whole picture; `quiet` names nothing (a return the person did not
* ask for as a zoom: leaving fullscreen, a pinch let go short of its rung). */
fit(how?: { quiet?: boolean }): void
/** The stage centred on a point of the fitted picture (its own px from its corner). */
centreOn(point: { x: number; y: number }): void
/** A rectangle of the picture (shares, 0..1) framed in the stage: a found thing
* brought close. `margin`, the stage's share left round it (0.2); `eased: false`
* places it at once, for a path that is already smooth (a camera following). */
frame(
rect: { x: number; y: number; w: number; h: number },
how?: { margin?: number; eased?: boolean },
): void
/** A hand let go: it settles where the stops say, easing. */
end(): void
/** The picture's element, for a finder to draw. */
picture(): HTMLVideoElement | null
}
const ZoomContext = React.createContext<PictureZoom | null>(null)
export function PictureZoomProvider({
zoom,
children,
}: {
zoom: PictureZoom | null
children: React.ReactNode
}) {
return <ZoomContext.Provider value={zoom}>{children}</ZoomContext.Provider>
}
/** The zoom around this component, or null where the picture does not zoom. */
export function usePictureZoom(): PictureZoom | null {
return React.useContext(ZoomContext)
}
const NO_ZOOM: PictureZoomState = {
view: { x: 0, y: 0, s: 1 },
box: null,
stage: null,
fill: 1,
max: 1,
moving: false,
said: null,
}
const NOTHING = () => () => {}
/** A slice of the zoom's state, re-rendering only when it changes; the unzoomed state
* where there is no zoom. */
export function usePictureZoomState<T>(select: (s: PictureZoomState) => T): T {
const zoom = usePictureZoom()
return React.useSyncExternalStore(
zoom ? zoom.subscribe : NOTHING,
() => select(zoom ? zoom.get() : NO_ZOOM),
() => select(NO_ZOOM),
)
}
/** How a player says what a gesture just changed ("2×", "volume 40%", "fill"): one
* small pill at the top of the picture, the same for every change. */
export const PLAYER_FLASH =
"pointer-events-none absolute top-3 left-1/2 z-10 -translate-x-1/2 rounded-md bg-black/70 px-2 py-1 font-mono text-white text-xs tabular-nums"
/** Where a player's popovers and menus go (declared with portal's `PortalTarget`, so
* every popup item inside finds it with no `container` passed): an element inside the player, out of its
* flow (absolute, no size), so a popup moves nothing around it (a portal into the
* player's own box was one more child of its layout, and opening a card pushed the
* page down) and still shows when the player is fullscreen (one portalled to the
* page's body is outside the fullscreen element, and invisible). The player's root
* places it first; null until it mounts, and a portal given null goes to the body.
*
* `contain-layout` makes it the containing block of FIXED descendants too. Base UI
* holds an unplaced popup at `position: fixed` and measures its first placement
* against that box; without this the box is the viewport, the first placement comes
* out in page coordinates, the popup is set that far below the layer, and the focus
* a popover moves into it scrolls the page down to it (the playlist card sent the
* page to its foot). With it, both passes measure against the layer and agree.
* Containment also makes it a stacking context, and it comes first in the player:
* without its own `z-50` everything in it painted UNDER the picture (the playlist
* card opened, invisible, behind the video). `active` false hands the popups on to the
* layer around this one: a layer inside a part that clips (a picture that is the
* fullscreen element) is wanted only while that part is fullscreen. */
export function PlayerLayer({
children,
active = true,
}: {
children: React.ReactNode
active?: boolean
}) {
const outer = usePortalTarget()
const [layer, setLayer] = React.useState<HTMLElement | null>(null)
return (
<>
<span
ref={setLayer}
data-slot="player-layer"
className="contain-layout absolute top-0 left-0 z-50 size-0"
/>
<PortalTarget element={active ? layer : outer}>{children}</PortalTarget>
</>
)
}
/** One thing a playlist plays: at least a name; the rest is whatever its player takes
* (a src, chapters). A list of it (ui/player's PlayerPlaylist) shows `poster` and
* `duration` when they are there. */
export interface PlaylistEntry {
label: string
poster?: string
/** ms. */
duration?: number
}
/** A row of media played one after another, around a player. Above the outline: the
* outline cuts one medium, the playlist strings media together, so a player's previous
* and next move through the playlist when there is one and through the chapters when
* there is not. */
export interface Playlist<T extends PlaylistEntry = PlaylistEntry> {
items: readonly T[]
index: number
item: T
/** The item was reached while watching (its predecessor ended, or the person picked
* or stepped to it): it plays at once. False for the first item on arrival. */
continuing: boolean
/** To this item, playing. */
go: (index: number) => void
/** Null at the ends, unless the playlist loops. */
previous: (() => void) | null
next: (() => void) | null
/** What an item's end asks for: the next one; at the last, the first when it loops,
* else nothing (the last stays on its end). */
advance: () => void
}
const PlaylistContext = React.createContext<Playlist | null>(null)
/** Holds which item of `items` is playing, for everything inside: the player keyed by
* the item, the transport and keys that step through it, a PlayerPlaylist that lists
* it. The player plays `item`, starts it when `continuing`, and calls `advance` when it
* ends:
*
* <PlaylistProvider items={films}>{<Films />}</PlaylistProvider>
* function Films() {
* const p = usePlaylist<Film>()
* return <VideoPlayback key={p.index} {...p.item} autoPlay={p.continuing} onEnded={p.advance}>…
*/
export function PlaylistProvider<T extends PlaylistEntry>({
items,
start = 0,
loop = false,
children,
}: {
items: readonly T[]
/** The item it opens on. */
start?: number
/** After the last, the first again. */
loop?: boolean
children: React.ReactNode
}) {
if (items.length === 0) throw new Error("PlaylistProvider: no items")
if (start < 0 || start >= items.length)
throw new Error(
`PlaylistProvider: start ${start} outside ${items.length} items`,
)
const [{ index, continuing }, setAt] = React.useState({
index: start,
continuing: false,
})
const count = items.length
const go = React.useCallback(
(i: number) => {
if (i < 0 || i >= count)
throw new Error(`Playlist: item ${i} outside ${count} items`)
setAt({ index: i, continuing: true })
},
[count],
)
const step = (by: 1 | -1) => {
const i = index + by
if (i >= 0 && i < count) return () => go(i)
return loop && count > 1 ? () => go((i + count) % count) : null
}
const next = step(1)
const value: Playlist<T> = {
items,
index,
item: items[index] as T,
continuing,
go,
previous: step(-1),
next,
advance: () => next?.(),
}
return (
<PlaylistContext.Provider value={value as unknown as Playlist}>
{children}
</PlaylistContext.Provider>
)
}
/** The playlist around this component, or null where it plays one medium alone. */
export function usePlaylist<
T extends PlaylistEntry = PlaylistEntry,
>(): Playlist<T> | null {
return React.useContext(PlaylistContext) as Playlist<T> | null
}
const NO_SUBSCRIBE = () => () => {}
/** The moment a component draws (`at`, 0..1): its own `progress` when given, else the
* timebase's around it (a preview when one shows). Neither is a component placed
* where nothing moves it, and that screams. `driven` says which: a driven component
* can change at any moment (a chat keeps a viewport that a landing message slides
* inside), one with its own progress is a still and never will. `playing` says time
* runs forward at the clip's pace, for content with a clock of its own (a video) to
* run alongside; anything else (paused, previewed, rewinding, a still) holds it on
* `at`. */
export function usePlayhead(progress: number | undefined): {
at: number
driven: boolean
playing: boolean
} {
const timebase = React.useContext(TimebaseContext)
if (progress === undefined && timebase === null)
throw new Error(
"no playhead: pass `progress`, or place the component inside a timebase owner (Playback, Player, Playhead)",
)
const subscribe = timebase?.subscribe ?? NO_SUBSCRIBE
const at = React.useSyncExternalStore(
subscribe,
() => (timebase ? progressOf(timebase.get()) : 0),
() => (timebase ? progressOf(timebase.get()) : 0),
)
const playing = React.useSyncExternalStore(
subscribe,
() => !!timebase && moving(timebase.get()),
() => !!timebase && moving(timebase.get()),
)
if (progress !== undefined)
return { at: clamp01(progress), driven: false, playing: false }
return { at, driven: true, playing }
}
const clamp01 = (p: number) => Math.min(1, Math.max(0, p))
const progressOf = (s: TimebaseState) =>
s.duration > 0 ? clamp01(shownAt(s) / s.duration) : 0
const moving = (s: TimebaseState) => s.playing && s.preview === null
const held = (what: string) => () => {
throw new Error(
`Playhead: the page holds this timebase; ${what} it through the page's own state`,
)
}
/** A timebase the PAGE holds: the moment is `progress` (0..1), and it plays only when
* the page says. A hand-made driver (a range input), or a still placed where content
* expects a driver. It has no length of its own, so nothing can move it from inside:
* a bar placed under it throws. */
export function Playhead({
progress,
playing = false,
children,
}: {
progress: number
playing?: boolean
children: React.ReactNode
}) {
const [store] = React.useState(
() =>
new TimebaseStore(
initialState({
at: clamp01(progress),
duration: 1,
playing,
ready: true,
}),
),
)
React.useLayoutEffect(() => {
store.set({ at: clamp01(progress), playing })
}, [store, progress, playing])
const timebase = React.useMemo<Timebase>(
() => ({
get: store.get,
subscribe: store.subscribe,
root: () => null,
controls: {
play: held("play"),
pause: held("pause"),
toggle: held("toggle"),
seek: held("seek"),
step: held("step"),
jump: held("jump"),
preview: held("preview"),
endPreview: held("end a preview of"),
setRange: held("loop"),
},
}),
[store],
)
return <TimebaseProvider timebase={timebase}>{children}</TimebaseProvider>
}
export interface PlaybackOptions {
/** Where it stands before it plays (ms): a story that opens mid-conversation. */
start?: number
/** Time on screen before it starts to play (ms): lets a neighbour go first. */
delay?: number
/** A span to be in, heading to its end: what a scroll stage's act asks for
* (`chapterSpan`). Behind it, the playhead jumps to `from` (the act's story, not a
* replay of everything before it); inside, it plays on to `to` and waits; past it,
* it rewinds to `to`. A new cue wins over the bar, and the bar over the last cue.
* A scrub that follows scroll exactly is no cue: it passes `progress`. */
cue?: Span
/** Whether it plays, when the page decides (a card under the pointer): true plays
* on from the frame it stands on, to the end and round again from the top,
* never cutting away from what is on screen; false holds it on the frame it
* stands on. Looked away from, it stops: it neither runs on to an end nor
* rewinds to a rest frame. It starts at `start`. Set, it replaces playing once on
* screen. */
playing?: boolean
/** Play once on screen, without anyone pressing play. Default true; a player whose
* person decides when it plays passes false. */
autoPlay?: boolean
}
/** How much faster than the clip an aim behind the playhead plays it backwards. */
const REWIND = 3
/** The clock. Plays once, the first time the content is on screen (after `delay`),
* or while the page says `playing`; holds off screen and in a hidden tab; under
* reduced motion every aim lands at once. One rule for motion: an AIM travels
* (forward at the clip's pace times its rate, backward at `REWIND`x, so scrolling
* back an act unwinds it), a SEEK jumps (the bar). Returns the timebase it owns and
* the element whose visibility gates it. */
export function usePlayback(
clip: Clip,
{ start = 0, delay = 0, cue, playing, autoPlay = true }: PlaybackOptions = {},
): { root: React.RefObject<HTMLDivElement | null>; timebase: Timebase } {
const duration = clip.duration
if (!(duration > 0)) throw new Error("playback: duration must be positive")
const clamp = (ms: number) => Math.min(duration, Math.max(0, ms))
const root = React.useRef<HTMLDivElement>(null)
const [store] = React.useState(() => {
const at = Math.round(clamp(Math.max(start, cue?.from ?? start)))
return new TimebaseStore(
initialState({ at, duration, fps: clip.fps ?? null, ready: true }),
)
})
// Where the clock is heading; `at` lives in the store. Moving forward is playing.
const target = React.useRef(store.get().at)
// Where the clock really is while it walks: `at` in the store is this, whole.
const exact = React.useRef(store.get().at)
// Where a preview found it: its moment and where it was heading.
const before = React.useRef<{ at: number; target: number } | null>(null)
const reduced = React.useRef(false)
const armed = React.useRef(false)
const inView = React.useRef(false)
const frame = React.useRef(0)
const cueRef = React.useRef(cue)
cueRef.current = cue
const pageDriven = playing !== undefined
React.useEffect(() => {
store.set({ duration, fps: clip.fps ?? null })
}, [store, duration, clip.fps])
// One loop walks `at` toward the target; it runs only while they differ.
const tick = React.useRef<(now: number) => void>(() => {})
const go = React.useCallback(
(at: number, to: number) => {
const d = store.get().duration
const t = Math.round(Math.min(d, Math.max(0, to)))
const a = Math.round(reduced.current ? t : Math.min(d, Math.max(0, at)))
target.current = t
exact.current = a
store.set({ at: a, playing: a < t })
if (a !== t && !frame.current) {
let last = performance.now()
tick.current = (now) => {
const dt = now - last
last = now
const s = store.get()
const goal = target.current
if (inView.current && !document.hidden && s.preview === null) {
// Walked in fractions (a sixteenth speed on a fast display moves well
// under a millisecond a frame), written whole.
const from = exact.current
let next =
from < goal
? Math.min(goal, from + dt * s.rate)
: Math.max(goal, from - dt * REWIND)
const r = s.range
if (r && from < goal && next >= r.to) next = r.from
exact.current = next
store.set({ at: Math.round(next), playing: next < goal })
if (next === goal) {
frame.current = 0
return
}
}
frame.current = requestAnimationFrame(tick.current)
}
frame.current = requestAnimationFrame(tick.current)
}
},
[store],
)
// Its handle cleared with it: a loop cancelled but still named here (React mounts
// an effect twice in development) kept `go` from ever starting another, and a
// clock told to play stood still at 0.
React.useEffect(
() => () => {
cancelAnimationFrame(frame.current)
frame.current = 0
},
[],
)
const timebase = React.useMemo<Timebase>(() => {
const at = () => store.get().at
const end = () => store.get().duration
return {
get: store.get,
subscribe: store.subscribe,
root: () => root.current,
controls: {
play: () => {
const a = at()
go(a >= end() ? 0 : a, end())
},
pause: () => go(at(), at()),
toggle: () => {
if (store.get().playing) go(at(), at())
else go(at() >= end() ? 0 : at(), end())
},
seek: (ms, options) => {
before.current = null
store.set({ preview: null })
go(ms, options?.play ? end() : ms)
},
step: (n) => {
const fps = store.get().fps
if (!fps)
throw new Error(
"playback: this clip has no fps; it cannot step frames",
)
const ms = frameTime(frameAt(at(), fps) + n, fps)
go(ms, ms)
},
jump: (ms) => {
const next = at() + ms
go(next, store.get().playing ? Math.max(target.current, next) : next)
},
preview: (ms) => {
if (!before.current)
before.current = { at: at(), target: target.current }
store.set({
preview: Math.round(Math.min(end(), Math.max(0, ms))),
playing: false,
})
},
endPreview: () => {
const b = before.current
before.current = null
store.set({ preview: null })
if (b) go(b.at, b.target)
},
setRange: (range) => store.set({ range }),
// A page's timebase remembers nothing: `remember` has nothing to do.
setRate: (rate) => store.set({ rate: clampRate(rate) }),
},
}
}, [store, go])
// First time on screen (and `delay` later) it plays: to the cue's end, else the end.
React.useEffect(() => {
const el = root.current
if (!el) return
const motion = window.matchMedia("(prefers-reduced-motion: reduce)")
let timer = 0
const settle = () => {
reduced.current = motion.matches
if (reduced.current) go(target.current, target.current)
}
const arm = () => {
armed.current = true
const c = cueRef.current
if (c) go(Math.max(store.get().at, c.from), c.to)
else go(store.get().at, duration)
}
const io = new IntersectionObserver(
([entry]) => {
inView.current =
!!entry?.isIntersecting && el.getBoundingClientRect().height > 0
if (
inView.current &&
!armed.current &&
!timer &&
!pageDriven &&
autoPlay
)
timer = window.setTimeout(arm, delay)
},
{ threshold: 0.35 },
)
settle()
io.observe(el)
motion.addEventListener("change", settle)
return () => {
io.disconnect()
motion.removeEventListener("change", settle)
window.clearTimeout(timer)
}
}, [go, store, duration, delay, pageDriven, autoPlay])
// The page's word: play on from here, or hold here; played to the end while the
// page still says play, round again from the top.
React.useEffect(() => {
if (playing === undefined) return
if (!playing) {
go(store.get().at, store.get().at)
return
}
// Resting on its last frame (a card whose story is told), play starts it again
// from the top: there is nowhere forward to go.
const at = store.get().at
go(at >= duration ? 0 : at, duration)
return store.subscribe(() => {
const s = store.get()
if (s.at >= s.duration && !s.playing && s.preview === null)
go(0, s.duration)
})
}, [playing, go, store, duration])
// A new cue moves the playhead into its span; before the first view it only
// decides where the story stands.
const cueKey = cue ? `${cue.from}:${cue.to}` : ""
// biome-ignore lint/correctness/useExhaustiveDependencies: the cue's value is its key
React.useEffect(() => {
const c = cueRef.current
if (!c) return
const at = store.get().at
if (armed.current) go(Math.max(at, c.from), c.to)
else go(Math.max(at, c.from), Math.max(at, c.from))
}, [cueKey, go, store])
return { root, timebase }
}
export interface PlaybackProps extends PlaybackOptions {
clip: Clip
children: React.ReactNode
className?: string
}
/** A clip that plays once on screen, with no controls: a film in a card. The same
* clock as the player, so a still, a playback and a player are one component apart. */
export function Playback({
clip,
children,
className,
...options
}: PlaybackProps) {
const { root, timebase } = usePlayback(clip, options)
const outline = React.useMemo(() => outlineOf(clip), [clip])
return (
<div ref={root} data-slot="playback" className={className}>
<PlayerLayer>
<TimebaseProvider timebase={timebase}>
<OutlineProvider outline={outline}>{children}</OutlineProvider>
</TimebaseProvider>
</PlayerLayer>
</div>
)
}"use client"
// A media element as the owner of a timebase (lib/timebase): the one implementation
// behind ui/video's VideoPlayback and ui/audio's AudioPlayback, so a video and a sound
// seek, step, preview, loop, remember and start the same way, and every player part
// (PlayerBar, PlayerKeys, PlayerSpeed, PlayerSound, a playlist) drives either unchanged.
// What differs by medium is only the CLOCK, read off the element itself:
//
// - a video reports the frame the browser PRESENTED (requestVideoFrameCallback), so a
// follower that says "frame 112" means the frame on screen, and the frame rate is
// measured from those frames when nobody states it;
// - a sound has no frames: `currentTime` read once a display frame while it plays, and
// on pause and on every landed seek. Its `fps` stays null, so nothing offers to step.
//
// Seeks coalesce (a sweep never queues stale positions behind the pointer) and a step
// or jump counts from where it is HEADING, so three fast presses move three. A preview
// pauses and shows its instant; ending it returns where it was and plays again if it
// played. `range` loops a span. A refused autoplay plays muted and the first press
// anywhere brings the sound back. Volume, mute and speed are remembered under one key
// for every medium: a person's volume is theirs, not a video's or a sound's.
import * as React from "react"
import type { Span } from "@/registry/base-nova/lib/clip"
import {
between,
fields,
isBoolean,
setting,
} from "@/registry/base-nova/lib/remembered-core"
import {
clampRate,
frameAt,
frameMs,
frameTime,
initialState,
measureRate,
msOf,
seekSeconds,
type Timebase,
TimebaseStore,
} from "@/registry/base-nova/lib/timebase"
import { toggleFullscreen, useTimebase } from "@/registry/base-nova/ui/playback"
import { recall, remember } from "@/registry/base-nova/ui/remembered"
/** Volume, mute and speed, kept across visits for every player (ui/remembered). */
export const PLAYBACK = setting("media:playback", {
initial: { volume: 1, muted: false, rate: 1 },
keep: "always",
parse: fields<{ volume: number; muted: boolean; rate: number }>({
volume: between(0, 1),
muted: isBoolean,
rate: between(0.0625, 16),
}),
})
export interface MediaTimebaseOptions {
/** Frames per second when known (a video); otherwise a video measures it from the
* frames presented while it plays at 1×. Ignored for a sound, which has no frames. */
fps?: number
/** Loop this span, ms. */
range?: Span
/** Where it starts, ms. */
start?: number
autoPlay?: boolean
/** Remember volume, mute and speed across visits. */
remember: boolean
/** It reached its end (not under `loop`): a playlist's `advance`. */
onEnded?: () => void
/** Runs inside `play`, before the element is asked: within the person's press when a
* press asked for it, which is where an AudioContext may start (ui/audio taps its
* spectrum here). */
beforePlay?: () => void
}
/** The timebase an element owns (`el` once attached, null before), and the root its
* owner draws in (what fullscreen fills). */
export function useMediaTimebase(
el: HTMLMediaElement | null,
{
fps,
range,
start = 0,
autoPlay = false,
remember: rememberSettings,
onEnded,
beforePlay,
}: MediaTimebaseOptions,
): { timebase: Timebase; root: React.RefObject<HTMLDivElement | null> } {
const ended = React.useEffectEvent(() => onEnded?.())
const before = React.useRef(beforePlay)
React.useLayoutEffect(() => {
before.current = beforePlay
})
const root = React.useRef<HTMLDivElement>(null)
const media = React.useRef<HTMLMediaElement | null>(null)
media.current = el
const [store] = React.useState(
() =>
new TimebaseStore(
initialState({ at: start, fps: fps ?? null, range: range ?? null }),
),
)
// Seeks coalesce: while one is in flight only the newest target is kept. `target` is
// where it is heading, which is where the next step or jump counts from.
const seeking = React.useRef({
busy: false,
next: null as number | null,
target: start,
})
// Where a preview found it, and whether it played.
const held = React.useRef<{ at: number; playing: boolean } | null>(null)
// Muted by the player because the browser refused sound, not by the person.
const autoMuted = React.useRef(false)
// The last speed the page set as it plays (setRate's `remember: false`): a ratechange
// to it is not the person's speed, however many events the element sends for it.
const passing = React.useRef<number | null>(null)
const rawSeek = React.useCallback((ms: number) => {
const m = media.current
if (!m) return
const end = Number.isFinite(m.duration)
? msOf(m.duration)
: Number.POSITIVE_INFINITY
const t = Math.round(Math.min(Math.max(0, ms), Math.max(0, end - 1)))
seeking.current.target = t
if (seeking.current.busy) {
seeking.current.next = t
return
}
seeking.current.busy = true
m.currentTime = seekSeconds(t)
}, [])
const here = React.useCallback(
() => (seeking.current.busy ? seeking.current.target : store.get().at),
[store],
)
const play = React.useCallback(() => {
const m = media.current
if (!m) return
before.current?.()
m.play().catch((e: unknown) => {
if (e instanceof DOMException && e.name === "AbortError") return
if (
e instanceof DOMException &&
e.name === "NotAllowedError" &&
!m.muted
) {
autoMuted.current = true
m.muted = true
m.play().catch(() => {})
return
}
throw e
})
}, [])
const timebase = React.useMemo<Timebase>(
() => ({
get: store.get,
subscribe: store.subscribe,
root: () => root.current,
controls: {
setVolume: (volume) => {
const m = media.current
if (!m) return
m.volume = Math.min(1, Math.max(0, volume))
if (m.volume > 0) m.muted = false
},
toggleMute: () => {
const m = media.current
if (!m) return
autoMuted.current = false
m.muted = !m.muted
},
play,
pause: () => media.current?.pause(),
toggle: () => {
if (media.current?.paused) play()
else media.current?.pause()
},
seek: (ms, options) => {
held.current = null
store.set({ preview: null })
rawSeek(ms)
if (options?.play) play()
},
step: (n) => {
const rate = store.get().fps
if (!rate)
throw new Error(
"media timebase: no frame rate (a sound has none; a video's is measured once it plays, or pass `fps`)",
)
media.current?.pause()
rawSeek(frameTime(frameAt(here(), rate) + n, rate))
},
jump: (ms) => rawSeek(here() + ms),
preview: (ms) => {
const m = media.current
if (!m) return
if (!held.current) {
held.current = { at: store.get().at, playing: !m.paused }
m.pause()
}
store.set({ preview: Math.round(ms) })
rawSeek(ms)
},
endPreview: () => {
const h = held.current
held.current = null
store.set({ preview: null })
if (!h) return
rawSeek(h.at)
if (h.playing) play()
},
setRange: (r) => store.set({ range: r }),
setRate: (rate, options) => {
const m = media.current
if (!m) return
const r = clampRate(rate)
// The ratechange this causes is the page's, not a choice to remember.
passing.current = options?.remember === false ? r : null
m.preservesPitch = options?.pitch !== "follows"
m.playbackRate = r
},
},
}),
[store, rawSeek, here, play],
)
React.useEffect(() => {
store.set({ range: range ?? null })
}, [store, range])
React.useEffect(() => {
if (fps) store.set({ fps })
}, [store, fps])
// The clock: the instant every follower reads, by medium.
React.useEffect(() => {
if (!el) return
// Past the loop's end while playing: back to its start.
const loop = (at: number) => {
const s = store.get()
const r = s.range
if (r && !el.paused && s.preview === null && at >= r.to) rawSeek(r.from)
}
if (el instanceof HTMLVideoElement && "requestVideoFrameCallback" in el) {
const gaps: number[] = []
let handle = 0
let last: number | null = null
let lastExact: number | null = null
// Time passes between two frames too. A still over a soundtrack (a podcast's
// cover, a 1 fps slide) presents a frame a second, and a clock that moved only
// on frames told a transcript the time once a second: every word in between went
// by unlit. So between frames the clock runs on from the last one presented, at
// the element's speed, and stops short of the next frame's time: a frame count
// read off it never names a frame that is not on screen yet, and a stalled video
// runs ahead by less than one frame.
let anchor: { at: number; wall: number; gap: number | null } | null = null
let tickHandle = 0
const tick = () => {
tickHandle = 0
if (el.paused || !anchor) return
if (!seeking.current.busy) {
let at = Math.floor(
anchor.at + (performance.now() - anchor.wall) * el.playbackRate,
)
if (anchor.gap !== null) at = Math.min(at, anchor.at + anchor.gap - 1)
if (at > store.get().at) {
store.set({ at })
loop(at)
}
}
tickHandle = requestAnimationFrame(tick)
}
const onFrame = (now: number, meta: VideoFrameCallbackMetadata) => {
const at = frameMs(meta.mediaTime)
const gap = !el.paused && last !== null && at > last ? at - last : null
// The rate is measured from the decoder's own times, never from the names:
// NTSC names alternate 33 and 34 ms apart, and their median is no rate.
const exact = meta.mediaTime * 1000
if (gap !== null && lastExact !== null && el.playbackRate === 1)
gaps.push(exact - lastExact)
if (gaps.length > 60) gaps.shift()
last = el.paused ? null : at
lastExact = el.paused ? null : exact
anchor = el.paused
? null
: { at, wall: now, gap: gap ?? anchor?.gap ?? null }
const s = store.get()
store.set({ at, fps: fps ?? s.fps ?? measureRate(gaps) })
loop(at)
if (!el.paused && !tickHandle) tickHandle = requestAnimationFrame(tick)
handle = el.requestVideoFrameCallback(onFrame)
}
// A new speed or a new source: the gaps measured before say nothing now.
const forget = () => {
gaps.length = 0
anchor = null
}
el.addEventListener("ratechange", forget)
el.addEventListener("emptied", forget)
el.addEventListener("pause", forget)
handle = el.requestVideoFrameCallback(onFrame)
return () => {
el.cancelVideoFrameCallback(handle)
cancelAnimationFrame(tickHandle)
el.removeEventListener("ratechange", forget)
el.removeEventListener("emptied", forget)
el.removeEventListener("pause", forget)
}
}
// No frames: the element's time, once a display frame, only while it plays.
let frame = 0
const tick = () => {
const at = msOf(el.currentTime)
store.set({ at })
loop(at)
frame = el.paused ? 0 : requestAnimationFrame(tick)
}
const run = () => {
if (!frame) frame = requestAnimationFrame(tick)
}
const read = () => store.set({ at: msOf(el.currentTime) })
el.addEventListener("playing", run)
el.addEventListener("pause", read)
if (!el.paused) run()
return () => {
cancelAnimationFrame(frame)
el.removeEventListener("playing", run)
el.removeEventListener("pause", read)
}
}, [el, store, fps, rawSeek])
// The element's events into the store.
React.useEffect(() => {
if (!el) return
if (rememberSettings) {
const saved = recall(PLAYBACK)
el.volume = saved.volume
el.muted = saved.muted
el.playbackRate = saved.rate
}
// A medium with sound has a volume from the start: state like the rest.
store.set({ volume: el.volume, muted: el.muted, rate: el.playbackRate })
const on: Record<string, () => void> = {
loadedmetadata: () => {
if (Number.isFinite(el.duration))
store.set({ duration: msOf(el.duration), ready: true })
if (start > 0) rawSeek(start)
if (autoPlay) play()
},
play: () => store.set({ playing: true }),
pause: () => store.set({ playing: false }),
ended: () => {
store.set({ playing: false })
ended()
},
seeked: () => {
const ours = seeking.current.busy
seeking.current.busy = false
const next = seeking.current.next
seeking.current.next = null
if (next !== null) rawSeek(next)
// Our own seek landed where it was sent: the instant asked for, not the half
// millisecond past it the seek aimed at (a video's presented frame follows on
// its own). A seek from elsewhere reads the element.
else if (el.paused)
store.set({
at: ours ? seeking.current.target : msOf(el.currentTime),
})
},
ratechange: () => {
store.set({ rate: el.playbackRate })
if (rememberSettings && el.playbackRate !== passing.current)
remember(PLAYBACK, { ...recall(PLAYBACK), rate: el.playbackRate })
},
volumechange: () => {
store.set({ volume: el.volume, muted: el.muted })
if (rememberSettings && !autoMuted.current)
remember(PLAYBACK, {
...recall(PLAYBACK),
volume: el.volume,
muted: el.muted,
})
},
emptied: () => {
seeking.current = { busy: false, next: null, target: 0 }
},
}
for (const [name, handler] of Object.entries(on))
el.addEventListener(name, handler)
// An element rendered on the server starts loading before hydration: its metadata
// may have arrived before anyone listened (an <audio>'s length read 0:00).
if (el.readyState >= HTMLMediaElement.HAVE_METADATA) on.loadedmetadata?.()
return () => {
for (const [name, handler] of Object.entries(on))
el.removeEventListener(name, handler)
}
}, [el, store, rawSeek, play, start, autoPlay, rememberSettings])
// The first press anywhere after an autoplay the browser muted brings the sound
// back. It runs after the press's own handlers, so a press on the mute button is the
// person's choice and wins.
React.useEffect(() => {
if (!autoPlay) return
const unmute = () =>
window.setTimeout(() => {
const m = media.current
if (!m || !autoMuted.current) return
autoMuted.current = false
m.muted = false
}, 0)
window.addEventListener("pointerdown", unmute, {
once: true,
capture: true,
})
window.addEventListener("keydown", unmute, { once: true, capture: true })
return () => {
window.removeEventListener("pointerdown", unmute, { capture: true })
window.removeEventListener("keydown", unmute, { capture: true })
}
}, [autoPlay])
return { timebase, root }
}
/** How long a still hand keeps a stage awake, ms: the chrome fades this long after the
* pointer stops, as players do. */
const AWAKE_MS = 1500
/** Whether a stage (VideoPicture, AudioPicture) is AWAKE: a pointer has moved over it
* within `AWAKE_MS`, or rests on one of its controls or popups. Hover alone is not
* enough: fullscreen, the pointer never leaves the stage, and the chrome over it
* never went. A touch wakes it the same way; leaving sleeps it at once. The stage
* wears it as `data-awake`, and what shows "on hover" over it reads that
* (`group-data-[awake]/picture:`). */
export function useStageAwake(
stage: React.RefObject<HTMLElement | null>,
): boolean {
const [awake, setAwake] = React.useState(false)
React.useEffect(() => {
const box = stage.current
if (!box) return
let sleep = 0
const wake = (e: PointerEvent) => {
setAwake(true)
window.clearTimeout(sleep)
// On a control (the bar, a button, the finder) or a popup open over the stage, it
// stays awake until the hand moves off it.
if (
e.target instanceof Element &&
e.target.closest(
"[data-slot=player-chrome] button, [data-slot=player-chrome] [role=slider], [data-slot=player-bar], [data-slot=player-zoom-finder], [data-slot=player-layer]",
)
)
return
sleep = window.setTimeout(() => setAwake(false), AWAKE_MS)
}
const leave = () => {
window.clearTimeout(sleep)
setAwake(false)
}
box.addEventListener("pointermove", wake)
box.addEventListener("pointerdown", wake)
box.addEventListener("pointerleave", leave)
return () => {
window.clearTimeout(sleep)
box.removeEventListener("pointermove", wake)
box.removeEventListener("pointerdown", wake)
box.removeEventListener("pointerleave", leave)
}
}, [stage])
return awake
}
/** A stage's click and double click (VideoPicture, AudioPicture): a click plays and
* pauses, a double click is fullscreen on the timebase's root. A double click must
* not also toggle twice, so the single click waits a beat. A click on a control or on
* the chrome is the control's, never a toggle, and neither is the release of a drag
* that `panned`. */
export function useStageClicks(panned?: React.RefObject<boolean>): {
onClick: (e: React.MouseEvent) => void
onDoubleClick: (e: React.MouseEvent) => void
} {
const { controls, root } = useTimebase()
const click = React.useRef(0)
return {
onClick: (e) => {
if (panned?.current) {
panned.current = false
return
}
if (
(e.target as Element).closest(
"button, a, input, [role=slider], [data-slot=player-chrome], [data-slot=player-zoom-finder]",
)
)
return
window.clearTimeout(click.current)
click.current = window.setTimeout(controls.toggle, 220)
},
onDoubleClick: (e) => {
if (
(e.target as Element).closest(
"[data-slot=player-chrome], [data-slot=player-zoom-finder]",
)
)
return
window.clearTimeout(click.current)
toggleFullscreen(root())
},
}
}"use client"
// A media element's captions (lib/captions) as a player reads them. The owner
// (VideoPlayback) puts each language's WebVTT file on its element as a <track>, and
// this reads the shown one's lines off the element's TextTrack, its mode "hidden": the
// browser parses and loads, and never draws (its own rendering sits under the
// player's chrome and ignores the page's type). What shows and when is ours: the lines
// at the timebase's shown instant, so a preview on the bar shows that instant's line.
//
// The choice (on, off, which language) is ONE decision a person makes for every
// player, kept with the volume where the owner remembers (media-timebase's rule):
// turned on here, captions are on in the next video, in the language last picked where
// that video has it. Never chosen, a file marked `default` shows.
//
// `Caption` is the look of a line over a picture, shared: the player's captions and
// transcript's TranscriptCaptions draw their lines in it, so captions read the same
// whoever wrote them.
import * as React from "react"
import { cn } from "@/lib/utils"
import {
type CaptionLine,
type Captions,
type CaptionsChoice,
checkCaptions,
linesOf,
NEVER_CHOSEN,
shownOf,
toggledChoice,
} from "@/registry/base-nova/lib/captions"
import {
fields,
isBoolean,
setting,
} from "@/registry/base-nova/lib/remembered-core"
import { useRemembered } from "@/registry/base-nova/ui/remembered"
const orNull =
<T,>(parse: (x: unknown) => T | undefined) =>
(x: unknown): T | null | undefined =>
x === null ? null : parse(x)
/** The captions choice, kept across visits for every player (ui/remembered). */
export const CAPTIONS = setting<CaptionsChoice>("media:captions", {
initial: NEVER_CHOSEN,
keep: "always",
parse: fields<CaptionsChoice>({
on: orNull(isBoolean),
lang: orNull((x) => (typeof x === "string" && x ? x : undefined)),
}),
})
/** A medium's captions, for the player's controls and its picture. */
export interface MediaCaptions {
/** The languages it is captioned in, in the order given. */
offered: readonly Captions[]
/** The ones showing, or null (off, or never chosen and no default). */
shown: Captions | null
/** The shown captions' lines, once their file has loaded; empty before. */
lines: readonly CaptionLine[]
/** Show these (a language), or none (null). */
choose: (lang: string | null) => void
/** On and off: on, in the language last picked. */
toggle: () => void
}
const CaptionsContext = React.createContext<MediaCaptions | null>(null)
/** Hands a medium's captions to everything inside (an owner renders it). */
export function CaptionsProvider({
captions,
children,
}: {
captions: MediaCaptions | null
children: React.ReactNode
}) {
return (
<CaptionsContext.Provider value={captions}>
{children}
</CaptionsContext.Provider>
)
}
/** The captions of the medium around, or null where it has none: a control for them
* is then absent, not dimmed (nothing to turn on). */
export function useCaptions(): MediaCaptions | null {
const captions = React.useContext(CaptionsContext)
return captions && captions.offered.length > 0 ? captions : null
}
/** The captions an element carries (its <track>s, one per file offered), the choice
* kept across visits when `remember` (else for this player's life), and the shown
* file's lines read off the element once loaded. */
export function useMediaCaptions(
el: HTMLMediaElement | null,
offered: readonly Captions[],
remember: boolean,
): MediaCaptions {
checkCaptions(offered)
const [kept, keep] = useRemembered(CAPTIONS)
const [own, setOwn] = React.useState<CaptionsChoice>(NEVER_CHOSEN)
const choice = remember ? kept : own
const setChoice = remember ? keep : setOwn
const shown = shownOf(offered, choice)
const [lines, setLines] = React.useState<{
src: string
lines: readonly CaptionLine[]
} | null>(null)
const src = shown?.src ?? null
React.useEffect(() => {
if (!el) return
const tags = [...el.querySelectorAll("track")]
// Every file but the shown one is off; the shown one loads and stays undrawn.
for (const tag of tags)
tag.track.mode = tag.getAttribute("src") === src ? "hidden" : "disabled"
const tag = tags.find((t) => t.getAttribute("src") === src)
if (!src || !tag) return
const read = () => {
const entries = tag.track.cues
if (!entries) return
setLines({
src,
lines: linesOf(
[...entries].map((e) => ({
startTime: e.startTime,
endTime: e.endTime,
text: (e as VTTCue).text,
})),
),
})
}
const failed = () =>
console.error(
`captions: ${src} did not load (a wrong path, or another origin without CORS and crossOrigin)`,
)
// HTMLTrackElement.LOADED
if (tag.readyState === 2) read()
tag.addEventListener("load", read)
tag.addEventListener("error", failed)
return () => {
tag.removeEventListener("load", read)
tag.removeEventListener("error", failed)
}
}, [el, src])
return {
offered,
shown,
lines: lines && lines.src === src ? lines.lines : [],
choose: (lang) =>
setChoice((was: CaptionsChoice) => ({
on: lang !== null,
lang: lang ?? was.lang,
})),
toggle: () =>
setChoice((was: CaptionsChoice) => toggledChoice(offered, was)),
}
}
/** The look of a caption line over a picture: light type on a dark plate, centred and
* balanced (text-wrap: balance splits a long line into rows of even length). Its
* padding and corners are in em, so it is one look at any size: a thumbnail's, a
* player's, fullscreen. */
export const CAPTION =
"relative max-w-full rounded-[0.5em] bg-black/70 px-[0.75em] py-[0.4em] text-center text-balance font-medium text-white leading-snug tracking-[-0.006em] antialiased"
/** A caption's size, stated in what a reader feels: a full line (the subtitling norm's
* longest, 42 characters) spans this share of the picture's width, at the type's
* average advance per em. 0.6 is the broadcast proportion (24 px on an 850 px
* picture); a full line as wide as the margins allow (0.84, 34 px there) reads as a
* headline over the film. Floored for a thumbnail, capped for a wall. */
const CAPTION_CHARS = 42
const CAPTION_MEASURE = 0.6
const CAPTION_ADVANCE = 0.5
export const CAPTION_SIZE = `clamp(12px, ${((CAPTION_MEASURE * 100) / (CAPTION_CHARS * CAPTION_ADVANCE)).toFixed(3)}cqi, 44px)`
/** Where caption lines stand: a column centred in the box it spans, which is the
* container its lines are sized against (CAPTION_SIZE), so the box must span the
* picture. Not hidden from assistive technology and not live: a braille reader reads
* the lines (each line's speaker in words), nobody is interrupted by them. */
export function CaptionLines({
className,
style,
children,
...props
}: React.ComponentProps<"div">) {
return (
<div
{...props}
className={cn("@container", className)}
style={
{ "--ag-caption-size": CAPTION_SIZE, ...style } as React.CSSProperties
}
>
<div className="flex flex-col items-center gap-[0.3em] text-(length:--ag-caption-size)">
{children}
</div>
</div>
)
}
/** One caption line in the shared look. */
export function Caption({
lang,
className,
style,
children,
}: {
lang?: string
className?: string
style?: React.CSSProperties
children: React.ReactNode
}) {
return (
<p
data-slot="caption"
lang={lang}
className={cn(CAPTION, className)}
style={style}
>
{children}
</p>
)
}// Zooming into a moving picture on a stage, as data. Framework-free; ui/video-playback
// holds the view, ui/player's gestures, keys and finder move it.
//
// THE LADDER. A picture fitted whole in a stage of another shape leaves bars on two
// sides. Zooming in, the first stop is FILL, the picture just covering the stage (the
// bars gone, the least of it cut); past it the zoom is free up to `max`. Zooming out
// comes back down the same way: free until fill, which catches, then FIT. A stop is a
// DETENT under a pinch: a stretch of the hand that does not change the scale, so a stop
// is felt and has to be pushed through rather than flown past. Keys and buttons step
// to the stops exactly. A picture of the stage's own shape has no fill: fit covers.
//
// THE VIEW is the lightbox's: { x, y, s }, a translation in px of the stage from its
// centre and a scale over the fitted picture, transform-origin at the centre
// (lib/lightbox-motion's `zoomAt` keeps a point under the hand). Panned, the picture
// never uncovers the stage on an axis it overflows; on an axis it does not overflow it
// stays centred.
import {
PINCH_CLOSE,
type Point,
type Size,
type View,
zoomAt,
} from "@/registry/base-nova/lib/lightbox-motion"
export type { Point, Size, View }
/** What one pinch does: fill the screen, leave it, zoom the picture, or nothing. */
export type PinchRung = "fullscreen" | "leave" | "zoom" | "none"
/** THE PINCH'S LADDER, every player's: in the page → fullscreen → zoomed, and down
* the same way. Decided where the pinch STARTS and kept to its end, so one gesture
* climbs at most one rung: a spread that fills the screen does not also zoom, and a
* close that zooms down to fit does not also leave (the lightbox's rule for its pinch
* to close: a pinch that began zoomed is a zoom being undone). The screen is always
* there to take: where the browser refuses its fullscreen (a pinch is no press), the
* player fills the window (ui/playback's toggleFullscreen). */
export function pinchRung(at: {
/** The fingers spread (true) or close. */
out: boolean
/** The player holds the screen now. */
full: boolean
/** The picture zooms (VideoPlayback's `zoom`). */
zooms: boolean
/** The picture is past its fit. */
zoomed: boolean
}): PinchRung {
if (at.zooms && at.zoomed) return "zoom"
if (at.full) return at.out ? (at.zooms ? "zoom" : "none") : "leave"
return at.out ? "fullscreen" : "none"
}
/** A trackpad's pinch arrives as a ctrl-wheel: the stretch each px of its deltaY is
* (`stretch *= exp(-deltaY × this)`, a spread being a negative deltaY). */
export const PINCH_PER_WHEEL = 0.01
/** How far a pinch spreads or closes (a share of where it began) before it has a
* direction: under it, two fingers resting are not a pinch yet. */
export const PINCH_SLOP = 0.04
/** Whether a pinch's `stretch` (the fingers' spread over where they began, a
* trackpad's alike) has taken its rung: closed to the lightbox's pinch to close it
* leaves the screen, spread to its inverse it fills it. One distance for one hand,
* whichever viewer it is in. */
export function rungTaken(rung: PinchRung, stretch: number): boolean {
if (rung === "fullscreen") return stretch >= 1 / PINCH_CLOSE
if (rung === "leave") return stretch <= PINCH_CLOSE
return false
}
/** The fitted view: the whole picture, centred. */
export const FIT_VIEW: View = { x: 0, y: 0, s: 1 }
/** How far a detent holds the hand, as a factor of scale each side of its stop: a
* pinch has to travel 15% past a stop before the scale moves on. */
const DETENT = Math.log(1.15)
/** A key or button press multiplies the scale by this past fill. */
const STEP = 1.5
/** How far past fill the zoom goes: a picture is soft long before this. */
const MAX_OVER_FILL = 6
/** Scales this close are the same stop. */
const EPS = 1e-3
/** The picture's box fitted whole in the stage: its natural shape at the largest size
* that fits (what `object-fit: contain` draws). */
export function fitted(natural: Size, stage: Size): Size {
if (!(natural.w > 0 && natural.h > 0 && stage.w > 0 && stage.h > 0))
throw new Error(
`picture-zoom: sizes must be positive (natural ${natural.w}×${natural.h}, stage ${stage.w}×${stage.h})`,
)
const k = Math.min(stage.w / natural.w, stage.h / natural.h)
return { w: natural.w * k, h: natural.h * k }
}
/** The stops for a fitted picture on a stage: fit (1), fill (the scale at which it
* covers the stage; 1 when it already does), and the most it zooms. */
export function stopsOf(
box: Size,
stage: Size,
): {
fill: number
max: number
} {
const fill = Math.max(stage.w / box.w, stage.h / box.h)
return { fill, max: fill * MAX_OVER_FILL }
}
/** The stops a detent catches at, in log scale, ascending: fit, and fill when it is
* far enough above fit for two detents not to overlap. */
function detentStops(fill: number): number[] {
const f = Math.log(fill)
return f > 2 * DETENT ? [0, f] : [0]
}
/** A pinch's raw scale (the hand's stretch carried over from where it began) to the
* scale shown: every stop holds still for DETENT each side of it. Continuous and
* monotonic; `pinchRaw` is its inverse. */
export function pinchScale(raw: number, fill: number): number {
const stops = detentStops(fill)
const u = Math.log(raw)
let shown = u + DETENT
stops.forEach((stop, i) => {
const r = stop + 2 * DETENT * i
shown -= Math.min(Math.max(u - r, -DETENT), DETENT) + DETENT
})
return Math.exp(shown)
}
/** The raw stretch that shows `scale`: where a pinch that begins at `scale` starts, so
* its first px moves from there. At a stop, the middle of its detent. */
export function pinchRaw(scale: number, fill: number): number {
const stops = detentStops(fill)
const e = Math.log(scale)
const at = stops.findIndex((s) => Math.abs(s - e) < EPS)
if (at >= 0) return Math.exp((stops[at] as number) + 2 * DETENT * at)
const below = stops.filter((s) => s < e).length
return Math.exp(e + DETENT * (2 * below - 1))
}
/** Where a scale comes to rest when the hand lets go: under fit back to fit, over the
* most back to the most, anywhere between where it is (the stops already caught it). */
export function settle(scale: number, max: number): number {
return Math.min(Math.max(scale, 1), max)
}
/** One step in: to fill when below it, then by STEP up to the most. */
export function stepIn(scale: number, fill: number, max: number): number {
if (scale < fill - EPS) return fill
return Math.min(max, scale * STEP)
}
/** One step out: by STEP down to fill, then to fit. */
export function stepOut(scale: number, fill: number): number {
if (scale > fill + EPS) return Math.max(fill, scale / STEP)
return 1
}
/** The view kept inside its bounds: on an axis the scaled picture overflows the stage,
* never so far that the stage shows past its edge; on one it does not, centred. */
export function clampView(view: View, box: Size, stage: Size): View {
const bx = Math.max(0, (box.w * view.s - stage.w) / 2)
const by = Math.max(0, (box.h * view.s - stage.h) / 2)
return {
x: Math.min(Math.max(view.x, -bx), bx),
y: Math.min(Math.max(view.y, -by), by),
s: view.s,
}
}
/** A new scale with the point `at` (px from the stage's centre) staying where it is,
* then kept inside its bounds. */
export function zoomView(
view: View,
scale: number,
at: Point,
box: Size,
stage: Size,
): View {
return clampView(zoomAt(view, scale, at), box, stage)
}
/** What a scale is called when it changes: the two stops by name, else the zoom over
* the fitted picture. */
export function zoomLabel(scale: number, fill: number): string {
if (Math.abs(scale - 1) < EPS) return "fit"
if (Math.abs(scale - fill) < EPS) return "fill"
return `${scale.toFixed(1)}×`
}
/** The part of the fitted picture the stage shows, in the fitted picture's own px from
* its top-left: what a finder draws its rectangle around. */
export function visibleRect(
view: View,
box: Size,
stage: Size,
): { x: number; y: number; w: number; h: number } {
const w = Math.min(box.w, stage.w / view.s)
const h = Math.min(box.h, stage.h / view.s)
const cx = box.w / 2 - view.x / view.s
const cy = box.h / 2 - view.y / view.s
return { x: cx - w / 2, y: cy - h / 2, w, h }
}
/** The view that frames a rectangle of the picture (shares of it, 0..1: x, y from its
* top-left, w, h) in the stage, a margin of the stage left around it, never past the
* most it zooms nor under fit: a found thing brought close. */
export function frameView(
rect: { x: number; y: number; w: number; h: number },
box: Size,
stage: Size,
max: number,
margin = 0.2,
): View {
if (!(rect.w > 0 && rect.h > 0))
throw new Error(
`picture-zoom: a rectangle to frame has a size, not ${rect.w}×${rect.h}`,
)
const room = 1 - margin
const s = Math.min(
max,
Math.max(
1,
Math.min(
(stage.w * room) / (rect.w * box.w),
(stage.h * room) / (rect.h * box.h),
),
),
)
// Its centre, from the fitted picture's centre, in its px: the view moves it to the
// stage's centre at scale s.
const cx = (rect.x + rect.w / 2 - 0.5) * box.w
const cy = (rect.y + rect.h / 2 - 0.5) * box.h
return clampView({ x: -cx * s, y: -cy * s, s }, box, stage)
}
/** The view that centres the stage on a point of the fitted picture (its own px from
* its top-left), at the same scale: a finder's drag. */
export function centreOn(
point: Point,
view: View,
box: Size,
stage: Size,
): View {
return clampView(
{
x: (box.w / 2 - point.x) * view.s,
y: (box.h / 2 - point.y) * view.s,
s: view.s,
},
box,
stage,
)
}// THE HAND ON A SURFACE, read once for every viewer that zooms: a pinch (a trackpad's,
// which arrives as a ctrl-wheel in Chrome and Firefox and as gesture events in Safari;
// two fingers on a screen), a wheel that is not a pinch, and one pointer's drag. The
// readers (VideoPicture's zoom, film-wall) say what each means; how a pinch is told
// from a rest, where it is centred and how far it has stretched is decided here, so a
// pinch is the same distance of the hand in every one of them.
//
// A pinch's STRETCH is the fingers' spread over where they began (a trackpad's alike,
// `exp(-deltaY × PINCH_PER_WHEEL)` per wheel step): above 1 a spread, below a close.
// It has a direction once it has moved PINCH_SLOP from 1 (two fingers resting are not
// a pinch yet); `begin` says which, then `pinch` follows it to `end`. A wheel's
// gesture ends when no event has come for WHEEL_END_MS: browsers report no phases.
import { wheelPx, wheelTick } from "@/registry/base-nova/lib/lightbox-motion"
import {
PINCH_PER_WHEEL,
PINCH_SLOP,
} from "@/registry/base-nova/lib/picture-zoom"
/** A point on the screen, client px. */
export interface Client {
x: number
y: number
}
/** Quiet that ends a wheel's pinch or pan: browsers report no wheel phases. */
export const WHEEL_END_MS = 150
/** px a press travels before it is a drag, not a click. */
export const DRAG_SLOP = 4
export interface PinchHandlers {
/** Whether the hand starting on `target` is the surface's: false on its controls. */
accepts?: (target: EventTarget | null) => boolean
/** A pinch has its direction: a spread (`out`) or a close, centred at `at`. */
begin: (out: boolean, at: Client) => void
/** Where it has stretched to since it began, centred at `at`; `start` on its first. */
pinch: (stretch: number, at: Client, start: boolean) => void
/** The pinch let go. */
end: () => void
/** A wheel that is no pinch: the reader's (a pan, a swipe), or not. True takes it
* (the page does not scroll) and `wheelEnd` follows the quiet after it. */
wheel?: (e: WheelEvent) => boolean
wheelEnd?: () => void
/** A press of one pointer: whether its drag is the reader's (a mouse's or a pen's is
* not tracked otherwise; a finger is, a second one may join it for a pinch). */
press?: (e: PointerEvent) => boolean
/** The pressed pointer moved past DRAG_SLOP, and on after it: by `dx`, `dy` px. */
drag?: (dx: number, dy: number, e: PointerEvent) => void
/** The last pointer let go after a drag. */
release?: () => void
}
/** Safari's pinch: a scale since it began, at a point. */
type GestureEvent = UIEvent & {
scale: number
clientX: number
clientY: number
}
/** Reads the hand on `el` for `h` until the returned function is called. */
export function pinchInput(el: HTMLElement, h: PinchHandlers): () => void {
const accepts = (target: EventTarget | null) => h.accepts?.(target) ?? true
// ── a wheel: a pinch with ctrl, else the reader's ──
let wheel: "pinch" | "other" | null = null
let stretch = 1
let wheelEnd = 0
const quiet = () => {
const was = wheel
wheel = null
if (was === "pinch") h.end()
else if (was === "other") h.wheelEnd?.()
}
// Safari's gesture events carry the pinch there; a ctrl-wheel during one is ignored.
let gesture = false
const onWheel = (e: WheelEvent) => {
if (!accepts(e.target)) return
if (e.ctrlKey) {
e.preventDefault()
if (gesture) return
if (wheel === "other") quiet()
const start = wheel !== "pinch"
if (start) stretch = 1
wheel = "pinch"
// In px, one event bounded (the lightbox's tick): a mouse notch with ctrl is a
// nudge, never a whole rung.
const dy = wheelTick(wheelPx(e.deltaY, e.deltaMode, window.innerHeight))
stretch *= Math.exp(-dy * PINCH_PER_WHEEL)
const at = { x: e.clientX, y: e.clientY }
// A spread is a negative deltaY.
if (start) h.begin(dy < 0, at)
h.pinch(stretch, at, start)
} else {
// A pinch under way keeps the wheel: none of it scrolls the page.
if (wheel === "pinch") {
e.preventDefault()
return
}
if (!h.wheel?.(e)) return
e.preventDefault()
wheel = "other"
}
window.clearTimeout(wheelEnd)
wheelEnd = window.setTimeout(quiet, WHEEL_END_MS)
}
let gestureBegun = false
const onGestureStart = (e: Event) => {
if (!accepts(e.target)) return
e.preventDefault()
// A pinch already read as a ctrl-wheel, or as fingers (iOS sends both), keeps its
// reader: one pinch is never begun twice.
if (wheel === "pinch" || points.size > 0) return
gesture = true
gestureBegun = false
}
const onGestureChange = (e: Event) => {
if (!gesture) {
e.preventDefault()
return
}
e.preventDefault()
const g = e as GestureEvent
const at = { x: g.clientX, y: g.clientY }
if (!gestureBegun) {
if (Math.abs(g.scale - 1) < PINCH_SLOP) return
gestureBegun = true
h.begin(g.scale > 1, at)
h.pinch(g.scale, at, true)
return
}
h.pinch(g.scale, at, false)
}
const onGestureEnd = (e: Event) => {
e.preventDefault()
if (!gesture) return
gesture = false
if (gestureBegun) h.end()
}
// ── pointers: two fingers pinch, one (or a mouse the reader takes) drags ──
const points = new Map<number, Client>()
let spread = 0
let pinching = false
let pressed: Client | null = null
let dragging = false
let drags = false
const two = () => {
const [a, b] = [...points.values()]
if (!a || !b) return null
return {
d: Math.hypot(a.x - b.x, a.y - b.y),
mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
}
}
const onDown = (e: PointerEvent) => {
if (!accepts(e.target)) return
const finger = e.pointerType === "touch"
// One hand at a time (the lightbox's rule): a finger landing ends a wheel's run.
if (finger && wheel !== null) {
window.clearTimeout(wheelEnd)
quiet()
}
if (points.size === 0) {
drags = h.press?.(e) ?? false
if (!finger && (!drags || e.button !== 0)) return
pressed = { x: e.clientX, y: e.clientY }
dragging = false
}
if (!finger) return points.set(e.pointerId, { x: e.clientX, y: e.clientY })
points.set(e.pointerId, { x: e.clientX, y: e.clientY })
const both = two()
if (both) {
spread = both.d
pinching = false
}
}
const onMove = (e: PointerEvent) => {
const prev = points.get(e.pointerId)
if (!prev) return
points.set(e.pointerId, { x: e.clientX, y: e.clientY })
const both = two()
if (both) {
if (!(spread > 0)) return
const ratio = both.d / spread
if (!pinching) {
if (Math.abs(ratio - 1) < PINCH_SLOP) return
pinching = true
h.begin(ratio > 1, both.mid)
h.pinch(ratio, both.mid, true)
return
}
h.pinch(ratio, both.mid, false)
return
}
if (!drags) return
if (
!dragging &&
pressed &&
Math.hypot(e.clientX - pressed.x, e.clientY - pressed.y) < DRAG_SLOP
)
return
dragging = true
h.drag?.(e.clientX - prev.x, e.clientY - prev.y, e)
}
const onUp = (e: PointerEvent) => {
if (!points.delete(e.pointerId)) return
if (points.size === 1) {
// From two fingers to one: the pinch is over, a drag goes on from here.
if (pinching) h.end()
pinching = false
spread = 0
dragging = true
return
}
if (points.size === 0) {
pressed = null
if (dragging && drags) h.release?.()
dragging = false
}
}
el.addEventListener("wheel", onWheel, { passive: false })
el.addEventListener("gesturestart", onGestureStart)
el.addEventListener("gesturechange", onGestureChange)
el.addEventListener("gestureend", onGestureEnd)
el.addEventListener("pointerdown", onDown)
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
window.addEventListener("pointercancel", onUp)
return () => {
window.clearTimeout(wheelEnd)
el.removeEventListener("wheel", onWheel)
el.removeEventListener("gesturestart", onGestureStart)
el.removeEventListener("gesturechange", onGestureChange)
el.removeEventListener("gestureend", onGestureEnd)
el.removeEventListener("pointerdown", onDown)
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
window.removeEventListener("pointercancel", onUp)
}
}// Captions as data: the languages a medium is captioned in (a WebVTT file each), the
// lines a file holds, which of them show at an instant, and which language a person's
// choice lands on. Framework-free: ui/media-captions binds it to a media element (the
// browser parses the WebVTT, its entries are read off the element's TextTrack) and the
// player draws it.
//
// The words of "Time" hold: a line is shown over a span, in the medium's own time. The
// browser calls a WebVTT file's entries cues; here they are lines, the word a person
// reading captions would use, since a cue means a scroll stage's act everywhere else.
import type { Span } from "@/registry/base-nova/lib/clip"
/** One language's captions for a medium: a WebVTT file. `lang` is BCP 47 (`en`,
* `es-ES`); `label` is what the menu says (`English`); `default` shows it to a person
* who never chose. */
export interface Captions {
src: string
lang: string
label: string
default?: boolean
}
/** What is shown over a span: the words, the file's own line breaks kept, and who says
* them when the file names a voice (`<v Kennedy>`). */
export interface CaptionLine {
span: Span
text: string
voice?: string
}
/** A person's choice, as kept: captions on, off, or never chosen (null: the medium's
* default decides), and the language last picked. */
export interface CaptionsChoice {
on: boolean | null
lang: string | null
}
export const NEVER_CHOSEN: CaptionsChoice = { on: null, lang: null }
/** The captions offered, checked: each with a file, a language and a label, no
* language twice, at most one default. A list that breaks these screams, naming it. */
export function checkCaptions(
offered: readonly Captions[],
): readonly Captions[] {
const langs = new Set<string>()
for (const c of offered) {
if (!c.src || !c.lang.trim() || !c.label.trim())
throw new Error(
`captions: every file needs a src, a lang and a label (${JSON.stringify(c)})`,
)
const lang = c.lang.toLowerCase()
if (langs.has(lang))
throw new Error(`captions: "${c.lang}" is offered twice`)
langs.add(lang)
}
if (offered.filter((c) => c.default).length > 1)
throw new Error("captions: more than one is the default")
return offered
}
/** The captions a choice shows: none when turned off; when turned on, the language
* last picked (or one of its family: `en-US` for `en`), else the default, else the
* first; never chosen, the default or none. */
export function shownOf(
offered: readonly Captions[],
choice: CaptionsChoice,
): Captions | null {
const fallback = offered.find((c) => c.default) ?? null
if (choice.on === false) return null
if (choice.on === null) return fallback
return languageOf(offered, choice.lang) ?? fallback ?? offered[0] ?? null
}
/** A choice turned the other way: off when it shows any, else on, in the language
* last picked where offered, else the default, else the first. */
export function toggledChoice(
offered: readonly Captions[],
was: CaptionsChoice,
): CaptionsChoice {
if (shownOf(offered, was)) return { ...was, on: false }
return {
on: true,
lang:
languageOf(offered, was.lang)?.lang ??
(offered.find((c) => c.default) ?? offered[0])?.lang ??
null,
}
}
/** The captions in `lang`, exactly, else of its family (the same primary subtag). */
export function languageOf(
offered: readonly Captions[],
lang: string | null,
): Captions | null {
if (!lang) return null
const want = lang.toLowerCase()
const family = want.split("-")[0]
return (
offered.find((c) => c.lang.toLowerCase() === want) ??
offered.find((c) => c.lang.toLowerCase().split("-")[0] === family) ??
null
)
}
const ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
lrm: "",
rlm: "",
}
/** The lines a WebVTT file holds, as the browser parsed it (a TextTrack's entries:
* times in seconds, the text with its markup): times in ms, sorted, the voice taken
* out of `<v Name>`, every other tag dropped (`<i>`, `<b>`, `<c.x>`, the karaoke
* timestamps), entities read. An entry ending before it starts screams. */
export function linesOf(
parsed: readonly { startTime: number; endTime: number; text: string }[],
): CaptionLine[] {
return parsed
.map((p) => {
const from = Math.round(p.startTime * 1000)
const to = Math.round(p.endTime * 1000)
if (!(to >= from))
throw new Error(
`captions: a line ends (${to} ms) before it starts (${from} ms): "${p.text}"`,
)
const voice = /<v(?:\.[^\s>]*)?\s+([^>]+)>/.exec(p.text)?.[1]?.trim()
const text = p.text
.replace(/<[^>]*>/g, "")
.replace(/&(#\d+|#x[\da-f]+|[a-z]+);/gi, (all, name: string) => {
if (name[0] === "#")
return String.fromCodePoint(
name[1] === "x" || name[1] === "X"
? Number.parseInt(name.slice(2), 16)
: Number.parseInt(name.slice(1), 10),
)
return ENTITIES[name.toLowerCase()] ?? all
})
.trim()
return { span: { from, to }, text, ...(voice ? { voice } : {}) }
})
.sort((a, b) => a.span.from - b.span.from)
}
/** The lines showing at `ms`: every one whose span holds it (from inclusive, to
* exclusive), in order; two speakers at once are two lines. */
export function linesAt(
lines: readonly CaptionLine[],
ms: number,
): CaptionLine[] {
return lines.filter((l) => l.span.from <= ms && ms < l.span.to)
}