Skip to contentVibraUI
Media

Video player

A native video with the controls people know — play, a seek bar with what has loaded, volume, speed, captions, picture-in-picture and full screen — the keys that drive them, and its loading, can't-play and ended states.

A <video> the browser plays, wearing the kit's controls instead of its own: play and pause, a seek bar with the buffered ranges behind what has played and the time under the pointer above it, mute and volume, the time, captions, a speed menu (0.5× to 2×), picture-in-picture and full screen. The player is a region named "Video: {title}" and takes the keys people know whenever the focus is in it: Space or K plays and pauses, J and L go back and on ten seconds, the arrows five, M mutes, C turns the captions on and off, F goes full screen, and 0–9 jump to tenths of the video. A key typed into a field is the field's, and a focused button keeps its own Space. The arrows run in reading order — in a right-to-left page ArrowLeft goes forward, and the seek bar fills from the right. The times — the readout and the one under the pointer — are runs of their own, left to right, so a right-to-left page keeps "0:05 / 0:39" in order. It is drawn on the dark planes in either theme, as video is: the controls sit over the picture on a scrim, and the letterbox round it is black. The controls step back 2.5 seconds after the pointer stops while the video plays, never while it is paused, finished or loading, or while the pointer or the focus is in them; under reduced motion they go without the fade. Picture-in-picture and full screen appear only in a browser that offers them, and full screen takes the whole player, so the speed menu is drawn inside it rather than in a portal the full-screen view would hide; in a short player it takes the room above its button and scrolls the rest. C and the Captions button show the track marked default, or the first, and hide whichever is showing; while the controls are up the captions rise to sit just above them, and settle back when the controls step back. A file that does not say its own length — one recorded in a browser often does not, until it has been read to the end — shows only the time played until the browser learns it. It says "Loading video" until the browser can play; a file it cannot play stops the spinner and says "This video can't be played" with the reason — not there, in a format this browser cannot play (told apart by the types src declares; with none declared it says the file isn't there or can't be played here), or cut off by the network — as an alert, with a Try again button that loads it once more. When the focus was in the controls it moves to Try again, and back to Play once the video loads again, rather than falling to the page. At the end Play becomes Replay and onEnded fires. List the same video in more than one format with src as an array, best first; a new src is loaded as it arrives. The shadcn CLI installs text only, so the video, its poster and captions are not installed with the player: put them in public/ and point src at them, and an install without them shows the can't-play state rather than an empty frame.

Install

npx shadcn@latest add @vibra/video-player

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
srcstring | { src: string; type: string }[]—One file, or the same video in several formats for the browser to choose from, best first.
titlestring—What the video is; the player is the region "Video: {title}".
posterstring—The picture shown until it plays.
captions{ src: string; srclang: string; label: string; default?: boolean }[]—WebVTT tracks; one marked default starts showing. Without any there is no Captions button.
preload"none" | "metadata" | "auto""metadata"How much the browser fetches before play; with none it says nothing is loading until the reader asks.
aspectRatiostring"16 / 9"The frame's CSS aspect-ratio; the video is fitted inside it.
onEnded() => void—Fires when playback reaches the end.

Dependencies

Source

components/ui/video-player/index.tsx
"use client"

import * as React from "react"
import { PlayIcon, RotateCcwIcon, VideoOffIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { useMediaElement, type MediaState } from "@/hooks/use-media-element"
import { isRightToLeft } from "@/lib/media-seek"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"

import { useCaptionsClearOf } from "./captions"
import { Controls } from "./controls"

export type VideoPlayerProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** One file, or the same video in several formats for the browser to choose from, best first. */
  src: string | { src: string; type: string }[]
  poster?: string
  /** What the video is; the player is the region "Video: {title}". */
  title: string
  captions?: { src: string; srclang: string; label: string; default?: boolean }[]
  preload?: "none" | "metadata" | "auto"
  /** CSS aspect-ratio of the frame. */
  aspectRatio?: string
  onEnded?: () => void
}

