← ui

playback

ui

a clip's own clock that plays once in view, and React's side of any timebase

npx shadcn add @ag/playback
Already installed? Preview an update
npx shadcn add @ag/playback --dry-run

Your 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.
Updated 2026-10-14 · release notes

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

playback.demo.tsx
"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

playback.tsx
"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>
  )
}
media-timebase.tsx
"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())
    },
  }
}
media-captions.tsx
"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>
  )
}
picture-zoom.ts
// 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,
  )
}
pinch-input.ts
// 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.ts
// 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)
}