import { useCallback, useEffect, useRef, useState } from "react"; /** WebKit exposes HLS alternate audio renditions here; the DOM lib omits it. */ interface AudioTrackLike { id: string; label: string; language: string; enabled: boolean; } interface AudioTrackListLike { length: number; [index: number]: AudioTrackLike; addEventListener?: (t: string, fn: () => void) => void; removeEventListener?: (t: string, fn: () => void) => void; } type VideoWithTracks = HTMLVideoElement & { audioTracks?: AudioTrackListLike }; function listAudio(v: HTMLVideoElement | null): AudioTrackLike[] { const list = (v as VideoWithTracks | null)?.audioTracks; if (!list) return []; return Array.from({ length: list.length }, (_, i) => list[i]); } function listSubs(v: HTMLVideoElement | null): TextTrack[] { if (!v) return []; return Array.from(v.textTracks).filter( (t) => t.kind === "subtitles" || t.kind === "captions", ); } interface Props { videoRef: React.RefObject; /** Element to fullscreen — the stage, so letterboxing travels with it. */ stageRef: React.RefObject; /** Nudges the auto-hide timer whenever the user does something. */ onActivity?: () => void; } const SKIP_S = 10; function clock(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); const mm = h > 0 ? String(m).padStart(2, "0") : String(m); return `${h > 0 ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`; } const btn = "grid size-10 shrink-0 place-items-center rounded-lg text-white/90 cursor-pointer " + "hover:bg-white/15 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"; /** * Our own transport bar. * * WebKit's native controls put fullscreen, Picture-in-Picture and volume as * overlay buttons in the top corners of the video and give no way to move them. * Owning the bar is the only way to get every control into one strip along the * bottom, so the native ones are switched off entirely. */ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props) { const [playing, setPlaying] = useState(false); const [time, setTime] = useState(0); const [duration, setDuration] = useState(0); const [volume, setVolume] = useState(1); const [muted, setMuted] = useState(false); const [pip, setPip] = useState(false); const [full, setFull] = useState(false); const [menu, setMenu] = useState(false); const [audio, setAudio] = useState([]); const [subs, setSubs] = useState([]); const [, bump] = useState(0); const menuRef = useRef(null); // Tracks arrive with the manifest, after metadata rather than on mount. useEffect(() => { const v = videoRef.current; if (!v) return; const read = () => { setAudio(listAudio(v)); setSubs(listSubs(v)); }; read(); // Nothing translated gets forced on. YouTube marks its subtitle track // AUTOSELECT=YES and WebKit will switch it on when it matches the system // language; that is the same unwanted auto-selection as a dubbed audio // track, so subtitles start off and stay a deliberate choice. const silenceSubs = () => { for (const t of Array.from(v.textTracks)) t.mode = "disabled"; read(); }; v.addEventListener("loadedmetadata", silenceSubs); v.addEventListener("loadedmetadata", read); const at = (v as VideoWithTracks).audioTracks; at?.addEventListener?.("addtrack", read); v.textTracks.addEventListener?.("addtrack", read); // Manifests can take a moment to surface renditions. const id = setInterval(read, 1000); const stop = setTimeout(() => clearInterval(id), 8000); return () => { v.removeEventListener("loadedmetadata", silenceSubs); v.removeEventListener("loadedmetadata", read); at?.removeEventListener?.("addtrack", read); v.textTracks.removeEventListener?.("addtrack", read); clearInterval(id); clearTimeout(stop); }; }, [videoRef]); // Close the menu on any outside click. useEffect(() => { if (!menu) return; const onDown = (e: MouseEvent) => { if (!menuRef.current?.contains(e.target as Node)) setMenu(false); }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, [menu]); const chooseAudio = (i: number) => { const v = videoRef.current; const list = (v as VideoWithTracks | null)?.audioTracks; if (!list) return; // Exactly one enabled, or WebKit mixes them. for (let k = 0; k < list.length; k++) list[k].enabled = k === i; bump((n) => n + 1); onActivity?.(); }; const chooseSub = (track: TextTrack | null) => { const v = videoRef.current; if (!v) return; for (const t of Array.from(v.textTracks)) { t.mode = t === track ? "showing" : "disabled"; } bump((n) => n + 1); onActivity?.(); }; // Mirror the element's state rather than assuming ours is authoritative — // playback can change from the keyboard, the system, or the video ending. useEffect(() => { const v = videoRef.current; if (!v) return; const sync = () => { setPlaying(!v.paused); setTime(v.currentTime); setDuration(Number.isFinite(v.duration) ? v.duration : 0); setVolume(v.volume); setMuted(v.muted); }; const onPip = () => setPip(!!document.pictureInPictureElement); const onFull = () => setFull(!!document.fullscreenElement); sync(); for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended"]) { v.addEventListener(e, sync); } v.addEventListener("enterpictureinpicture", onPip); v.addEventListener("leavepictureinpicture", onPip); document.addEventListener("fullscreenchange", onFull); return () => { for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended"]) { v.removeEventListener(e, sync); } v.removeEventListener("enterpictureinpicture", onPip); v.removeEventListener("leavepictureinpicture", onPip); document.removeEventListener("fullscreenchange", onFull); }; }, [videoRef]); const act = useCallback( (fn: (v: HTMLVideoElement) => void) => () => { const v = videoRef.current; if (v) fn(v); onActivity?.(); }, [videoRef, onActivity], ); const togglePlay = act((v) => { if (v.paused) void v.play().catch(() => {}); else v.pause(); }); const togglePip = useCallback(async () => { const v = videoRef.current; if (!v) return; onActivity?.(); try { if (document.pictureInPictureElement) await document.exitPictureInPicture(); else await v.requestPictureInPicture(); } catch { /* not available for this source */ } }, [videoRef, onActivity]); const toggleFull = useCallback(async () => { onActivity?.(); try { if (document.fullscreenElement) await document.exitFullscreen(); else await stageRef.current?.requestFullscreen(); } catch { /* element fullscreen unavailable */ } }, [stageRef, onActivity]); const pct = duration > 0 ? (time / duration) * 100 : 0; return (
e.stopPropagation()} className="absolute inset-x-0 bottom-0 z-20 flex items-center gap-2.5 bg-gradient-to-t from-slate-950/90 via-slate-950/65 to-transparent px-5 pb-4 pt-10" > {clock(time)} { const v = videoRef.current; if (v) v.currentTime = Number(e.target.value); onActivity?.(); }} aria-label="Seek" className="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/25 accent-sky-500" style={{ background: `linear-gradient(to right, var(--color-sky-500) ${pct}%, rgba(255,255,255,0.25) ${pct}%)`, }} /> {clock(duration)} { const v = videoRef.current; if (!v) return; v.volume = Number(e.target.value); v.muted = Number(e.target.value) === 0; onActivity?.(); }} aria-label="Volume" className="h-1.5 w-24 shrink-0 cursor-pointer appearance-none rounded-full accent-sky-500" style={{ background: `linear-gradient(to right, rgba(255,255,255,0.85) ${ (muted ? 0 : volume) * 100 }%, rgba(255,255,255,0.25) ${(muted ? 0 : volume) * 100}%)`, }} /> {(audio.length > 1 || subs.length > 0) && (
{menu && (
{audio.length > 1 && ( <>
Audio
{audio.map((t, i) => ( ))} )} {subs.length > 0 && ( <>
Subtitles
{subs.map((t, i) => ( ))} )}
)}
)}
); }