/** How long the controls stay after the pointer stops, while the video plays. */
const HIDE_AFTER = 2500

const REASONS: Record<NonNullable<MediaState["error"]>, string> = {
  missing: "The video file isn't there.",
  unsupported: "This browser can't play its format.",
  unplayable: "The video file isn't there, or its format can't be played here.",
  network: "The connection dropped while it loaded.",
  aborted: "Loading it was stopped.",
}

const never = () => () => {}

/** A key typed into a field is the field's, never the player's. */
function typing(target: HTMLElement): boolean {
  if (target.isContentEditable || target.closest("textarea, select")) return true
  return target instanceof HTMLInputElement && !["range", "checkbox", "radio", "button"].includes(target.type)
}

/**
 * A video with the controls people know: play and pause, a seek bar with what
 * has loaded and the time under the pointer, volume, speed, captions,
 * picture-in-picture and full screen, and the keys — Space or K, J and L, the
 * arrows, M, C, F and 0–9. It says when it is loading, when it cannot play
 * and why, and offers to play again at the end.
 */
function VideoPlayer({ src, poster, title, captions, preload = "metadata", aspectRatio = "16 / 9", onEnded, className, style, onKeyDown, onPointerMove, ...props }: VideoPlayerProps) {
  const rootRef = React.useRef<HTMLDivElement>(null)
  const videoRef = React.useRef<HTMLVideoElement>(null)
  const controlsRef = React.useRef<HTMLDivElement>(null)
  const trackRefs = React.useRef<(HTMLTrackElement | null)[]>([])
  const media = useMediaElement(videoRef)
  const sources = typeof src === "string" ? [{ src, type: undefined }] : src
  const unavailable = media.status === "error"

  // What the browser offers is read on the client only, so the markup the
  // server sends is the markup that hydrates.
  const canPictureInPicture = React.useSyncExternalStore(
    never,
    () => document.pictureInPictureEnabled === true && "requestPictureInPicture" in HTMLVideoElement.prototype,
    () => false
  )
  const canFullscreen = React.useSyncExternalStore(
    never,
    () => typeof Element.prototype.requestFullscreen === "function" && document.fullscreenEnabled !== false,
    () => false
  )
  const [fullscreen, setFullscreen] = React.useState(false)
  const [pictureInPicture, setPictureInPicture] = React.useState(false)
  React.useEffect(() => {
    const video = videoRef.current
    const onChange = () => setFullscreen(document.fullscreenElement === rootRef.current)
    // React has no props for these two; the element says them natively.
    const enter = () => setPictureInPicture(true)
    const leave = () => setPictureInPicture(false)
    document.addEventListener("fullscreenchange", onChange)
    video?.addEventListener("enterpictureinpicture", enter)
    video?.addEventListener("leavepictureinpicture", leave)
    return () => {
      document.removeEventListener("fullscreenchange", onChange)
      video?.removeEventListener("enterpictureinpicture", enter)
      video?.removeEventListener("leavepictureinpicture", leave)
    }
  }, [])

  // A source list does not reload when its <source> elements change, so a
  // new src is loaded by hand — never on the first render.
  const sourceKey = sources.map((source) => source.src).join("|")
  const loadedKey = React.useRef(sourceKey)
  React.useEffect(() => {
    if (loadedKey.current === sourceKey) return
    loadedKey.current = sourceKey
    videoRef.current?.load()
  }, [sourceKey])

  // Captions: on when a track is showing. The tracks say so themselves when
  // their mode changes, whoever changed it.
  const [captionsOn, setCaptionsOn] = React.useState(() => Boolean(captions?.some((track) => track.default)))
  const tracks = () => trackRefs.current.map((element) => element?.track).filter((track): track is TextTrack => Boolean(track))
  React.useEffect(() => {
    const list = videoRef.current?.textTracks
    if (!list || typeof list.addEventListener !== "function") return
    const onChange = () => setCaptionsOn(Array.from(list).some((track) => track.mode === "showing"))
    list.addEventListener("change", onChange)
    return () => list.removeEventListener("change", onChange)
  }, [])
  const toggleCaptions = () => {
    const all = tracks()
    if (all.length === 0) return
    const showing = all.filter((track) => track.mode === "showing")
    if (showing.length > 0) for (const track of showing) track.mode = "hidden"
    else (trackRefs.current[Math.max(0, captions?.findIndex((track) => track.default) ?? 0)]?.track ?? all[0]).mode = "showing"
    setCaptionsOn(showing.length === 0)
  }

  const toggleFullscreen = () => {
    if (document.fullscreenElement) void document.exitFullscreen?.().catch(() => {})
    else void rootRef.current?.requestFullscreen?.().catch(() => {})
  }
  const togglePictureInPicture = () => {
    if (document.pictureInPictureElement) void document.exitPictureInPicture?.().catch(() => {})
    else void videoRef.current?.requestPictureInPicture?.().catch(() => {})
  }

  // The controls step back 2.5 s after the pointer stops while the video
  // plays — never while it is paused, finished or loading, while the pointer
  // is over them, or while the focus is in them.
  const [awake, setAwake] = React.useState(true)
  const [overControls, setOverControls] = React.useState(false)
  const [focusInControls, setFocusInControls] = React.useState(false)
  const sleepTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
  const wake = () => {
    setAwake(true)
    clearTimeout(sleepTimer.current)
    sleepTimer.current = setTimeout(() => setAwake(false), HIDE_AFTER)
  }
  React.useEffect(() => () => clearTimeout(sleepTimer.current), [])
  const shown = awake || overControls || focusInControls || media.paused || media.ended || media.status !== "ready"
  useCaptionsClearOf(videoRef, trackRefs, controlsRef, shown)

  // The strip and the failure notice take each other's place, so whichever
  // holds the focus is about to go. The focus moves to the one that comes —
  // Try again, then Play once the video is back — rather than to the page.
  const tryAgainRef = React.useRef<HTMLButtonElement>(null)
  const wasUnavailable = React.useRef(unavailable)
  React.useLayoutEffect(() => {
    if (wasUnavailable.current === unavailable) return
    wasUnavailable.current = unavailable
    if (!focusInControls) return
    if (unavailable) tryAgainRef.current?.focus()
    else controlsRef.current?.querySelector<HTMLElement>("[aria-keyshortcuts=K]")?.focus()
  }, [unavailable, focusInControls])
  const trackFocus = {
    onFocus: () => setFocusInControls(true),
    onBlur: (event: React.FocusEvent<HTMLDivElement>) => setFocusInControls(event.currentTarget.contains(event.relatedTarget as Node | null)),
  }

  const now = () => videoRef.current?.currentTime ?? media.currentTime

  const onRootKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event)
    const target = event.target as HTMLElement
    if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || typing(target) || unavailable) return
    wake()
    const key = event.key.length === 1 ? event.key.toLowerCase() : event.key
    const onControl = target.closest("button, a, [role=menuitemradio]") !== null
    const onSlider = target.closest("[role=slider]") !== null || (target instanceof HTMLInputElement && target.type === "range")
    if (key === " ") {
      if (onControl) return
      media.toggle()
    } else if (key === "k") media.toggle()
    else if (key === "j") media.seek(now() - 10)
    else if (key === "l") media.seek(now() + 10)
    else if (key === "ArrowLeft" || key === "ArrowRight") {
      if (onSlider) return
      // In reading order: in a right-to-left page ArrowLeft goes forward.
      const forward = (key === "ArrowRight") !== isRightToLeft(event.currentTarget)
      media.seek(now() + (forward ? 5 : -5))
    } else if (key === "m") media.setMuted(!media.muted)
    else if (key === "c" && captions?.length) toggleCaptions()
    else if (key === "f" && canFullscreen) toggleFullscreen()
    else if (/^[0-9]$/.test(key)) media.seek((media.duration * Number(key)) / 10)
    else return
    event.preventDefault()
  }

  return (
    <div
      ref={rootRef}
      role="region"
      aria-label={`Video: ${title}`}
      tabIndex={0}
      data-slot="video-player"
      data-status={media.status}
      data-controls={shown ? "shown" : "hidden"}
      data-fullscreen={fullscreen || undefined}
      onKeyDown={onRootKeyDown}
      onPointerMove={(event) => {
        onPointerMove?.(event)
        wake()
      }}
      // The hairline keeps its edge on a dark page, whose ground is the same.
      // The focus outline is drawn here, in the page's own accent: the dark
      // planes start one layer in. A size container, so the controls fit the
      // player's own width rather than the window's.
      className={cn(
        "group/player @container/player relative isolate w-full overflow-hidden rounded-xl ring-1 ring-border focus-ring data-fullscreen:rounded-none data-fullscreen:ring-0 data-[controls=hidden]:cursor-none",
        className
      )}
      style={{ aspectRatio, ...style }}
      {...props}
    >
      {/* Always on the dark planes, as video is: the controls sit over the
          picture, and the letterbox round it is black in either theme. */}
      <div className="dark absolute inset-0 bg-background text-foreground">
        <video
          ref={videoRef}
          poster={poster}
          preload={preload}
          playsInline
          onClick={media.toggle}
          onDoubleClick={canFullscreen ? toggleFullscreen : undefined}
          onPlay={wake}
          onEnded={onEnded}
          // Where the browser draws captions in a container (Blink, WebKit),
          // that container rises 4rem — the 3.5rem strip and a little air —
          // while the controls are up; elsewhere captions.ts moves the cues.
          className="absolute inset-0 size-full object-contain [&::-webkit-media-text-track-container]:transition-[translate] [&::-webkit-media-text-track-container]:duration-(--duration-base) [&::-webkit-media-text-track-container]:ease-(--ease-standard) group-data-[controls=shown]/player:[&::-webkit-media-text-track-container]:-translate-y-16"
        >
          {sources.map((source) => (
            <source key={source.src} src={source.src} type={source.type} />
          ))}
          {captions?.map((track, index) => (
            <track
              key={track.src}
              ref={(element) => {
                trackRefs.current[index] = element
              }}
              kind="captions"
              src={track.src}
              srcLang={track.srclang}
              label={track.label}
              default={track.default}
            />
          ))}
        </video>

        {media.status === "loading" ? (
          <div className="pointer-events-none absolute inset-0 grid place-items-center">
            <Spinner aria-label="Loading video" className="size-8" />
          </div>
        ) : media.status === "ready" && media.paused ? (
          <div aria-hidden="true" className="pointer-events-none absolute inset-0 grid place-items-center">
            <span className="grid size-14 place-items-center rounded-full bg-background/80 [&_svg]:size-6">{media.ended ? <RotateCcwIcon /> : <PlayIcon className="ms-0.5" />}</span>
          </div>
        ) : null}

        {unavailable ? (
          // On the strip's ground: a poster that loaded stays behind a file
          // that did not, and the words must read over any picture.
          <div
            data-slot="video-player-error"
            {...trackFocus}
            className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background/85 p-6 text-center"
          >
            <VideoOffIcon aria-hidden="true" className="size-6 text-muted-foreground" />
            {/* Said as it happens, wherever the reader is. */}
            <div role="alert" className="flex flex-col items-center gap-2">
              <p className="type-label">This video can&apos;t be played</p>
              <p className="text-xs text-muted-foreground">{REASONS[media.error ?? "unsupported"]}</p>
            </div>
            <Button ref={tryAgainRef} variant="outline" size="sm" className="mt-1" onClick={() => videoRef.current?.load()}>
              Try again
            </Button>
          </div>
        ) : (
          // A flat strip of the dark ground, opaque enough for its words to
          // read over any picture.
          <div
            ref={controlsRef}
            data-slot="video-player-controls"
            onPointerEnter={() => setOverControls(true)}
            onPointerLeave={() => setOverControls(false)}
            {...trackFocus}
            className="absolute inset-x-0 bottom-0 flex flex-col gap-0.5 bg-background/85 px-3 pt-1 pb-1.5 transition-opacity duration-(--duration-base) ease-(--ease-standard) group-data-[controls=hidden]/player:pointer-events-none group-data-[controls=hidden]/player:opacity-0"
          >
            <Controls
              media={media}
              captions={{ available: Boolean(captions?.length), active: captionsOn, toggle: toggleCaptions }}
              pictureInPicture={{ available: canPictureInPicture, active: pictureInPicture, toggle: togglePictureInPicture }}
              fullscreen={{ available: canFullscreen, active: fullscreen, toggle: toggleFullscreen }}
            />
          </div>
        )}
      </div>
    </div>
  )
}

