Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
20 kB · 545 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546// 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<HTMLVideoElement | null>; /** Element to send into browser fullscreen. Defaults to the parent of the video. */ containerRef: RefObject<HTMLElement | null>; /** 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 ( <div className={cn( "absolute inset-0 flex flex-col justify-end transition-opacity duration-200", visible ? "opacity-100" : "pointer-events-none opacity-0", )} > {/* 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. */} <div className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent" />
<button type="button" onClick={togglePlay} className={cn( "absolute inset-0 flex items-center justify-center transition-opacity duration-200 ease-out", bigPlay ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0", )} aria-label={t("player-play")} aria-hidden={!bigPlay} tabIndex={bigPlay ? 0 : -1} > <div className="flex items-center gap-3 rounded-full border border-white/20 bg-white/10 px-5 py-3 backdrop-blur transition-colors hover:bg-white/20"> <Play className="h-6 w-6 fill-white text-white" /> <span className="font-medium text-white">{t("player-play")}</span> </div> </button>
<button type="button" onClick={(event) => { event.stopPropagation(); toggleMute(); }} className={cn( "focus-visible:ring-ring/50 wide:bottom-20 absolute bottom-16 left-1/2 -translate-x-1/2 rounded-full transition-opacity duration-200 ease-out focus-visible:ring-3 focus-visible:outline-none", showUnmutePrompt ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0", )} aria-label={t("player-unmute")} aria-hidden={!showUnmutePrompt} tabIndex={showUnmutePrompt ? 0 : -1} > <div className="group bg-primary/50 hover:bg-primary/80 flex h-11 items-center gap-2 rounded-full border border-white/20 px-4 backdrop-blur transition-colors"> <VolumeX className="text-primary-foreground h-5 w-5" /> <span className="text-primary-foreground font-medium"> {t("player-unmute")} </span> </div> </button>
<div className="pointer-events-auto relative space-y-2 bg-linear-to-t from-black/80 to-black/0 px-3 py-2 sm:gap-3" // The wrapper itself is a "control surface" so clicks on the // gradient below the buttons don't bubble to the play handler. onClick={(e) => e.stopPropagation()} > {!isLive && duration > 0 && ( <Slider min={0} max={duration} step={0.1} value={currentTime} onValueChange={onSeekInput} className="h-1 w-full min-w-full cursor-pointer sm:w-48" aria-label={t("player-seek")} /> )} <div className="wide:gap-2 flex items-center gap-0.5"> <button type="button" onClick={togglePlay} className="wide:size-auto wide:p-1 flex size-11 items-center justify-center rounded-md text-white transition-colors hover:bg-white/10 hover:text-white/80" aria-label={playing ? t("player-pause") : t("player-play")} title={playing ? t("player-pause") : t("player-play")} > {playing ? ( <Pause className="h-5 w-5 fill-white" /> ) : ( <Play className="h-5 w-5 fill-white" /> )} </button>
{!isLive && ( <span className="flex font-mono text-sm text-white/80 tabular-nums"> <div className="flex font-mono text-sm tabular-nums"> {formatTime(currentTime)} </div> <div className="ml-1 flex font-mono text-sm tabular-nums">/</div> <div className="ml-1 flex font-mono text-sm tabular-nums"> {formatTime(duration)} </div> </span> )}
<div className="group/vol flex items-center gap-1.5"> <button type="button" onClick={toggleMute} className="wide:size-auto wide:p-1 flex size-11 items-center justify-center rounded-md text-white transition-colors hover:bg-white/10 hover:text-white/80" aria-label={muted ? t("player-unmute") : t("player-mute")} title={muted ? t("player-unmute") : t("player-mute")} > {muted || volume === 0 ? ( <VolumeX className="h-5 w-5" /> ) : ( <Volume2 className="h-5 w-5" /> )} </button> <Slider className="wide:flex wide:w-24 hidden w-20" min={0} max={1} step={0.01} value={muted ? 0 : volume} onValueChange={onVolumeInput} /> </div>
<div className="flex-1" />
{pipSupported && ( <button type="button" onClick={togglePiP} className={cn( "wide:size-auto wide:p-1 flex size-11 items-center justify-center rounded-md transition-colors hover:bg-white/10", isPiP ? "text-white" : "text-white/40 hover:text-white/80", )} aria-label={isPiP ? t("player-exit-pip") : t("player-pip")} title={isPiP ? t("player-exit-pip") : t("player-pip")} > <PictureInPicture className="h-5 w-5" /> </button> )}
<button type="button" onClick={() => setTheatre(!theatre)} className={cn( "wide:flex wide:p-1 hidden transition-colors", theatre ? "text-white" : "text-white/40 hover:text-white/80", )} aria-label={ theatre ? t("player-exit-theatre") : t("player-theatre") } title={theatre ? t("player-exit-theatre") : t("player-theatre")} > <RectangleHorizontal className="h-5 w-5" /> </button>
<button type="button" onClick={() => onShowDanmuChange(!showDanmu)} className={cn( "wide:flex wide:p-1 hidden transition-colors", showDanmu ? "text-white" : "text-white/40 hover:text-white/80", )} aria-label={ showDanmu ? t("player-disable-danmu") : t("player-enable-danmu") } title={ showDanmu ? t("player-disable-danmu") : t("player-enable-danmu") } > <MuIcon size={20} /> </button>
<DropdownMenu onOpenChange={setSettingsOpen}> <DropdownMenuTrigger className="wide:size-auto wide:p-1 flex size-11 items-center justify-center rounded-md text-white transition-colors hover:bg-white/10 hover:text-white/80" aria-label={t("player-settings")} title={t("player-settings")} > <Settings className={cn( "h-5 w-5 transition-transform duration-300", settingsOpen && "rotate-60", )} /> </DropdownMenuTrigger> <DropdownMenuContent side="top" align="end" className="border-white/10 bg-black/85 backdrop-blur" > <DropdownMenuGroup> <DropdownMenuLabel>{t("player-latency")}</DropdownMenuLabel> <DropdownMenuRadioGroup value={useWebRTC ? "webrtc" : "hls"} onValueChange={(v) => onUseWebRTCChange(v === "webrtc")} > <DropdownMenuRadioItem value="hls"> Standard </DropdownMenuRadioItem> <DropdownMenuRadioItem value="webrtc"> Low (WebRTC) </DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuGroup>
{qualities.length > 0 && ( <> <DropdownMenuSeparator /> <DropdownMenuGroup> <DropdownMenuLabel>{t("player-quality")}</DropdownMenuLabel> <DropdownMenuRadioGroup value={String(currentQuality)} onValueChange={(v) => onQualityChange(Number(v))} > {qualities.map((q) => ( <DropdownMenuRadioItem key={q.index} value={String(q.index)} > {q.label} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuGroup> </> )}
<DropdownMenuSeparator /> <DropdownMenuCheckboxItem className="wide:hidden" checked={theatre} onCheckedChange={(checked) => setTheatre(checked)} > {t("player-theatre")} </DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem className="wide:hidden" checked={showDanmu} onCheckedChange={onShowDanmuChange} > {t("player-danmu")} </DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem checked={showStats} onCheckedChange={onShowStatsChange} > Stats for nerds </DropdownMenuCheckboxItem> </DropdownMenuContent> </DropdownMenu>
<button type="button" onClick={toggleFullscreen} className="wide:size-auto wide:p-1 flex size-11 items-center justify-center rounded-md text-white transition-colors hover:bg-white/10 hover:text-white/80" aria-label={ isFullscreen ? t("player-exit-fullscreen") : t("player-fullscreen") } title={ isFullscreen ? t("player-exit-fullscreen") : t("player-fullscreen") } > {isFullscreen ? ( <Minimize className="h-5 w-5" /> ) : ( <Maximize className="h-5 w-5" /> )} </button> </div> </div> </div> );}
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")}`;}