import { useEffect, useRef, useState } from "react"; import { BAR_GAP, BAR_W, extractPeaks, NUM_BARS, VIEWBOX_W, WAVEFORM_H, } from "../utils/waveform.ts"; import { fmt } from "../utils/duration.ts"; export const IconPlay = () => ( ); export const IconPause = () => ( ); const IconVolume = ({ muted }: { muted: boolean }) => ( {muted ? ( ) : ( )} ); const IconFullscreen = () => ( ); // ── Waveform ────────────────────────────────────────────────────────────────── function Waveform( { src, current, duration, onSeek }: { src: string; current: number; duration: number; onSeek: (t: number) => void; }, ) { const [peaks, setPeaks] = useState(null); useEffect(() => { let cancelled = false; extractPeaks(src, NUM_BARS) .then((p) => { if (!cancelled) setPeaks(p); }) .catch(() => {}); return () => { cancelled = true; }; }, [src]); const progress = duration > 0 ? current / duration : 0; const handleClick = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const ratio = Math.max( 0, Math.min(1, (e.clientX - rect.left) / rect.width), ); onSeek(ratio * duration); }; if (!peaks) { return (
); } return ( {Array.from(peaks).map((p, i) => { const barH = Math.max(p * WAVEFORM_H, 2); const x = i * (BAR_W + BAR_GAP); const y = (WAVEFORM_H - barH) / 2; const played = i / NUM_BARS <= progress; return ( ); })} ); } // ── Volume ──────────────────────────────────────────────────────────────────── // Each queue advance remounts MediaPlayer (the element is keyed on the source), // which would otherwise snap the volume back to 100% on every track. Hold it // outside the component and mirror it to localStorage so it also survives a // reload. const VOLUME_KEY = "player-volume"; let lastVolume = 1; let lastMuted = false; try { const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null"); if (stored && typeof stored.volume === "number") { lastVolume = Math.min(Math.max(stored.volume, 0), 1); lastMuted = stored.muted === true; } } catch { // Malformed or unavailable storage — the defaults above stand. } function rememberVolume(volume: number, muted: boolean) { lastVolume = volume; lastMuted = muted; try { localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted })); } catch { // Private mode or blocked storage — volume just won't persist. } } // ── Seek bar ────────────────────────────────────────────────────────────────── /** Slim progress + scrub bar. Used by video, and by audio sources whose peaks * can't be decoded (cross-origin streams) — where a waveform would be a lie. */ function SeekBar( { current, duration, onSeek, onDragChange, className }: { current: number; duration: number; onSeek: (t: number) => void; onDragChange: (dragging: boolean) => void; className?: string; }, ) { const progress = duration > 0 ? current / duration : 0; return (
onDragChange(true)} onMouseUp={() => onDragChange(false)} onChange={(e) => onSeek(Number(e.target.value))} aria-label="Seek" />
); } // ── MediaPlayer ─────────────────────────────────────────────────────────────── const HIDE_DELAY = 2500; interface MediaPlayerProps { src: string; kind: "audio" | "video"; mime?: string; poster?: string; autoplay?: boolean; startTime?: number; onPlayStateChange?: (playing: boolean) => void; 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"; seekRef?: { current: ((t: number) => void) | null }; toggleRef?: { current: (() => void) | null }; } export function MediaPlayer( { src, kind, mime, poster, autoplay, startTime, onPlayStateChange, onTimeUpdate, onEnded, onError, trackStyle = "waveform", seekRef, toggleRef, }: MediaPlayerProps, ) { 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(lastVolume); const [muted, setMuted] = useState(lastMuted); 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?.(); }; a.addEventListener("timeupdate", onTime); a.addEventListener("durationchange", onDuration); a.addEventListener("ended", onEndedEvent); a.addEventListener("error", onErrorEvent); return () => { a.removeEventListener("timeupdate", onTime); 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}
); }