export { VideoPlayer }
components/ui/video-player/controls.tsx
"use client"

import * as React from "react"
import {
  CaptionsIcon,
  CaptionsOffIcon,
  CheckIcon,
  MaximizeIcon,
  MinimizeIcon,
  PauseIcon,
  PictureInPicture2Icon,
  PlayIcon,
  RotateCcwIcon,
  Volume1Icon,
  Volume2Icon,
  VolumeXIcon,
} from "lucide-react"

import { type MediaControls, type MediaState } from "@/hooks/use-media-element"
import { formatMediaTime, SPEEDS } from "@/lib/media-seek"
import { Button } from "@/components/ui/button"
import { Slider } from "@/components/ui/slider"

import { Scrubber } from "./scrubber"

/** A feature the player may or may not have here: whether it does, whether it is on, and its switch. */
type Feature = { available: boolean; active: boolean; toggle: () => void }

export type ControlsProps = {
  media: MediaState & MediaControls
  captions: Feature
  pictureInPicture: Feature
  fullscreen: Feature
}

const speed = (rate: number) => `${rate}×`

/**
 * The speeds, as a menu drawn inside the player rather than in a portal at
 * the end of the page: a full-screen player shows only what is inside it, so
 * a portalled menu would open where nobody could see it.
 */
function SpeedMenu({ rate, onRate }: { rate: number; onRate: (rate: number) => void }) {
  const [open, setOpen] = React.useState(false)
  const menuId = React.useId()
  const wrapperRef = React.useRef<HTMLDivElement>(null)
  const buttonRef = React.useRef<HTMLButtonElement>(null)
  const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([])

  // As the menu opens — once, on mounting: the player re-renders four times a
  // second while it plays, and the arrows must win — it fits the room above
  // its button, since the player clips whatever reaches past its top, and
  // scrolls the rest; then the chosen speed takes the focus, the way a menu of
  // radio items starts on its checked one, and is scrolled into sight.
  const onMenu = React.useCallback((menu: HTMLDivElement | null) => {
    if (!menu) return
    const player = menu.closest("[data-slot=video-player]")
    const button = buttonRef.current
    if (player && button) {
      // Less the 8px gap under the menu and 4 to spare, and never under two speeds.
      const room = button.getBoundingClientRect().top - player.getBoundingClientRect().top - 12
      menu.style.maxHeight = `${Math.max(56, Math.floor(room))}px`
    }
    menu.querySelector<HTMLElement>('[aria-checked="true"]')?.focus()
  }, [])

  // A press anywhere else closes it.
  React.useEffect(() => {
    if (!open) return
    const onPointerDown = (event: PointerEvent) => {
      if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false)
    }
    document.addEventListener("pointerdown", onPointerDown)
    return () => document.removeEventListener("pointerdown", onPointerDown)
  }, [open])

  const close = () => {
    setOpen(false)
    buttonRef.current?.focus()
  }

  const onMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null)
    const at = items.indexOf(document.activeElement as HTMLButtonElement)
    const to: Record<string, number> = { ArrowDown: (at + 1) % items.length, ArrowUp: (at - 1 + items.length) % items.length, Home: 0, End: items.length - 1 }
    if (event.key in to) items[to[event.key]]?.focus()
    else if (event.key === "Escape") close()
    else if (event.key === "Tab") setOpen(false)
    else return
    if (event.key !== "Tab") event.preventDefault()
    event.stopPropagation()
  }

  return (
    <div ref={wrapperRef} className="relative">
      <Button
        ref={buttonRef}
        variant="ghost"
        size="sm"
        aria-label={`Speed: ${speed(rate)}`}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? menuId : undefined}
        onClick={() => setOpen((current) => !current)}
        className="min-w-10 tabular-nums"
      >
        {speed(rate)}
      </Button>
      {open ? (
        <div
          ref={onMenu}
          id={menuId}
          role="menu"
          aria-label="Speed"
          onKeyDown={onMenuKeyDown}
          className="absolute end-0 bottom-full z-10 mb-2 flex min-w-24 flex-col overflow-y-auto overscroll-contain elev-1 p-1"
        >
          {SPEEDS.map((option, index) => (
            <button
              key={option}
              ref={(node) => {
                itemRefs.current[index] = node
              }}
              type="button"
              role="menuitemradio"
              aria-checked={option === rate}
              tabIndex={-1}
              onClick={() => {
                onRate(option)
                close()
              }}
              className="flex items-center justify-between gap-3 rounded-md py-1 ps-2 pe-1.5 text-label text-popover-foreground tabular-nums outline-hidden select-none hover:bg-accent focus:bg-accent"
            >
              {speed(option)}
              {option === rate ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
            </button>
          ))}
        </div>
      ) : null}
    </div>
  )
}

