{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "video-player",
  "type": "registry:ui",
  "title": "Video Player",
  "description": "Custom controls over native video, with chapters, subtitles and a morphing play button.",
  "categories": [
    "media"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/video-player.tsx",
      "content": "\"use client\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport { Slider } from \"@base-ui/react/slider\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/* ------------------------------------------------------------------ media state */\n\nconst noop = () => {};\nconst never = () => noop;\n\n/** Reads one property of a media element and re-reads it when any of `events` fire. */\nfunction useMediaValue<T extends string | number | boolean>(el: HTMLMediaElement | null, events: readonly string[], read: (el: HTMLMediaElement) => T, fallback: T): T {\n  const key = events.join(\" \");\n  const subscribe = useCallback(\n    (cb: () => void) => {\n      if (!el) return noop;\n      const list = key.split(\" \");\n      list.forEach((e) => el.addEventListener(e, cb));\n      return () => list.forEach((e) => el.removeEventListener(e, cb));\n    },\n    [el, key],\n  );\n  return useSyncExternalStore(subscribe, () => (el ? read(el) : fallback), () => fallback);\n}\n\nconst PLAY_EVENTS = [\"play\", \"pause\", \"ended\", \"emptied\"] as const;\nconst TIME_EVENTS = [\"timeupdate\", \"seeking\", \"seeked\", \"durationchange\", \"emptied\"] as const;\nconst DURATION_EVENTS = [\"loadedmetadata\", \"durationchange\", \"emptied\"] as const;\nconst VOLUME_EVENTS = [\"volumechange\"] as const;\nconst RATE_EVENTS = [\"ratechange\"] as const;\nconst BUFFER_EVENTS = [\"progress\", \"loadedmetadata\", \"seeked\", \"emptied\"] as const;\nconst WAIT_EVENTS = [\"waiting\", \"playing\", \"canplay\", \"pause\", \"seeked\", \"emptied\", \"error\"] as const;\n\n/** currentTime, re-read every frame while playing and `live`, so the scrubber glides instead of stepping at 4Hz. */\nfunction useSmoothTime(el: HTMLMediaElement | null, live: boolean) {\n  const subscribe = useCallback(\n    (cb: () => void) => {\n      if (!el) return noop;\n      let raf = 0;\n      const loop = () => {\n        cb();\n        raf = requestAnimationFrame(loop);\n      };\n      const sync = () => {\n        cancelAnimationFrame(raf);\n        cb();\n        if (live && !el.paused && !el.ended) raf = requestAnimationFrame(loop);\n      };\n      const events = [\"play\", \"playing\", \"pause\", \"ended\", \"seeked\", \"seeking\", \"timeupdate\", \"durationchange\", \"emptied\"];\n      events.forEach((e) => el.addEventListener(e, sync));\n      sync();\n      return () => {\n        cancelAnimationFrame(raf);\n        events.forEach((e) => el.removeEventListener(e, sync));\n      };\n    },\n    [el, live],\n  );\n  return useSyncExternalStore(subscribe, () => el?.currentTime ?? 0, () => 0);\n}\n\nconst readBuffered = (el: HTMLMediaElement) => {\n  let out = \"\";\n  for (let i = 0; i < el.buffered.length; i++) out += `${out ? \",\" : \"\"}${el.buffered.start(i).toFixed(2)}-${el.buffered.end(i).toFixed(2)}`;\n  return out;\n};\n\nconst readDuration = (el: HTMLMediaElement) => (Number.isFinite(el.duration) ? el.duration : 0);\n\n/** 72 → \"1:12\", 3725 → \"1:02:05\". Negative values get a leading minus for \"time remaining\". */\nexport function formatTime(seconds: number, reference = seconds) {\n  const sign = seconds < 0 ? \"-\" : \"\";\n  const s = Math.floor(Math.abs(seconds));\n  const h = Math.floor(s / 3600);\n  const m = Math.floor((s % 3600) / 60);\n  const ss = String(s % 60).padStart(2, \"0\");\n  return Math.abs(reference) >= 3600 ? `${sign}${h}:${String(m).padStart(2, \"0\")}:${ss}` : `${sign}${m}:${ss}`;\n}\n\n/** Spoken form for screen readers: \"1 minute 12 seconds\". */\nfunction speakTime(seconds: number) {\n  const s = Math.max(0, Math.floor(seconds));\n  const h = Math.floor(s / 3600);\n  const m = Math.floor((s % 3600) / 60);\n  const parts = [h && `${h} hour${h === 1 ? \"\" : \"s\"}`, m && `${m} minute${m === 1 ? \"\" : \"s\"}`, `${s % 60} second${s % 60 === 1 ? \"\" : \"s\"}`];\n  return parts.filter(Boolean).join(\" \");\n}\n\nconst subscribeFullscreen = (cb: () => void) => {\n  document.addEventListener(\"fullscreenchange\", cb);\n  return () => document.removeEventListener(\"fullscreenchange\", cb);\n};\nconst canPip = () => typeof document !== \"undefined\" && !!document.pictureInPictureEnabled;\n\nfunction assignRef<T>(ref: React.Ref<T> | undefined, value: T | null) {\n  if (typeof ref === \"function\") ref(value);\n  else if (ref) ref.current = value;\n}\n\n/* ------------------------------------------------------------------ types */\n\nexport type VideoTrack = {\n  src: string;\n  /** BCP 47 code, e.g. \"en\". */\n  srcLang: string;\n  label: string;\n  default?: boolean;\n};\n\nexport type VideoChapter = { start: number; title: string };\n\ntype Bezel = { id: number; icon: React.ReactNode; label?: string; side?: \"left\" | \"right\" | \"center\" };\n\nexport type VideoPlayerProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"title\"> & {\n  src: string;\n  poster?: string;\n  /** Names the player for assistive tech, and shows over the poster before playback. */\n  title?: string;\n  /** Subtitle tracks (WebVTT). Rendered by the player, above the controls, not by the browser. */\n  tracks?: VideoTrack[];\n  /** Chapter starts in seconds. The scrubber splits at each one and the hover time names it. */\n  chapters?: VideoChapter[];\n  /** Length in seconds if you already know it, shown until the file's metadata arrives (useful with preload=\"none\"). */\n  duration?: number;\n  /** Width / height. Reserved before metadata so nothing jumps. */\n  aspectRatio?: number;\n  /** Milliseconds of stillness before the controls hide during playback. */\n  autoHideDelay?: number;\n  playbackRates?: number[];\n  defaultPlaybackRate?: number;\n  defaultVolume?: number;\n  defaultMuted?: boolean;\n  /** Keep the subtitles on from the start when a track exists. */\n  defaultCaptions?: boolean;\n  loop?: boolean;\n  preload?: \"none\" | \"metadata\" | \"auto\";\n  /** Anything else for the underlying <video>: crossOrigin, onEnded, onTimeUpdate… */\n  videoProps?: Omit<React.ComponentProps<\"video\">, \"src\" | \"poster\" | \"controls\" | \"ref\">;\n  videoRef?: React.Ref<HTMLVideoElement>;\n};\n\n/* ------------------------------------------------------------------ player */\n\nexport function VideoPlayer({\n  src,\n  poster,\n  title,\n  tracks = [],\n  chapters,\n  duration: durationProp,\n  aspectRatio = 16 / 9,\n  autoHideDelay = 2500,\n  playbackRates = [0.5, 0.75, 1, 1.25, 1.5, 2],\n  defaultPlaybackRate = 1,\n  defaultVolume = 1,\n  defaultMuted = false,\n  defaultCaptions = false,\n  loop,\n  preload = \"metadata\",\n  videoProps,\n  videoRef,\n  className,\n  style,\n  onKeyDown,\n  ...rest\n}: VideoPlayerProps) {\n  const reduce = !!useReducedMotion();\n  // The element lives twice: in state so subscriptions re-bind when it mounts, and in a\n  // ref so event handlers can command it (play, seek, volume) without mutating render state.\n  const [video, setVideo] = useState<HTMLVideoElement | null>(null);\n  const media = useRef<HTMLVideoElement | null>(null);\n  const [root, setRoot] = useState<HTMLDivElement | null>(null);\n  const [layer, setLayer] = useState<HTMLDivElement | null>(null);\n\n  const attachVideo = useCallback(\n    (el: HTMLVideoElement | null) => {\n      media.current = el;\n      setVideo(el);\n      assignRef(videoRef, el);\n    },\n    [videoRef],\n  );\n\n  const paused = useMediaValue(video, PLAY_EVENTS, (v) => v.paused, true);\n  const ended = useMediaValue(video, PLAY_EVENTS, (v) => v.ended, false);\n  const duration = useMediaValue(video, DURATION_EVENTS, readDuration, 0) || durationProp || 0;\n  const volume = useMediaValue(video, VOLUME_EVENTS, (v) => v.volume, defaultVolume);\n  const muted = useMediaValue(video, VOLUME_EVENTS, (v) => v.muted, defaultMuted);\n  const rate = useMediaValue(video, RATE_EVENTS, (v) => v.playbackRate, defaultPlaybackRate);\n  const networkWaiting = useMediaValue(video, WAIT_EVENTS, (v) => !v.paused && v.readyState < 3, false);\n  const fullscreen = useSyncExternalStore(subscribeFullscreen, () => !!root && document.fullscreenElement === root, () => false);\n  const pipSupported = useSyncExternalStore(never, canPip, () => false);\n\n  const [started, setStarted] = useState(false);\n  const [failed, setFailed] = useState(false);\n  const [active, setActive] = useState(true);\n  const [menuOpen, setMenuOpen] = useState(false);\n  const [scrubbing, setScrubbing] = useState(false);\n  const [previewing, setPreviewing] = useState(false);\n  const [keyboardInside, setKeyboardInside] = useState(false);\n  const [remaining, setRemaining] = useState(false);\n  const [captions, setCaptions] = useState(defaultCaptions);\n  const [trackIndex, setTrackIndex] = useState(() => Math.max(0, tracks.findIndex((t) => t.default)));\n  const [cue, setCue] = useState<string[]>([]);\n  const [bezel, setBezel] = useState<Bezel | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [waiting, setWaiting] = useState(false);\n\n  const idleTimer = useRef<number>(undefined);\n  const bezelTimer = useRef<number>(undefined);\n  const lastTap = useRef<{ t: number; x: number }>({ t: 0, x: 0 });\n  const tapTimer = useRef<number>(undefined);\n  const bezelId = useRef(0);\n\n  const controlsVisible = !started || paused || ended || failed || active || menuOpen || scrubbing || keyboardInside;\n\n  // Initial media settings, applied once the element exists.\n  useEffect(() => {\n    const v = media.current;\n    if (!v) return;\n    v.volume = defaultVolume;\n    v.muted = defaultMuted;\n    v.playbackRate = defaultPlaybackRate;\n    v.defaultPlaybackRate = defaultPlaybackRate;\n    // Only for the first element; later prop changes are the user's to make through the controls.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // The browser only flags \"waiting\" briefly on a fast network; hold the spinner back\n  // 250ms so a quick seek never flashes it.\n  useEffect(() => {\n    if (!networkWaiting) {\n      const t = window.setTimeout(() => setWaiting(false), 0);\n      return () => window.clearTimeout(t);\n    }\n    const t = window.setTimeout(() => setWaiting(true), 250);\n    return () => window.clearTimeout(t);\n  }, [networkWaiting]);\n\n  useEffect(() => {\n    const video = media.current;\n    if (!video) return;\n    const onPlay = () => {\n      setStarted(true);\n      setFailed(false);\n    };\n    const onError = () => setFailed(true);\n    const onEmptied = () => setStarted(false);\n    video.addEventListener(\"play\", onPlay);\n    video.addEventListener(\"error\", onError);\n    video.addEventListener(\"emptied\", onEmptied);\n    // An error that happened before hydration never fires again.\n    if (video.error) onError();\n    return () => {\n      video.removeEventListener(\"play\", onPlay);\n      video.removeEventListener(\"error\", onError);\n      video.removeEventListener(\"emptied\", onEmptied);\n    };\n  }, []);\n\n  // Subtitles: the browser loads cues (\"hidden\" mode) and we draw them, so they can\n  // sit above the controls and move with them instead of hiding underneath.\n  useEffect(() => {\n    const video = media.current;\n    if (!video) return;\n    const list = video.textTracks;\n    const bind = () => {\n      const handlers: Array<() => void> = [];\n      for (let i = 0; i < list.length; i++) {\n        const track = list[i];\n        track.mode = \"hidden\";\n        if (i !== trackIndex) continue;\n        const read = () => {\n          const lines: string[] = [];\n          const activeCues = track.activeCues;\n          if (activeCues) for (let c = 0; c < activeCues.length; c++) lines.push(...((activeCues[c] as VTTCue).text ?? \"\").replace(/<[^>]+>/g, \"\").split(\"\\n\"));\n          setCue(lines);\n        };\n        track.addEventListener(\"cuechange\", read);\n        read();\n        handlers.push(() => track.removeEventListener(\"cuechange\", read));\n      }\n      return () => handlers.forEach((h) => h());\n    };\n    let unbind = bind();\n    const rebind = () => {\n      unbind();\n      unbind = bind();\n    };\n    list.addEventListener(\"addtrack\", rebind);\n    return () => {\n      unbind();\n      list.removeEventListener(\"addtrack\", rebind);\n    };\n  }, [video, trackIndex, tracks.length]);\n\n  useEffect(\n    () => () => {\n      window.clearTimeout(idleTimer.current);\n      window.clearTimeout(bezelTimer.current);\n      window.clearTimeout(tapTimer.current);\n    },\n    [],\n  );\n\n  /* ---------------------------------------------------------------- actions */\n\n  const wake = useCallback(() => {\n    setActive(true);\n    window.clearTimeout(idleTimer.current);\n    idleTimer.current = window.setTimeout(() => setActive(false), autoHideDelay);\n  }, [autoHideDelay]);\n\n  const flash = useCallback((b: Omit<Bezel, \"id\">) => {\n    window.clearTimeout(bezelTimer.current);\n    setBezel({ ...b, id: ++bezelId.current });\n    bezelTimer.current = window.setTimeout(() => setBezel(null), 650);\n  }, []);\n\n  const say = useCallback((text: string) => {\n    // Re-announcing the same words needs the text to change, so toggle a trailing space.\n    setAnnouncement((prev) => (prev === text ? `${text} ` : text));\n  }, []);\n\n  const togglePlay = useCallback(() => {\n    const video = media.current;\n    if (!video) return;\n    if (video.paused || video.ended) {\n      if (video.ended) video.currentTime = 0;\n      video.play().catch(() => {\n        // Autoplay policy or a decode failure. The error event covers the second; the first needs no noise.\n      });\n    } else video.pause();\n  }, []);\n\n  const seekBy = useCallback(\n    (delta: number) => {\n      const video = media.current;\n      if (!video || !duration) return;\n      video.currentTime = Math.min(duration, Math.max(0, video.currentTime + delta));\n    },\n    [duration],\n  );\n\n  const setVol = useCallback(\n    (v: number) => {\n      const video = media.current;\n      if (!video) return;\n      const next = Math.min(1, Math.max(0, Math.round(v * 100) / 100));\n      video.volume = next;\n      video.muted = next === 0;\n    },\n    [],\n  );\n\n  const toggleMute = useCallback(() => {\n    const video = media.current;\n    if (!video) return;\n    if (video.muted || video.volume === 0) {\n      video.muted = false;\n      if (video.volume === 0) video.volume = 0.5;\n    } else video.muted = true;\n  }, []);\n\n  const toggleFullscreen = useCallback(() => {\n    const video = media.current;\n    if (!root || !video) return;\n    if (document.fullscreenElement) document.exitFullscreen().catch(noop);\n    else if (root.requestFullscreen) root.requestFullscreen().catch(noop);\n    // iOS Safari only lets the <video> itself go full screen.\n    else (video as HTMLVideoElement & { webkitEnterFullscreen?: () => void }).webkitEnterFullscreen?.();\n  }, [root]);\n\n  const togglePip = useCallback(() => {\n    const video = media.current;\n    if (!video) return;\n    if (document.pictureInPictureElement) document.exitPictureInPicture().catch(noop);\n    else video.requestPictureInPicture().catch(noop);\n  }, []);\n\n  const setRate = useCallback(\n    (r: number) => {\n      if (media.current) media.current.playbackRate = r;\n    },\n    [],\n  );\n\n  const retry = useCallback(() => {\n    const video = media.current;\n    if (!video) return;\n    setFailed(false);\n    video.load();\n  }, []);\n\n  /* ---------------------------------------------------------------- keyboard */\n\n  const handleKey = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(e);\n    const video = media.current;\n    if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || !video) return;\n    const target = e.target as HTMLElement;\n    // Menus own their keys; sliders own their arrows; buttons own Space and Enter.\n    if (target.closest(\"[role=menu]\")) return;\n    const onSlider = target instanceof HTMLInputElement && target.type === \"range\";\n    const onButton = target.tagName === \"BUTTON\";\n    const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;\n    const act = (fn: () => void) => {\n      // A focused player owns the keys it handles: page-level shortcuts must not also fire.\n      e.preventDefault();\n      e.stopPropagation();\n      wake();\n      fn();\n    };\n    switch (key) {\n      case \" \":\n        if (onButton) return;\n        return act(() => {\n          togglePlay();\n          flash({ icon: <PlayGlyph paused={!video.paused} /> });\n        });\n      case \"k\":\n        return act(() => {\n          togglePlay();\n          flash({ icon: <PlayGlyph paused={!video.paused} /> });\n        });\n      case \"j\":\n      case \"l\": {\n        const d = key === \"j\" ? -10 : 10;\n        return act(() => {\n          seekBy(d);\n          flash({ icon: <SeekGlyph dir={d < 0 ? -1 : 1} />, label: `${d > 0 ? \"+\" : \"−\"}10s`, side: d < 0 ? \"left\" : \"right\" });\n        });\n      }\n      case \"ArrowLeft\":\n      case \"ArrowRight\": {\n        if (onSlider) return;\n        const d = key === \"ArrowLeft\" ? -5 : 5;\n        return act(() => {\n          seekBy(d);\n          flash({ icon: <SeekGlyph dir={d < 0 ? -1 : 1} />, label: `${d > 0 ? \"+\" : \"−\"}5s`, side: d < 0 ? \"left\" : \"right\" });\n        });\n      }\n      case \"ArrowUp\":\n      case \"ArrowDown\": {\n        if (onSlider) return;\n        return act(() => {\n          const next = Math.min(1, Math.max(0, (video.muted ? 0 : video.volume) + (key === \"ArrowUp\" ? 0.1 : -0.1)));\n          setVol(next);\n          flash({ icon: <VolumeGlyph level={next} muted={next === 0} />, label: `${Math.round(next * 100)}%` });\n        });\n      }\n      case \"m\":\n        return act(() => {\n          const willMute = !(video.muted || video.volume === 0);\n          toggleMute();\n          flash({ icon: <VolumeGlyph level={willMute ? 0 : video.volume} muted={willMute} />, label: willMute ? \"Muted\" : `${Math.round(video.volume * 100)}%` });\n          say(willMute ? \"Muted\" : \"Unmuted\");\n        });\n      case \"f\":\n        return act(toggleFullscreen);\n      case \"c\":\n        if (!tracks.length) return;\n        return act(() => {\n          setCaptions(!captions);\n          flash({ icon: <CaptionsGlyph />, label: captions ? \"Subtitles off\" : \"Subtitles on\" });\n          say(captions ? \"Subtitles off\" : `Subtitles on, ${tracks[trackIndex]?.label ?? \"\"}`);\n        });\n      case \"<\":\n      case \">\": {\n        const i = playbackRates.indexOf(rate);\n        const next = playbackRates[Math.min(playbackRates.length - 1, Math.max(0, (i < 0 ? playbackRates.indexOf(1) : i) + (key === \">\" ? 1 : -1)))];\n        if (next === undefined) return;\n        return act(() => {\n          setRate(next);\n          flash({ icon: <SpeedGlyph />, label: `${formatRate(next)}` });\n          say(`Speed ${formatRate(next)}`);\n        });\n      }\n      case \"Home\":\n      case \"End\":\n        if (onSlider) return;\n        return act(() => {\n          video.currentTime = key === \"Home\" ? 0 : duration;\n        });\n      default:\n        if (/^[0-9]$/.test(key) && duration) return act(() => (video.currentTime = (Number(key) / 10) * duration));\n    }\n  };\n\n  /* ---------------------------------------------------------------- pointer on the picture */\n\n  const onSurfacePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0 || failed) return;\n    if (e.pointerType === \"mouse\") return; // mouse uses click / double-click below\n    // Touch: one tap shows or hides the controls; two quick taps on a side seek 10s.\n    const now = performance.now();\n    const rect = e.currentTarget.getBoundingClientRect();\n    const x = (e.clientX - rect.left) / rect.width;\n    const double = now - lastTap.current.t < 300 && Math.abs(x - lastTap.current.x) < 0.2;\n    lastTap.current = { t: now, x };\n    window.clearTimeout(tapTimer.current);\n    if (double && started && (x < 0.35 || x > 0.65)) {\n      const d = x < 0.35 ? -10 : 10;\n      seekBy(d);\n      flash({ icon: <SeekGlyph dir={d < 0 ? -1 : 1} />, label: `${d > 0 ? \"+\" : \"−\"}10s`, side: d < 0 ? \"left\" : \"right\" });\n      lastTap.current = { t: now, x }; // allow triple-tap to keep seeking\n      return;\n    }\n    if (!started) return togglePlay();\n    tapTimer.current = window.setTimeout(() => {\n      if (controlsVisible && !paused) {\n        window.clearTimeout(idleTimer.current);\n        setActive(false);\n      } else wake();\n    }, 180);\n  };\n\n  const onSurfaceClick = (e: React.MouseEvent<HTMLDivElement>) => {\n    if (failed || (e.nativeEvent as PointerEvent).pointerType === \"touch\") return;\n    togglePlay();\n    if (started) flash({ icon: <PlayGlyph paused={paused} /> });\n  };\n\n  const trackCount = tracks.length;\n  const currentTrack = tracks[trackIndex];\n  const showCaption = captions && trackCount > 0 && cue.length > 0;\n\n  return (\n    <Tooltip.Provider delay={500} closeDelay={0}>\n      <div\n        ref={setRoot}\n        role=\"region\"\n        aria-label={title ?? \"Video player\"}\n        tabIndex={0}\n        data-state={failed ? \"error\" : !started ? \"idle\" : ended ? \"ended\" : paused ? \"paused\" : \"playing\"}\n        data-controls={controlsVisible ? \"visible\" : \"hidden\"}\n        data-fullscreen={fullscreen ? \"\" : undefined}\n        onKeyDown={handleKey}\n        onPointerMove={(e) => {\n          if (e.pointerType === \"mouse\") wake();\n        }}\n        onPointerLeave={(e) => {\n          if (e.pointerType !== \"mouse\" || scrubbing) return;\n          window.clearTimeout(idleTimer.current);\n          idleTimer.current = window.setTimeout(() => setActive(false), 300);\n        }}\n        onFocus={(e) => {\n          if (e.target !== e.currentTarget && e.target.matches(\":focus-visible\")) setKeyboardInside(true);\n        }}\n        onBlur={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node)) setKeyboardInside(false);\n        }}\n        className={cn(\n          \"@container group/player relative isolate w-full select-none\",\n          \"rounded-xl shadow-[var(--shadow)] data-fullscreen:shadow-none outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"data-fullscreen:rounded-none data-fullscreen:bg-page\",\n          \"data-[controls=hidden]:data-[state=playing]:cursor-none\",\n          className,\n        )}\n        style={{ aspectRatio, ...style }}\n        {...rest}\n      >\n        {/* Everything inside is a dark surface in both themes (pictures read best on black), while the\n            frame's own shadow and focus ring follow the page. Popups portal in here to inherit it. */}\n        <div ref={setLayer} data-theme=\"dark\" className=\"absolute inset-0 rounded-[inherit] text-fg\">\n        {/* The picture layer clips to the radius; controls and popups live above it, unclipped. */}\n        <div className=\"absolute inset-0 overflow-hidden rounded-[inherit] bg-page ring-1 ring-line in-data-fullscreen:ring-0\">\n          <video\n            ref={attachVideo}\n            src={src}\n            poster={poster}\n            loop={loop}\n            preload={preload}\n            playsInline\n            {...videoProps}\n            className={cn(\"absolute inset-0 size-full object-contain\", videoProps?.className)}\n          >\n            {tracks.map((t) => (\n              <track key={t.src} kind=\"subtitles\" src={t.src} srcLang={t.srcLang} label={t.label} />\n            ))}\n          </video>\n\n          {/* Scrim, so white controls hold contrast on a bright frame. Fades with the controls. */}\n          <div\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute inset-x-0 bottom-0 h-28 bg-linear-to-t from-page/85 via-page/35 to-transparent\",\n              \"transition-opacity duration-300 ease-out-quart group-data-[controls=visible]/player:opacity-100 group-data-[controls=hidden]/player:opacity-0 group-data-[controls=visible]/player:duration-150\",\n            )}\n          />\n          {title && (\n            <div\n              aria-hidden\n              className={cn(\n                \"pointer-events-none absolute inset-x-0 top-0 h-20 bg-linear-to-b from-page/70 to-transparent transition-opacity duration-200 ease-out-quart\",\n                started && \"opacity-0\",\n              )}\n            />\n          )}\n        </div>\n\n        {/* Click / tap surface */}\n        <div\n          aria-hidden\n          className=\"absolute inset-0\"\n          onClick={onSurfaceClick}\n          onDoubleClick={(e) => {\n            if ((e.nativeEvent as PointerEvent).pointerType !== \"touch\") toggleFullscreen();\n          }}\n          onPointerUp={onSurfacePointerUp}\n        />\n\n        {/* Title over the poster, until the first play */}\n        {title && (\n          <div\n            className={cn(\n              \"pointer-events-none absolute inset-x-0 top-0 flex items-baseline gap-2 p-4 transition-[opacity,translate] duration-200 ease-out-quart @max-[420px]:p-3\",\n              started && \"-translate-y-1 opacity-0\",\n            )}\n          >\n            <p className=\"min-w-0 truncate text-[14px] font-medium tracking-[-0.015em] text-fg\">{title}</p>\n            {duration > 0 && <span className=\"tabular shrink-0 font-mono text-[11px] text-fg-2\">{formatTime(duration)}</span>}\n          </div>\n        )}\n\n        <CenterLayer\n          reduce={reduce}\n          started={started}\n          ended={ended}\n          failed={failed}\n          waiting={waiting && !failed}\n          bezel={bezel}\n          onPlay={togglePlay}\n          onRetry={retry}\n        />\n\n        <Captions text={showCaption ? cue : []} lang={currentTrack?.srcLang} lift={!started || !controlsVisible ? \"none\" : previewing ? \"preview\" : \"controls\"} />\n\n        {/* Controls */}\n        <div\n          className={cn(\n            \"absolute inset-x-0 bottom-0 flex flex-col gap-0.5 px-3 pb-2 @max-[420px]:px-2 @max-[420px]:pb-1\",\n            \"transition-[opacity,translate] ease-out-quart motion-reduce:translate-y-0\",\n            // Before the first play the poster, title and big button speak for themselves.\n            started && controlsVisible && !failed ? \"translate-y-0 opacity-100 duration-150\" : \"pointer-events-none translate-y-1 opacity-0 duration-300\",\n          )}\n          // Hidden controls leave the tab order and the accessibility tree until they return.\n          inert={!started || failed ? true : undefined}\n        >\n          <Scrubber\n            video={video}\n            mediaRef={media}\n            duration={duration}\n            live={controlsVisible}\n            chapters={chapters}\n            onScrubbingChange={setScrubbing}\n            onPreviewChange={setPreviewing}\n          />\n          <div className=\"flex h-9 items-center gap-0.5\">\n            <ControlButton label={ended ? \"Replay\" : paused ? \"Play\" : \"Pause\"} shortcut=\"K\" container={layer} onClick={togglePlay}>\n              {ended ? <ReplayGlyph /> : <PlayGlyph paused={paused} morph={!reduce} />}\n            </ControlButton>\n\n            <div className=\"group/volume flex items-center\">\n              <ControlButton label={muted || volume === 0 ? \"Unmute\" : \"Mute\"} shortcut=\"M\" container={layer} onClick={toggleMute}>\n                <VolumeGlyph level={volume} muted={muted || volume === 0} />\n              </ControlButton>\n              {/* Reveals on hover or keyboard focus; on touch the system volume is the control. */}\n              <div className=\"grid grid-cols-[0fr] transition-[grid-template-columns] duration-200 ease-out-quart group-hover/volume:grid-cols-[1fr] group-focus-within/volume:grid-cols-[1fr] pointer-coarse:hidden @max-[420px]:hidden motion-reduce:transition-none\">\n                <div className=\"min-w-0 overflow-hidden\">\n                  <VolumeSlider value={muted ? 0 : volume} onChange={setVol} />\n                </div>\n              </div>\n            </div>\n\n            <button\n              type=\"button\"\n              onClick={() => setRemaining((r) => !r)}\n              aria-label={remaining ? \"Show elapsed time\" : \"Show time remaining\"}\n              className=\"ml-1 grid h-7 place-items-center rounded-md px-1.5 outline-none transition-colors duration-150 hover:bg-fg/10 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\"\n            >\n              <TimeReadout video={video} duration={duration} remaining={remaining} />\n            </button>\n\n            <span className=\"flex-1\" />\n\n            {trackCount > 0 && (\n              <ControlButton\n                label={captions ? \"Turn off subtitles\" : \"Turn on subtitles\"}\n                shortcut=\"C\"\n                container={layer}\n                pressed={captions}\n                onClick={() => {\n                  setCaptions((c) => !c);\n                  say(captions ? \"Subtitles off\" : `Subtitles on, ${currentTrack?.label ?? \"\"}`);\n                }}\n              >\n                <CaptionsGlyph on={captions} />\n              </ControlButton>\n            )}\n\n            <SettingsMenu\n              container={layer}\n              rate={rate}\n              rates={playbackRates}\n              onRate={(r) => {\n                setRate(r);\n                say(`Speed ${formatRate(r)}`);\n              }}\n              tracks={tracks}\n              trackValue={captions ? String(trackIndex) : \"off\"}\n              onTrack={(v) => {\n                if (v === \"off\") setCaptions(false);\n                else {\n                  setTrackIndex(Number(v));\n                  setCaptions(true);\n                }\n              }}\n              onOpenChange={setMenuOpen}\n            />\n\n            {pipSupported && (\n              <ControlButton label=\"Picture in picture\" container={layer} onClick={togglePip} className=\"@max-[420px]:hidden\">\n                <PipGlyph />\n              </ControlButton>\n            )}\n\n            <ControlButton label={fullscreen ? \"Exit full screen\" : \"Full screen\"} shortcut=\"F\" container={layer} onClick={toggleFullscreen}>\n              <FullscreenGlyph on={fullscreen} />\n            </ControlButton>\n          </div>\n        </div>\n\n        </div>\n        <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {announcement}\n        </span>\n      </div>\n    </Tooltip.Provider>\n  );\n}\n\n/* ------------------------------------------------------------------ center: big play, spinner, bezel, error */\n\nfunction CenterLayer({\n  reduce,\n  started,\n  ended,\n  failed,\n  waiting,\n  bezel,\n  onPlay,\n  onRetry,\n}: {\n  reduce: boolean;\n  started: boolean;\n  ended: boolean;\n  failed: boolean;\n  waiting: boolean;\n  bezel: Bezel | null;\n  onPlay: () => void;\n  onRetry: () => void;\n}) {\n  const bigButton = !failed && (!started || ended);\n  return (\n    <>\n      <AnimatePresence initial={false}>\n        {bigButton && (\n          <motion.div\n            key={ended ? \"replay\" : \"play\"}\n            className=\"pointer-events-none absolute inset-0 grid place-items-center\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 1.08, transition: { duration: 0.18, ease: ease.out } }}\n            transition={reduce ? { duration: 0.15 } : spring.pop}\n          >\n            <button\n              type=\"button\"\n              onClick={onPlay}\n              aria-label={ended ? \"Replay\" : \"Play video\"}\n              className={cn(\n                \"group/big pointer-events-auto grid size-14 place-items-center rounded-full border border-fg/15 bg-page/55 text-fg shadow-pop backdrop-blur-md @max-[420px]:size-12\",\n                \"outline-none transition-[background-color,scale] duration-150 ease-out-quart hover:bg-page/70 active:scale-[0.94] active:duration-75\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-2\",\n              )}\n            >\n              <span className=\"transition-transform duration-200 ease-out-quart group-hover/big:scale-110 motion-reduce:transition-none\">\n                {ended ? <ReplayGlyph size={22} /> : <PlayGlyph paused size={22} />}\n              </span>\n            </button>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <AnimatePresence>\n        {waiting && (\n          <motion.div\n            key=\"wait\"\n            aria-hidden\n            className=\"pointer-events-none absolute inset-0 grid place-items-center\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.12 } }}\n            transition={{ duration: 0.2 }}\n          >\n            <span className=\"grid size-12 place-items-center rounded-full bg-page/50\">\n              <svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" className=\"animate-spin text-fg [animation-duration:0.8s]\" aria-hidden>\n                <circle cx=\"12\" cy=\"12\" r=\"9\" stroke=\"currentColor\" strokeOpacity=\"0.2\" strokeWidth=\"2\" />\n                <path d=\"M21 12a9 9 0 0 0-9-9\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n              </svg>\n            </span>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {/* One bezel at a time: a new action replaces the old one in place. */}\n      <AnimatePresence>\n        {bezel && (\n          <motion.div\n            key={bezel.id}\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute inset-y-0 grid place-items-center\",\n              bezel.side === \"left\" ? \"left-0 w-1/3\" : bezel.side === \"right\" ? \"right-0 w-1/3\" : \"inset-x-0\",\n            )}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.86 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 1.1, transition: { duration: 0.28, ease: ease.out } }}\n            transition={reduce ? { duration: 0.1 } : { duration: 0.16, ease: ease.out }}\n          >\n            <span className=\"flex flex-col items-center gap-1.5\">\n              <span className=\"grid size-12 place-items-center rounded-full bg-page/60 text-fg [&_svg]:size-5\">{bezel.icon}</span>\n              {bezel.label && <span className=\"tabular rounded-md bg-page/60 px-1.5 py-0.5 font-mono text-[11px] text-fg\">{bezel.label}</span>}\n            </span>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {failed && (\n        // The frame behind stays, veiled, so the message reads on any picture.\n        <div role=\"alert\" className=\"absolute inset-0 grid place-items-center rounded-[inherit] bg-page/80 p-6 backdrop-blur-sm\">\n          <div className=\"flex max-w-[280px] flex-col items-center gap-3 text-center\">\n            <span className=\"grid size-10 place-items-center rounded-full bg-danger-soft text-danger\">\n              <svg width=\"18\" height=\"18\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" aria-hidden>\n                <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n                <path d=\"M8 5v3.5\" />\n                <circle cx=\"8\" cy=\"10.9\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n              </svg>\n            </span>\n            <div className=\"flex flex-col gap-1\">\n              <p className=\"text-[13px] font-medium text-fg\">Couldn’t play this video</p>\n              <p className=\"text-[12px] leading-[1.5] text-fg-2 [text-wrap:balance]\">The file didn’t load. Check your connection, then try again.</p>\n            </div>\n            <button\n              type=\"button\"\n              onClick={onRetry}\n              className=\"h-8 rounded-lg bg-fg px-3 text-[12.5px] font-medium text-frame outline-none transition-[background-color,scale] duration-150 hover:bg-fg/90 active:scale-[0.97] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\"\n            >\n              Try again\n            </button>\n          </div>\n        </div>\n      )}\n    </>\n  );\n}\n\n/* ------------------------------------------------------------------ captions */\n\nfunction Captions({ text, lang, lift }: { text: string[]; lang?: string; lift: \"none\" | \"controls\" | \"preview\" }) {\n  return (\n    <div\n      // Drawn for sighted viewers; the soundtrack is already audible to screen reader users.\n      aria-hidden\n      lang={lang}\n      className={cn(\n        \"pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center gap-0.5 px-6 pb-4 text-center\",\n        // Rise above the controls while they show, instead of hiding under them.\n        \"transition-transform duration-200 ease-in-out-quart motion-reduce:transition-none\",\n        // …and higher still while the scrubber's time preview is up, so the two never overlap.\n        lift === \"preview\" ? \"-translate-y-[120px] @max-[420px]:-translate-y-[110px]\" : lift === \"controls\" ? \"-translate-y-[68px] @max-[420px]:-translate-y-[58px]\" : \"translate-y-0\",\n      )}\n    >\n      {text.map((line, i) => (\n        <span\n          key={`${i}-${line}`}\n          className=\"max-w-[min(90%,560px)] rounded-md bg-page/75 px-2 py-0.5 text-[15px] leading-[1.4] tracking-[-0.005em] text-fg [text-wrap:balance] @max-[420px]:text-[13px] in-data-fullscreen:text-[22px]\"\n        >\n          {line}\n        </span>\n      ))}\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ scrubber */\n\nfunction Scrubber({\n  video,\n  mediaRef,\n  duration,\n  live,\n  chapters,\n  onScrubbingChange,\n  onPreviewChange,\n}: {\n  video: HTMLVideoElement | null;\n  mediaRef: React.RefObject<HTMLVideoElement | null>;\n  duration: number;\n  live: boolean;\n  chapters?: VideoChapter[];\n  onScrubbingChange: (v: boolean) => void;\n  onPreviewChange: (v: boolean) => void;\n}) {\n  const time = useSmoothTime(video, live);\n  const bufferedKey = useMediaValue(video, BUFFER_EVENTS, readBuffered, \"\");\n  const [drag, setDrag] = useState<number | null>(null);\n  const [hover, setHover] = useState<number | null>(null);\n  const [width, setWidth] = useState(0);\n  const resume = useRef(false);\n  const control = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const el = control.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const buffered = useMemo(\n    () =>\n      bufferedKey\n        ? bufferedKey.split(\",\").map((r) => {\n            const [a, b] = r.split(\"-\").map(Number);\n            return [a, b] as const;\n          })\n        : [],\n    [bufferedKey],\n  );\n\n  const value = drag ?? time;\n  const pct = (t: number) => (duration ? (Math.min(duration, Math.max(0, t)) / duration) * 100 : 0);\n\n  // Chapter gaps, cut out of every layer at once with a mask.\n  const mask = useMemo(() => {\n    if (!chapters?.length || !duration) return undefined;\n    const cuts = chapters.map((c) => c.start).filter((s) => s > 0 && s < duration);\n    if (!cuts.length) return undefined;\n    const stops = cuts.map((s) => `var(--fg) calc(${pct(s)}% - 1.5px), transparent calc(${pct(s)}% - 1.5px) calc(${pct(s)}% + 1.5px), var(--fg) calc(${pct(s)}% + 1.5px)`);\n    return `linear-gradient(to right, var(--fg) 0, ${stops.join(\", \")})`;\n    // pct only depends on duration\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [chapters, duration]);\n\n  const chapterAt = (t: number) => {\n    if (!chapters?.length) return undefined;\n    let found: VideoChapter | undefined;\n    for (const c of chapters) if (c.start <= t) found = c;\n    return found?.title;\n  };\n\n  const shown = drag ?? hover;\n  const previewing = shown !== null;\n  useEffect(() => onPreviewChange(previewing), [previewing, onPreviewChange]);\n  const bubbleX = shown === null ? 0 : (pct(shown) / 100) * width;\n  const label = shown === null ? \"\" : formatTime(shown, duration);\n  const chapter = shown === null ? undefined : chapterAt(shown);\n\n  return (\n    <Slider.Root\n      value={value}\n      min={0}\n      max={duration || 1}\n      step={0.01}\n      disabled={!duration}\n      onValueChange={(v, details) => {\n        const video = mediaRef.current;\n        if (!video || !duration) return;\n        if (details.reason === \"keyboard\") {\n          // Native range steps are useless for time; seek in human-sized jumps instead.\n          details.cancel();\n          const key = (details.event as KeyboardEvent).key;\n          const jump = key === \"PageUp\" || key === \"PageDown\" ? 10 : 5;\n          const dir = key === \"ArrowRight\" || key === \"ArrowUp\" || key === \"PageUp\" ? 1 : key === \"Home\" ? -Infinity : key === \"End\" ? Infinity : -1;\n          video.currentTime = Math.min(duration, Math.max(0, video.currentTime + dir * jump));\n          return;\n        }\n        if (drag === null) {\n          resume.current = !video.paused;\n          if (resume.current) video.pause();\n          onScrubbingChange(true);\n        }\n        setDrag(v);\n        // Show the frame under the thumb while dragging. fastSeek lands on the nearest keyframe, which is what scrubbing wants.\n        if (\"fastSeek\" in video && typeof video.fastSeek === \"function\") video.fastSeek(v);\n        else video.currentTime = v;\n      }}\n      onValueCommitted={(v, details) => {\n        const video = mediaRef.current;\n        if (!video || details.reason === \"keyboard\") return;\n        video.currentTime = v;\n        setDrag(null);\n        onScrubbingChange(false);\n        if (resume.current) video.play().catch(noop);\n        resume.current = false;\n      }}\n      className=\"group/scrub relative\"\n    >\n      <Slider.Control\n        ref={control}\n        onPointerMove={(e) => {\n          if (e.pointerType !== \"mouse\" || !duration) return;\n          const r = e.currentTarget.getBoundingClientRect();\n          setHover(Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) * duration);\n        }}\n        onPointerLeave={() => setHover(null)}\n        className=\"relative flex h-5 w-full touch-none items-center data-disabled:opacity-50 pointer-coarse:h-7\"\n      >\n        <Slider.Track className=\"relative h-[3px] w-full\">\n          {/* The rail thickens under the pointer: a hint that it's grabbable. The thumb sits outside it, unscaled. */}\n          <div\n            className={cn(\n              \"absolute inset-0 overflow-hidden rounded-full\",\n              \"origin-center transition-[scale] duration-150 ease-out-quart group-hover/scrub:scale-y-[1.67] group-data-dragging/scrub:scale-y-[1.67] motion-reduce:transition-none\",\n            )}\n            style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined}\n          >\n            <div className=\"absolute inset-0 bg-fg/20\" />\n            {buffered.map(([a, b]) => (\n              <div key={`${a}-${b}`} className=\"absolute inset-y-0 bg-fg/25\" style={{ left: `${pct(a)}%`, width: `${pct(b) - pct(a)}%` }} />\n            ))}\n            {hover !== null && drag === null && <div className=\"absolute inset-y-0 left-0 bg-fg/30\" style={{ width: `${pct(hover)}%` }} />}\n            <Slider.Indicator className=\"bg-fg\" />\n          </div>\n          <Slider.Thumb\n            aria-label=\"Seek\"\n            getAriaValueText={(_, v) => `${speakTime(v)} of ${speakTime(duration)}`}\n            className={cn(\n              \"size-3 rounded-full bg-fg shadow-[0_0_0_1px_var(--page)]\",\n              \"scale-50 opacity-0 transition-[scale,opacity] duration-150 ease-out-quart motion-reduce:transition-none\",\n              \"group-hover/scrub:scale-100 group-hover/scrub:opacity-100 has-[:focus-visible]:scale-100 has-[:focus-visible]:opacity-100\",\n              \"data-dragging:scale-[1.15] data-dragging:opacity-100\",\n              \"has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg-2\",\n            )}\n          />\n        </Slider.Track>\n\n        {/* Hover / drag time. Clamped so it never leaves the rail. */}\n        <div\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute bottom-full left-0 mb-2 flex flex-col items-center gap-0.5 transition-opacity duration-100\",\n            shown === null ? \"opacity-0\" : \"opacity-100\",\n          )}\n          style={{ translate: `clamp(0px, ${bubbleX}px - 50%, ${width}px - 100%) 0` }}\n        >\n          {chapter && <span className=\"max-w-[200px] truncate rounded-md bg-page/80 px-1.5 py-0.5 text-[11.5px] font-medium text-fg\">{chapter}</span>}\n          <span className=\"tabular rounded-md bg-page/80 px-1.5 py-0.5 font-mono text-[11px] text-fg\">{label}</span>\n        </div>\n      </Slider.Control>\n    </Slider.Root>\n  );\n}\n\nfunction TimeReadout({ video, duration, remaining }: { video: HTMLVideoElement | null; duration: number; remaining: boolean }) {\n  const time = useMediaValue(video, TIME_EVENTS, (v) => Math.floor(v.currentTime), 0);\n  const total = duration ? formatTime(duration) : \"0:00\";\n  const now = remaining && duration ? formatTime(-(duration - time), duration) : formatTime(time, duration);\n  // Reserve the widest value so the row never shifts as digits change or the mode flips.\n  const widest = formatTime(-(duration || 0), duration).replace(/\\d/g, \"0\");\n  return (\n    <span className=\"tabular flex items-center gap-1 font-mono text-[11.5px] text-fg\">\n      <span className=\"grid text-right\">\n        <span aria-hidden className=\"invisible col-start-1 row-start-1\">{widest}</span>\n        <span className=\"col-start-1 row-start-1\">{now}</span>\n      </span>\n      <span className=\"text-fg-3\">/</span>\n      <span className=\"text-fg-2\">{total}</span>\n    </span>\n  );\n}\n\n/* ------------------------------------------------------------------ volume */\n\nfunction VolumeSlider({ value, onChange }: { value: number; onChange: (v: number) => void }) {\n  return (\n    <Slider.Root value={value} min={0} max={1} step={0.01} largeStep={0.1} onValueChange={(v) => onChange(v)} className=\"group/vol w-[76px] px-2\">\n      <Slider.Control className=\"flex h-8 w-full touch-none items-center\">\n        <Slider.Track className=\"relative h-[3px] w-full rounded-full bg-fg/20\">\n          <Slider.Indicator className=\"rounded-full bg-fg\" />\n          <Slider.Thumb\n            aria-label=\"Volume\"\n            getAriaValueText={(_, v) => (v === 0 ? \"Muted\" : `${Math.round(v * 100)}%`)}\n            className={cn(\n              \"size-2.5 rounded-full bg-fg transition-[scale] duration-150 ease-out-quart data-dragging:scale-125 motion-reduce:transition-none\",\n              \"has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg-2\",\n            )}\n          />\n        </Slider.Track>\n      </Slider.Control>\n    </Slider.Root>\n  );\n}\n\n/* ------------------------------------------------------------------ settings menu */\n\nconst formatRate = (r: number) => (r === 1 ? \"Normal\" : `${r}×`);\n\nfunction SettingsMenu({\n  container,\n  rate,\n  rates,\n  onRate,\n  tracks,\n  trackValue,\n  onTrack,\n  onOpenChange,\n}: {\n  container: HTMLElement | null;\n  rate: number;\n  rates: number[];\n  onRate: (r: number) => void;\n  tracks: VideoTrack[];\n  trackValue: string;\n  onTrack: (v: string) => void;\n  onOpenChange: (open: boolean) => void;\n}) {\n  const labels = rates.map((r) => `${r}×`);\n  const item = cn(\n    \"group/item flex h-8 cursor-default items-center gap-2 rounded-lg pl-2 pr-3 text-[13px] text-fg outline-none select-none\",\n    \"data-highlighted:bg-fg/[0.07] data-disabled:opacity-50\",\n  );\n  return (\n    <Menu.Root onOpenChange={(open) => onOpenChange(open)} modal={false}>\n      <Menu.Trigger\n        aria-label={`Settings, speed ${formatRate(rate)}`}\n        className={cn(\n          \"grid h-8 min-w-9 place-items-center rounded-lg px-1.5 font-mono text-[11.5px] tabular text-fg outline-none\",\n          \"transition-[background-color,scale] duration-150 ease-out-quart hover:bg-fg/10 active:scale-[0.94] active:duration-75 data-popup-open:bg-fg/10\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        )}\n      >\n        {/* All rate labels share one cell, so the button never changes width. */}\n        <span className=\"grid\">\n          {labels.map((l) => (\n            <span key={l} aria-hidden className=\"invisible col-start-1 row-start-1\">{l}</span>\n          ))}\n          <span className=\"col-start-1 row-start-1 text-center\">{`${rate}×`}</span>\n        </span>\n      </Menu.Trigger>\n      <Menu.Portal container={container}>\n        <Menu.Positioner side=\"top\" align=\"end\" sideOffset={8} collisionPadding={8} className=\"z-(--z-popover) outline-none\">\n          <Menu.Popup\n            className={cn(\n              \"max-h-(--available-height) min-w-[184px] overflow-y-auto overscroll-contain rounded-xl border border-line-2 bg-raised/95 p-1 shadow-pop outline-none backdrop-blur-md\",\n              \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-180 ease-out-expo\",\n              \"data-starting-style:translate-y-1 data-starting-style:scale-96 data-starting-style:opacity-0\",\n              \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120\",\n              \"data-instant:duration-0\",\n            )}\n          >\n            <Menu.RadioGroup value={String(rate)} onValueChange={(v: string) => onRate(Number(v))}>\n              <Menu.GroupLabel className=\"px-2 pb-1 pt-1.5 font-mono text-[10.5px] uppercase tracking-[0.08em] text-fg-3\">Speed</Menu.GroupLabel>\n              {rates.map((r) => (\n                <Menu.RadioItem key={r} value={String(r)} closeOnClick className={item}>\n                  <span className=\"grid size-4 place-items-center\">\n                    <Menu.RadioItemIndicator keepMounted className=\"grid place-items-center\">\n                      <Tick />\n                    </Menu.RadioItemIndicator>\n                  </span>\n                  <span className=\"flex-1\">{formatRate(r)}</span>\n                </Menu.RadioItem>\n              ))}\n            </Menu.RadioGroup>\n            {tracks.length > 1 && (\n              <>\n                <Menu.Separator className=\"mx-2 my-1 h-px bg-line\" />\n                <Menu.RadioGroup value={trackValue} onValueChange={(v: string) => onTrack(v)}>\n                  <Menu.GroupLabel className=\"px-2 pb-1 pt-1.5 font-mono text-[10.5px] uppercase tracking-[0.08em] text-fg-3\">Subtitles</Menu.GroupLabel>\n                  {[{ value: \"off\", label: \"Off\" }, ...tracks.map((t, i) => ({ value: String(i), label: t.label }))].map((o) => (\n                    <Menu.RadioItem key={o.value} value={o.value} closeOnClick className={item}>\n                      <span className=\"grid size-4 place-items-center\">\n                        <Menu.RadioItemIndicator keepMounted className=\"grid place-items-center\">\n                          <Tick />\n                        </Menu.RadioItemIndicator>\n                      </span>\n                      <span className=\"flex-1 truncate\">{o.label}</span>\n                    </Menu.RadioItem>\n                  ))}\n                </Menu.RadioGroup>\n              </>\n            )}\n          </Menu.Popup>\n        </Menu.Positioner>\n      </Menu.Portal>\n    </Menu.Root>\n  );\n}\n\n/* ------------------------------------------------------------------ control button with tooltip */\n\nfunction ControlButton({\n  label,\n  shortcut,\n  container,\n  pressed,\n  onClick,\n  className,\n  children,\n}: {\n  label: string;\n  shortcut?: string;\n  container: HTMLElement | null;\n  pressed?: boolean;\n  onClick: () => void;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger\n        aria-label={label}\n        aria-pressed={pressed}\n        aria-keyshortcuts={shortcut}\n        onClick={onClick}\n        className={cn(\n          \"group/btn relative grid size-8 shrink-0 place-items-center rounded-lg text-fg outline-none\",\n          \"transition-[background-color,scale] duration-150 ease-out-quart hover:bg-fg/10 active:scale-[0.9] active:duration-75\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n          // 44px on touch without changing the drawing.\n          \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n          className,\n        )}\n      >\n        {children}\n      </Tooltip.Trigger>\n      <Tooltip.Portal container={container}>\n        <Tooltip.Positioner side=\"top\" sideOffset={10} collisionPadding={8} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"flex items-center gap-2 rounded-lg border border-line-2 bg-raised px-2 py-1 text-[12px] text-fg shadow-pop\",\n              \"origin-(--transform-origin) transition-[opacity,scale] duration-150 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:duration-0\",\n            )}\n          >\n            {label}\n            {shortcut && <kbd className=\"grid h-4 min-w-4 place-items-center rounded-[4px] border border-line-2 px-1 font-mono text-[10px] leading-none text-fg-3\">{shortcut}</kbd>}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n\n/* ------------------------------------------------------------------ glyphs */\n\n// Play is two quads that morph into the two pause bars: same point count, so the path tweens.\nconst PLAY_L = \"M4.6 2.9L8.6 5.3L8.6 10.7L4.6 13.1Z\";\nconst PLAY_R = \"M8.6 5.3L12.9 8L12.9 8L8.6 10.7Z\";\nconst PAUSE_L = \"M4 3L7 3L7 13L4 13Z\";\nconst PAUSE_R = \"M9 3L12 3L12 13L9 13Z\";\n\nfunction PlayGlyph({ paused, size = 16, morph = false }: { paused: boolean; size?: number; morph?: boolean }) {\n  const t = { duration: 0.2, ease: ease.inOut };\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 16 16\" fill=\"currentColor\" stroke=\"currentColor\" strokeWidth={1.2} strokeLinejoin=\"round\" aria-hidden>\n      <motion.path initial={false} animate={{ d: paused ? PLAY_L : PAUSE_L }} transition={morph ? t : { duration: 0 }} />\n      <motion.path initial={false} animate={{ d: paused ? PLAY_R : PAUSE_R }} transition={morph ? t : { duration: 0 }} />\n    </svg>\n  );\n}\n\nfunction ReplayGlyph({ size = 16 }: { size?: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M3 8a5 5 0 1 0 1.5-3.55M3 2.75V5h2.25\" />\n    </svg>\n  );\n}\n\nfunction SeekGlyph({ dir }: { dir: 1 | -1 }) {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden style={{ transform: dir < 0 ? \"scaleX(-1)\" : undefined }}>\n      <path d=\"M2.5 4v8l5-4zM8.5 4v8l5-4z\" stroke=\"currentColor\" strokeWidth={1} strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nfunction SpeedGlyph() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" aria-hidden>\n      <path d=\"M2.75 11a5.25 5.25 0 1 1 10.5 0\" />\n      <path d=\"M8 11 10.5 7\" />\n    </svg>\n  );\n}\n\n// Waves retract toward the speaker as the level drops; a cross draws in when muted.\nfunction VolumeGlyph({ level, muted }: { level: number; muted: boolean }) {\n  const w1 = !muted && level > 0;\n  const w2 = !muted && level > 0.5;\n  const wave = \"origin-[7.5px_8px] transition-[opacity,scale] duration-200 ease-out-quart motion-reduce:transition-none\";\n  const cross = \"transition-[stroke-dashoffset] duration-200 ease-out-quart [stroke-dasharray:1] motion-reduce:transition-none\";\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M7.5 3.25 4.6 5.6H2.6v4.8h2l2.9 2.35z\" />\n      <path d=\"M10 6.2a2.6 2.6 0 0 1 0 3.6\" className={cn(wave, w1 ? \"scale-100 opacity-100\" : \"scale-50 opacity-0\")} />\n      <path d=\"M12 4.4a5.2 5.2 0 0 1 0 7.2\" className={cn(wave, w2 ? \"scale-100 opacity-100\" : \"scale-50 opacity-0\")} />\n      <path d=\"M10.25 6.4l3.2 3.2\" pathLength={1} className={cn(cross, muted ? \"[stroke-dashoffset:0]\" : \"[stroke-dashoffset:1]\")} />\n      <path d=\"M13.45 6.4l-3.2 3.2\" pathLength={1} className={cn(cross, \"delay-75\", muted ? \"[stroke-dashoffset:0]\" : \"[stroke-dashoffset:1]\")} />\n    </svg>\n  );\n}\n\nfunction CaptionsGlyph({ on }: { on?: boolean }) {\n  return (\n    <span className=\"relative grid place-items-center\">\n      <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n        <rect x=\"2\" y=\"3.5\" width=\"12\" height=\"9\" rx=\"2\" />\n        <path d=\"M7 6.9a1.5 1.5 0 1 0 0 2.2M11.25 6.9a1.5 1.5 0 1 0 0 2.2\" />\n      </svg>\n      {on !== undefined && (\n        // The \"on\" bar grows from the center under the icon.\n        <span\n          aria-hidden\n          className={cn(\n            \"absolute -bottom-[5px] h-[2px] w-3.5 rounded-full bg-fg transition-[scale,opacity] duration-200 ease-out-quart motion-reduce:transition-none\",\n            on ? \"scale-x-100 opacity-100\" : \"scale-x-0 opacity-0\",\n          )}\n        />\n      )}\n    </span>\n  );\n}\n\nfunction PipGlyph() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M13.5 7.5V4.25c0-.4-.35-.75-.75-.75H3.25c-.4 0-.75.35-.75.75v7.5c0 .4.35.75.75.75H6\" />\n      <rect x=\"8.5\" y=\"9.5\" width=\"5\" height=\"3.5\" rx=\"0.75\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\n// Corners travel outward to enter full screen and inward to leave; on hover they lean 0.75px in their direction.\nfunction FullscreenGlyph({ on }: { on: boolean }) {\n  const corners = on\n    ? [\"M6 2.75V6H2.75\", \"M10 2.75V6h3.25\", \"M6 13.25V10H2.75\", \"M10 13.25V10h3.25\"]\n    : [\"M2.75 6V2.75H6\", \"M10 2.75h3.25V6\", \"M2.75 10v3.25H6\", \"M13.25 10v3.25H10\"];\n  const nudge = on\n    ? [\"group-hover/btn:translate-x-[0.75px] group-hover/btn:translate-y-[0.75px]\", \"group-hover/btn:-translate-x-[0.75px] group-hover/btn:translate-y-[0.75px]\", \"group-hover/btn:translate-x-[0.75px] group-hover/btn:-translate-y-[0.75px]\", \"group-hover/btn:-translate-x-[0.75px] group-hover/btn:-translate-y-[0.75px]\"]\n    : [\"group-hover/btn:-translate-x-[0.75px] group-hover/btn:-translate-y-[0.75px]\", \"group-hover/btn:translate-x-[0.75px] group-hover/btn:-translate-y-[0.75px]\", \"group-hover/btn:-translate-x-[0.75px] group-hover/btn:translate-y-[0.75px]\", \"group-hover/btn:translate-x-[0.75px] group-hover/btn:translate-y-[0.75px]\"];\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"overflow-visible\">\n      {corners.map((d, i) => (\n        <path key={`${on}-${i}`} d={d} className={cn(\"transition-transform duration-200 ease-out-quart motion-reduce:transition-none\", nudge[i])} />\n      ))}\n    </svg>\n  );\n}\n\nfunction Tick() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        pathLength={1}\n        className=\"[stroke-dasharray:1] [stroke-dashoffset:1] transition-[stroke-dashoffset] duration-300 ease-out-expo in-data-checked:[stroke-dashoffset:0] motion-reduce:transition-none\"\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/video-player"
}
