/** * The site's audio and video player. * * @module */ import { useEffect, useRef, useState } from "react"; import { fmt } from "../../utils/player/duration.ts"; import { t } from "@lingui/core/macro"; import { IconFullscreen, IconPause, IconPlay, IconVolume, } from "./PlayerIcons.tsx"; import { SeekBar } from "./SeekBar.tsx"; import { Waveform } from "./Waveform.tsx"; import { rememberVolume, storedVolume } from "../../utils/player/volume.ts"; /** How long a playing video's controls stay after the pointer stops, in ms. */ const HIDE_DELAY = 2500; /** The props of {@link MediaPlayer}. */ export interface MediaPlayerProps { /** The media's URL. */ src: string; /** Whether it is audio or video. */ kind: "audio" | "video"; /** The media's type. */ mime?: string; /** A video's still before it plays. */ poster?: string; /** Whether it starts playing on mount. */ autoplay?: boolean; /** Where it starts, in seconds. */ startTime?: number; /** Called when it starts or stops playing. */ onPlayStateChange?: (playing: boolean) => void; /** Called as it plays, with the playhead and the duration. */ onTimeUpdate?: (time: number, duration: number) => void; /** Fired when the media reaches its end — the queue's advance hook. */ onEnded?: () => void; /** Fired when the element fails to load or decode. For expiring stream URLs * this is the signal to re-resolve. */ onError?: () => void; /** "waveform" (default) decodes peaks from the source; "progress" draws a * slim seek bar and never fetches the media a second time. */ trackStyle?: "waveform" | "progress"; /** Receives its seek handle, for the global player's controls. */ seekRef?: { current: ((t: number) => void) | null }; /** Receives its play/pause handle, for the global player's controls. */ toggleRef?: { current: (() => void) | null }; } /** * Renders a media element with the site's own controls: play/pause, a waveform * or seek bar, time, volume (remembered across players) and, for video, * fullscreen. */ export function MediaPlayer( { src, kind, mime, poster, autoplay, startTime, onPlayStateChange, onTimeUpdate, onEnded, onError, trackStyle = "waveform", seekRef, toggleRef, }: MediaPlayerProps, ): React.JSX.Element { const mediaRef = useRef(null); const [playing, setPlaying] = useState(false); const [current, setCurrent] = useState(0); const [duration, setDuration] = useState(0); const [dragging, setDragging] = useState(false); const [volume, setVolume] = useState(() => storedVolume().volume); const [muted, setMuted] = useState(() => storedVolume().muted); const [controlsVisible, setControlsVisible] = useState(true); const hideTimer = useRef | null>(null); // ── Callback refs ───────────────────────────────────────────────────────────── // Updated via effects (after render) so the linter doesn't flag render-phase ref // mutation. No cleanup → no null window. Negligible stale-window between commit // and effect, acceptable since these are only called from async event handlers. const onPlayStateChangeRef = useRef(onPlayStateChange); const onTimeUpdateRef = useRef(onTimeUpdate); const onEndedRef = useRef(onEnded); const onErrorRef = useRef(onError); // Sync prop callbacks after every render useEffect(() => { onPlayStateChangeRef.current = onPlayStateChange; onTimeUpdateRef.current = onTimeUpdate; onEndedRef.current = onEnded; onErrorRef.current = onError; }); // Stable function refs — updated via effects, indirected by the registration // effect below so external seekRef/toggleRef never see a null window. const seekToFnRef = useRef((_t: number) => {}); const toggleFnRef = useRef(() => {}); // seekTo: all captured values are stable (setCurrent from useState, mediaRef DOM ref) useEffect(() => { seekToFnRef.current = (time: number) => { setCurrent(time); mediaRef.current!.currentTime = time; }; }, []); // toggle: captures `playing` — re-synced whenever play state changes useEffect(() => { toggleFnRef.current = () => { const a = mediaRef.current!; if (playing) { a.pause(); setPlaying(false); onPlayStateChangeRef.current?.(false); } else { a.play() .then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); }) .catch(() => {}); } }; }, [playing]); // Stable wrappers used everywhere inside the component const seekTo = (time: number) => seekToFnRef.current(time); const toggle = () => toggleFnRef.current(); // ── Effects ────────────────────────────────────────────────────────────────── // Resume to a restored offset once metadata (and thus seekability) is available. // Runs only on mount for the initial offset — later seeks go through seekTo. useEffect(() => { if (!startTime) return; const a = mediaRef.current!; const apply = () => { a.currentTime = startTime; setCurrent(startTime); }; if (a.readyState >= 1) apply(); else a.addEventListener("loadedmetadata", apply, { once: true }); return () => a.removeEventListener("loadedmetadata", apply); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Apply the remembered volume to the freshly-mounted element, which always // starts at 1.0 regardless of what the last track was playing at. useEffect(() => { const a = mediaRef.current; if (!a) return; a.volume = volume; a.muted = muted; // Mount only: later changes go through changeVolume/toggleMute. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Autoplay on mount (e.g. triggered by play() in PlayerContext) useEffect(() => { if (!autoplay) return; mediaRef.current?.play() .then(() => { setPlaying(true); onPlayStateChangeRef.current?.(true); }) .catch(() => {}); }, [autoplay]); // On unmount: pause and cut callbacks so stale timeupdate/ended events that fire // between React's commit and the listener-removal effect can't reach the provider. useEffect(() => { const a = mediaRef.current!; return () => { a.pause(); onPlayStateChangeRef.current = undefined; onTimeUpdateRef.current = undefined; onEndedRef.current = undefined; onErrorRef.current = undefined; }; }, []); // Register imperative handles into provider refs. seekRef/toggleRef are stable // (created with useRef in PlayerProvider), so this effect runs exactly once. useEffect(() => { if (seekRef) seekRef.current = (t) => seekToFnRef.current(t); if (toggleRef) toggleRef.current = () => toggleFnRef.current(); return () => { if (seekRef) seekRef.current = null; if (toggleRef) toggleRef.current = null; }; }, [seekRef, toggleRef]); // Media element event listeners useEffect(() => { const a = mediaRef.current!; const onTime = () => { if (!dragging) { setCurrent(a.currentTime); onTimeUpdateRef.current?.(a.currentTime, a.duration); } }; const onDuration = () => { setDuration(a.duration); onTimeUpdateRef.current?.(a.currentTime, a.duration); }; const onEndedEvent = () => { setPlaying(false); onPlayStateChangeRef.current?.(false); onEndedRef.current?.(); }; const onErrorEvent = () => { setPlaying(false); onErrorRef.current?.(); }; // The element can also be started or stopped from outside the page — a // media key, a headset, the OS's media controls — so follow it rather // than only what our own button did. const onPlayEvent = () => { setPlaying(true); onPlayStateChangeRef.current?.(true); }; const onPauseEvent = () => { setPlaying(false); onPlayStateChangeRef.current?.(false); }; a.addEventListener("timeupdate", onTime); a.addEventListener("play", onPlayEvent); a.addEventListener("pause", onPauseEvent); a.addEventListener("durationchange", onDuration); a.addEventListener("ended", onEndedEvent); a.addEventListener("error", onErrorEvent); return () => { a.removeEventListener("timeupdate", onTime); a.removeEventListener("play", onPlayEvent); a.removeEventListener("pause", onPauseEvent); a.removeEventListener("durationchange", onDuration); a.removeEventListener("ended", onEndedEvent); a.removeEventListener("error", onErrorEvent); }; }, [dragging]); // Hide video controls after inactivity useEffect(() => { if (kind !== "video") return; if (hideTimer.current) clearTimeout(hideTimer.current); if (playing) { hideTimer.current = setTimeout( () => setControlsVisible(false), HIDE_DELAY, ); } return () => { if (hideTimer.current) clearTimeout(hideTimer.current); }; }, [playing, kind]); // ── Render helpers ──────────────────────────────────────────────────────────── const showingControls = kind !== "video" || !playing || controlsVisible; const showControlsTemporarily = () => { if (kind !== "video") return; setControlsVisible(true); if (hideTimer.current) clearTimeout(hideTimer.current); if (playing) { hideTimer.current = setTimeout( () => setControlsVisible(false), HIDE_DELAY, ); } }; const changeVolume = (e: React.ChangeEvent) => { const v = Number(e.target.value); const nextMuted = v > 0 && muted ? false : muted; setVolume(v); mediaRef.current!.volume = v; if (nextMuted !== muted) { setMuted(nextMuted); mediaRef.current!.muted = nextMuted; } rememberVolume(v, nextMuted); }; const toggleMute = () => { const next = !muted; setMuted(next); mediaRef.current!.muted = next; rememberVolume(volume, next); }; const goFullscreen = () => { (mediaRef.current as HTMLVideoElement).requestFullscreen?.(); }; // A waveform needs the raw bytes, which cross-origin streams won't hand over. // Those get an honest progress bar rather than a permanent loading skeleton. const track = kind === "audio" && trackStyle === "waveform" ? ( ) : ( ); const controls = ( <> {fmt(current)} {track} {fmt(duration)}
{kind === "video" && ( )} ); if (kind === "video") { return (
playing && setControlsVisible(false)} >
{controls}
); } return (
{controls}
); }