/** The seek bar over the row of controls. */
function Controls({ media, captions, pictureInPicture, fullscreen }: ControlsProps) {
  const quiet = media.muted || media.volume === 0
  const VolumeIcon = quiet ? VolumeXIcon : media.volume < 0.5 ? Volume1Icon : Volume2Icon
  const play = media.ended ? "Replay" : media.paused ? "Play" : "Pause"

  return (
    <>
      <Scrubber currentTime={media.currentTime} duration={media.duration} buffered={media.buffered} onSeek={media.seek} />
      <div className="flex items-center gap-1">
        <Button
          variant="ghost"
          size="icon-sm"
          aria-label={play}
          aria-keyshortcuts="K"
          onClick={() => {
            if (media.ended) media.seek(0)
            media.toggle()
          }}
        >
          {play === "Replay" ? <RotateCcwIcon aria-hidden="true" /> : play === "Play" ? <PlayIcon aria-hidden="true" /> : <PauseIcon aria-hidden="true" />}
        </Button>
        <Button variant="ghost" size="icon-sm" aria-label={media.muted ? "Unmute" : "Mute"} aria-keyshortcuts="M" onClick={() => media.setMuted(!media.muted)}>
          <VolumeIcon aria-hidden="true" />
        </Button>
        {/* Only where the player has room: a narrow one keeps Mute alone. */}
        <div className="hidden w-20 shrink-0 @md/player:flex">
          <Slider
            aria-label="Volume"
            min={0}
            max={100}
            value={quiet ? 0 : Math.round(media.volume * 100)}
            onValueChange={(next) => {
              const value = typeof next === "number" ? next : next[0]
              media.setVolume(value / 100)
              if (value > 0 && media.muted) media.setMuted(false)
            }}
            getAriaValueText={(_, value) => `${value}%`}
          />
        </div>
        <p className="ms-3 text-xs whitespace-nowrap tabular-nums">
          {/* Until the file says how long it is, only the time played. A run
              of its own, left to right — on the run, not on the line, whose
              start is the page's — so a right-to-left page keeps "0:05 / 0:39"
              in order. */}
          <bdi dir="ltr">
            {media.duration > 0 ? `${formatMediaTime(media.currentTime)} / ${formatMediaTime(media.duration)}` : formatMediaTime(media.currentTime)}
          </bdi>
        </p>
        <div className="ms-auto flex items-center gap-1">
          {captions.available ? (
            <Button variant="ghost" size="icon-sm" aria-label="Captions" aria-pressed={captions.active} aria-keyshortcuts="C" onClick={captions.toggle}>
              {captions.active ? <CaptionsIcon aria-hidden="true" /> : <CaptionsOffIcon aria-hidden="true" />}
            </Button>
          ) : null}
          <SpeedMenu rate={media.playbackRate} onRate={media.setPlaybackRate} />
          {pictureInPicture.available ? (
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label={pictureInPicture.active ? "Exit picture in picture" : "Picture in picture"}
              onClick={pictureInPicture.toggle}
              // The first to step aside in a narrow player: full screen matters more.
              className="hidden @xs/player:inline-flex"
            >
              <PictureInPicture2Icon aria-hidden="true" />
            </Button>
          ) : null}
          {fullscreen.available ? (
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label={fullscreen.active ? "Exit full screen" : "Full screen"}
              aria-keyshortcuts="F"
              onClick={fullscreen.toggle}
            >
              {fullscreen.active ? <MinimizeIcon aria-hidden="true" /> : <MaximizeIcon aria-hidden="true" />}
            </Button>
          ) : null}
        </div>
      </div>
    </>
  )
}

