// Custom controls overlay for the HLS player. Replaces the native HTML5 // `controls` attribute so the player matches the rest of the web brand. // Hides when the video is playing and the user is idle; stays visible // when the video is paused or has errored. import { Maximize, Minimize, Pause, PictureInPicture, Play, RectangleHorizontal, Settings, Volume2, VolumeX, } from "lucide-react"; import { type RefObject, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useFullscreen } from "../../contexts/fullscreen-context"; import { cn } from "../../lib/utils"; import MuIcon from "../svg/mu"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { Slider } from "../ui/slider"; import type { QualityOption } from "./player"; export type PlayerControlsProps = { videoRef: RefObject; /** Element to send into browser fullscreen. Defaults to the parent of the video. */ containerRef: RefObject; /** Live streams hide the scrubber and show a "LIVE" badge in its place. */ isLive: boolean; /** Parent controls visibility; auto-hide logic lives in the HLSPlayer. */ showControls: boolean; /** When true, show a centered play button (no control bar). */ bigPlay: boolean; /** When true, controls render at full opacity regardless of `showControls`. */ forceVisible?: boolean; /** Available quality options from the backend. Empty hides the menu. */ qualities: QualityOption[]; /** Index of the currently selected quality (matches a `qualities[i].index`). */ currentQuality: number; /** Request a quality change; the backend decides what to do. */ onQualityChange: (index: number) => void; /** Whether the user picked low-latency (WebRTC) over standard (HLS). */ useWebRTC: boolean; /** Toggle between standard (HLS) and low (WebRTC) transport. */ onUseWebRTCChange: (useWebRTC: boolean) => void; /** Whether the "Stats for nerds" overlay is visible. */ showStats: boolean; /** Toggle the stats overlay. */ onShowStatsChange: (showStats: boolean) => void; /** Whether the danmu overlay is visible. */ showDanmu: boolean; /** Toggle the danmu overlay. */ onShowDanmuChange: (showDanmu: boolean) => void; }; export function shouldShowUnmutePrompt(playing: boolean, muted: boolean) { return playing && muted; } export function PlayerControls({ videoRef, containerRef, isLive, showControls, bigPlay, forceVisible, qualities, currentQuality, onQualityChange, useWebRTC, onUseWebRTCChange, showStats, onShowStatsChange, showDanmu, onShowDanmuChange, }: PlayerControlsProps) { const [playing, setPlaying] = useState(false); const [muted, setMuted] = useState(true); const [volume, setVolume] = useState(1); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [isFullscreen, setIsFullscreen] = useState(false); const [isPiP, setIsPiP] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const { theatre, setTheatre } = useFullscreen(); const { t } = useTranslation(); // Mirror video element state into React. useEffect(() => { const video = videoRef.current; if (!video) return; const onPlay = () => setPlaying(true); const onPause = () => setPlaying(false); const onVolumeChange = () => { setMuted(video.muted); setVolume(video.volume); }; const onTimeUpdate = () => { if (!isLive) setCurrentTime(video.currentTime); }; const onLoadedMetadata = () => { if (!isLive) setDuration(video.duration); }; // Sync initial state — the video may already be playing when we attach. setMuted(video.muted); setVolume(video.volume); if (!video.paused) setPlaying(true); video.addEventListener("play", onPlay); video.addEventListener("pause", onPause); video.addEventListener("volumechange", onVolumeChange); video.addEventListener("timeupdate", onTimeUpdate); video.addEventListener("loadedmetadata", onLoadedMetadata); return () => { video.removeEventListener("play", onPlay); video.removeEventListener("pause", onPause); video.removeEventListener("volumechange", onVolumeChange); video.removeEventListener("timeupdate", onTimeUpdate); video.removeEventListener("loadedmetadata", onLoadedMetadata); }; }, [videoRef, isLive]); // Track browser fullscreen state. useEffect(() => { if (typeof document === "undefined") return; const onChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener("fullscreenchange", onChange); return () => document.removeEventListener("fullscreenchange", onChange); }, []); // Track PiP state. useEffect(() => { const video = videoRef.current; if (!video) return; const onEnter = () => setIsPiP(true); const onLeave = () => setIsPiP(false); video.addEventListener("enterpictureinpicture", onEnter); video.addEventListener("leavepictureinpicture", onLeave); // Sync initial state. setIsPiP(!!document.pictureInPictureElement); return () => { video.removeEventListener("enterpictureinpicture", onEnter); video.removeEventListener("leavepictureinpicture", onLeave); }; }, [videoRef]); const pipSupported = typeof document !== "undefined" && !!document.pictureInPictureEnabled; const togglePiP = useCallback(async () => { const video = videoRef.current; if (!video) return; try { if (document.pictureInPictureElement) { await document.exitPictureInPicture(); } else { await video.requestPictureInPicture(); } } catch { // PiP can be denied by the browser or user settings. } }, [videoRef]); const togglePlay = useCallback(() => { const video = videoRef.current; if (!video) return; if (video.paused) { video.play().catch(() => {}); } else { video.pause(); } }, [videoRef]); const toggleMute = useCallback(() => { const video = videoRef.current; if (!video) return; video.muted = !video.muted; }, [videoRef]); const onVolumeInput = useCallback( (v: number) => { const video = videoRef.current; if (!video) return; video.volume = v; // Unmuting requires volume > 0 on some browsers. if (v > 0) video.muted = false; }, [videoRef], ); const onSeekInput = useCallback( (currentTime: number) => { const video = videoRef.current; if (!video) return; video.currentTime = currentTime; }, [videoRef], ); const toggleFullscreen = useCallback(async () => { const el = containerRef.current; if (!el) return; try { if (document.fullscreenElement) { await document.exitFullscreen(); } else { await el.requestFullscreen(); } } catch { // Fullscreen can be denied (e.g. not user-initiated in some browsers). } }, [containerRef]); // Keyboard shortcuts: space=play/pause, m=mute, f=fullscreen. useEffect(() => { const onKey = (e: KeyboardEvent) => { // Don't hijack typing in inputs or anywhere a user is composing text // (contenteditable covers rich editors like the Tiptap chat input). const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA") return; if (target?.isContentEditable) return; if (e.key === " ") { e.preventDefault(); togglePlay(); } else if (e.key === "m" || e.key === "M") { e.preventDefault(); toggleMute(); } else if (e.key === "f" || e.key === "F") { e.preventDefault(); toggleFullscreen(); } else if (e.key === "t" || e.key === "T") { e.preventDefault(); setTheatre(!theatre); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [togglePlay, toggleMute, toggleFullscreen, theatre, setTheatre]); const showUnmutePrompt = shouldShowUnmutePrompt(playing, muted); const visible = forceVisible || showControls || bigPlay || showUnmutePrompt; return (
{/* Top gradient; subtle hint that there's a controls bar. Not strictly needed since the bar has its own background, but gives the play button overlay a darker canvas. */}
e.stopPropagation()} > {!isLive && duration > 0 && ( )}
{!isLive && (
{formatTime(currentTime)}
/
{formatTime(duration)}
)}
{pipSupported && ( )} {t("player-latency")} onUseWebRTCChange(v === "webrtc")} > Standard Low (WebRTC) {qualities.length > 0 && ( <> {t("player-quality")} onQualityChange(Number(v))} > {qualities.map((q) => ( {q.label} ))} )} setTheatre(checked)} > {t("player-theatre")} {t("player-danmu")} Stats for nerds
); } function formatTime(s: number): string { if (!isFinite(s) || s < 0) return "0:00"; const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = Math.floor(s % 60); if (h === 0) return `${m}:${sec.toString().padStart(2, "0")}`; return `${h}:${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`; }