import type { AudioTrack, QualityRung, SubtitleTrack } from './shared'; import { useEffect, useRef, useState } from 'react'; import { Check, Gauge, Languages, Maximize2, Minimize2, Pause, Play, RotateCcw, SkipForward, Subtitles, Volume1, Volume2, VolumeX, } from 'lucide-react'; import { formatClock } from './shared'; // The control bar. Ours rather than the browser's for every transport, so that the progressive fallback — whose // scrubber has to re-request the stream — is not a visibly different player from the seekable ones. // // THE MENUS ARE HAND-ROLLED, and that is not an oversight. A Radix/shadcn dropdown renders through a portal // into document.body, and body is not inside the element that went fullscreen — so every one of these would be // invisible exactly when the player is most likely to be used. These stay in the DOM subtree they belong to. const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2]; const BUTTON = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white/90 hover:bg-white/15'; const ROW = 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-white/10'; type MenuProps = { icon: React.ReactNode; label: string; active?: boolean; children: (close: () => void) => React.ReactNode; }; const Menu = ({ icon, label, active, children }: MenuProps) => { const [open, setOpen] = useState(false); const wrapper = useRef(null); useEffect(() => { if (!open) return; const onPointer = (ev: PointerEvent) => { if (!wrapper.current?.contains(ev.target as Node)) setOpen(false); }; const onKey = (ev: KeyboardEvent) => { if (ev.key !== 'Escape') return; // Escape closes the menu rather than leaving fullscreen, but only while one is open. ev.stopPropagation(); ev.preventDefault(); setOpen(false); }; document.addEventListener('pointerdown', onPointer, true); document.addEventListener('keydown', onKey, true); return () => { document.removeEventListener('pointerdown', onPointer, true); document.removeEventListener('keydown', onKey, true); }; }, [open]); return (
{open && (

{label}

{children(() => setOpen(false))}
)}
); }; type OptionProps = { selected: boolean; onSelect: () => void; children: React.ReactNode; hint?: string | null }; const Option = ({ selected, onSelect, children, hint }: OptionProps) => ( ); export type PlayerControlsProps = { playing: boolean; position: number; duration: number; /** Seconds buffered ahead of the current position, as one number — the element's own ranges, flattened. */ bufferedTo: number; /** False in progressive mode, where a scrub costs a new transcode and only commits on release. */ liveScrub: boolean; volume: number; muted: boolean; rate: number; fullscreen: boolean; audio: AudioTrack[]; audioIndex: number | null; subtitles: SubtitleTrack[]; subtitleIndex: number | null; qualityRungs: QualityRung[]; maxBitrate: number | null; /** Set while a re-negotiation is in flight, so a picker click reads as busy rather than as nothing. */ busy: boolean; onTogglePlay: () => void; onScrub: (seconds: number) => void; onScrubCommit: (seconds: number) => void; onSkip: (seconds: number) => void; onVolume: (volume: number) => void; onToggleMute: () => void; onRate: (rate: number) => void; onAudio: (index: number) => void; onSubtitle: (index: number | null) => void; onQuality: (bitrate: number | null) => void; onFullscreen: () => void; }; export const PlayerControls = (props: PlayerControlsProps) => { const { position, duration, bufferedTo, liveScrub } = props; const barRef = useRef(null); const [dragging, setDragging] = useState(null); const [hover, setHover] = useState(null); const shown = dragging ?? position; const pct = (seconds: number) => (duration > 0 ? Math.min(100, Math.max(0, (seconds / duration) * 100)) : 0); const secondsAt = (clientX: number): number => { const box = barRef.current?.getBoundingClientRect(); if (!box || box.width === 0) return 0; return Math.max(0, Math.min(duration, ((clientX - box.left) / box.width) * duration)); }; // Pointer capture rather than window listeners: the drag keeps tracking when the cursor leaves the bar, and // it ends on the same element it started on even if that is over the video or outside the window. const onPointerDown = (ev: React.PointerEvent) => { ev.currentTarget.setPointerCapture(ev.pointerId); const seconds = secondsAt(ev.clientX); setDragging(seconds); if (liveScrub) props.onScrub(seconds); }; const onPointerMove = (ev: React.PointerEvent) => { const seconds = secondsAt(ev.clientX); setHover(seconds); if (dragging === null) return; setDragging(seconds); // In progressive mode every intermediate value would start an ffmpeg, so only the release commits. if (liveScrub) props.onScrub(seconds); }; const onPointerUp = (ev: React.PointerEvent) => { if (dragging === null) return; ev.currentTarget.releasePointerCapture(ev.pointerId); props.onScrubCommit(secondsAt(ev.clientX)); setDragging(null); }; const currentSubtitle = props.subtitles.find((track) => track.index === props.subtitleIndex) ?? null; const currentQuality = props.qualityRungs.find((rung) => rung.videoBitrate === props.maxBitrate) ?? null; return (
{formatClock(shown)}
setHover(null)} className="group relative h-6 flex-1 cursor-pointer touch-none select-none" >
{hover !== null && duration > 0 && ( {formatClock(hover)} )}
{formatClock(duration)}
{/* The slider stays out of the tab order on purpose — the volume button is the keyboard affordance, and a range input here would swallow the arrow keys the player binds to seeking. */}
props.onVolume(Number(ev.target.value))} aria-label="Volume" className="h-1 w-0 cursor-pointer accent-primary opacity-0 transition-all group-hover/vol:w-20 group-hover/vol:opacity-100" />
{props.busy && switching…} } label="Subtitles" active={props.subtitleIndex !== null}> {(close) => ( <> {props.subtitles.length === 0 && (

This file has no subtitle tracks.

)} {props.subtitles.map((track) => track.url ? ( ) : (

{track.label} unavailable — {track.unavailable}

), )} )}
} label="Audio track"> {(close) => ( <> {props.audio.map((track) => ( ))} {props.audio.length <= 1 && (

Only one audio track.

)} )}
} label="Speed and quality"> {(close) => ( <>

Speed

{PLAYBACK_RATES.map((rate) => ( ))}

Quality

{props.qualityRungs.map((rung) => ( ))} )}
{(currentSubtitle || currentQuality) && (

{[ currentSubtitle && `subtitles: ${currentSubtitle.label}`, currentQuality && `quality: ${currentQuality.label}`, ] .filter(Boolean) .join(' · ')}

)}
); };