export { Controls, SpeedMenu }
components/ui/video-player/scrubber.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { formatMediaTime, fractionAt, seekKeyTarget, seekValue, useSeekPointer } from "@/lib/media-seek"

export type ScrubberProps = {
  currentTime: number
  duration: number
  /** Fractions 0–1 of the duration the browser holds. */
  buffered: { start: number; end: number }[]
  onSeek: (seconds: number) => void
  disabled?: boolean
  label?: string
  className?: string
}

const percent = (fraction: number) => `${Math.min(1, Math.max(0, fraction)) * 100}%`

/**
 * The seek bar: a slider over the whole video, the buffered ranges drawn
 * behind what has played, and the time under the pointer named above it. Its
 * keys and its pointer are the kit's shared seek bar's (media-seek), so it
 * runs in reading order — a right-to-left page plays from right to left, and
 * ArrowLeft goes forward there.
 */
function Scrubber({ currentTime, duration, buffered, onSeek, disabled = false, label = "Seek", className }: ScrubberProps) {
  const [hover, setHover] = React.useState<number | null>(null)
  const seek = useSeekPointer({ duration, onSeek, disabled })
  const played = duration > 0 ? currentTime / duration : 0

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const to = disabled ? undefined : seekKeyTarget(event, currentTime, duration)
    if (to === undefined) return
    // Marked handled, so the player's own keys do not move the video a second time.
    event.preventDefault()
    onSeek(to)
  }

  return (
    <div
      {...seekValue(currentTime, duration)}
      aria-label={label}
      aria-disabled={disabled || undefined}
      tabIndex={disabled ? -1 : 0}
      data-slot="video-player-scrubber"
      data-dragging={seek.dragging || undefined}
      onKeyDown={onKeyDown}
      {...seek.handlers}
      onPointerMove={(event) => {
        if (!disabled) setHover(fractionAt(event.currentTarget, event.clientX))
        seek.handlers.onPointerMove(event)
      }}
      onPointerLeave={() => setHover(null)}
      className={cn(
        "group/scrubber relative flex h-4 w-full cursor-pointer touch-none items-center rounded-full focus-ring aria-disabled:cursor-default",
        className
      )}
    >
      <div className="relative h-1 w-full overflow-hidden rounded-full bg-foreground/25 transition-[height] duration-(--duration-fast) ease-(--ease-standard) group-hover/scrubber:h-1.5 group-data-dragging/scrubber:h-1.5">
        {buffered.map((range) => (
          <div
            key={`${range.start}-${range.end}`}
            className="absolute inset-y-0 bg-foreground/35"
            style={{ insetInlineStart: percent(range.start), width: percent(range.end - range.start) }}
          />
        ))}
        <div className="absolute inset-y-0 start-0 bg-brand" style={{ width: percent(played) }} />
      </div>
      <div
        aria-hidden="true"
        className="pointer-events-none absolute size-3 -translate-x-1/2 rounded-full bg-brand opacity-0 transition-opacity duration-(--duration-fast) ease-(--ease-standard) group-hover/scrubber:opacity-100 group-focus-visible/scrubber:opacity-100 group-data-dragging/scrubber:opacity-100 rtl:translate-x-1/2"
        style={{ insetInlineStart: percent(played) }}
      />
      {hover !== null ? (
        <div
          aria-hidden="true"
          className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-md bg-popover px-1.5 py-0.5 text-xs text-popover-foreground tabular-nums shadow-(--shadow-float) rtl:translate-x-1/2"
          style={{ insetInlineStart: percent(hover) }}
        >
          <bdi dir="ltr">{formatMediaTime(hover * duration)}</bdi>
        </div>
      ) : null}
    </div>
  )
}

export { Scrubber }
components/ui/video-player/captions.ts
"use client"

import * as React from "react"

/** The parts of a VTTCue the placement reads and writes. */
type Placeable = { line: number | "auto"; snapToLines: boolean; lineAlign: string }

/** Where each cue was before it was lifted — the file's own placement. */
const AUTHORED = new WeakMap<object, number | "auto">()

const placeable = (cue: unknown): cue is Placeable =>
  typeof cue === "object" && cue !== null && "line" in cue && "snapToLines" in cue && "lineAlign" in cue

/**
 * Blink and WebKit draw the cues in a container the page's CSS can move, and
 * the player lifts that with the controls (index.tsx) — Blink has no
 * `lineAlign` to place a cue by its foot. Only elsewhere are the cues moved.
 */
const liftedByCss = () =>
  typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("selector(::-webkit-media-text-track-container)")

/**
 * Keeps the captions clear of the controls. The browser draws a cue on the
 * bottom line of the picture, which is where the controls sit: while they
 * are up, every cue the file left on its default line is lifted to end just
 * above them, and when they step back it goes down again. A cue the file
 * placed itself (a `line:` setting) is left where it was put.
 */
export function useCaptionsClearOf(
  video: React.RefObject<HTMLVideoElement | null>,
  tracks: React.RefObject<(HTMLTrackElement | null)[]>,
  controls: React.RefObject<HTMLElement | null>,
  lifted: boolean
) {
  React.useEffect(() => {
    const element = video.current
    if (!element || liftedByCss()) return
    const place = () => {
      const height = element.clientHeight
      // The controls' own height, and a little air between them and the words.
      const lift = lifted && height > 0 ? (controls.current?.offsetHeight ?? 0) + 8 : 0
      for (const track of tracks.current ?? []) {
        for (const cue of Array.from(track?.track?.cues ?? [])) {
          if (!placeable(cue)) continue
          if (!AUTHORED.has(cue)) AUTHORED.set(cue, cue.snapToLines ? cue.line : -1)
          if (AUTHORED.get(cue) !== "auto") continue
          if (lift > 0) {
            cue.snapToLines = false
            cue.lineAlign = "end"
            cue.line = Math.max(0, Math.round((1 - lift / height) * 100))
          } else {
            cue.snapToLines = true
            cue.lineAlign = "start"
            cue.line = "auto"
          }
        }
      }
    }
    place()
    // Cues arrive when a track loads, and the picture changes size with the
    // window and with full screen.
    const loaded = (tracks.current ?? []).filter((track): track is HTMLTrackElement => track !== null)
    for (const track of loaded) track.addEventListener("load", place)
    const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(place)
    observer?.observe(element)
    return () => {
      for (const track of loaded) track.removeEventListener("load", place)
      observer?.disconnect()
    }
  }, [video, tracks, controls, lifted])
}