import { forwardRef, useEffect, useRef, useState } from "react"; import Hls from "hls.js"; import { Loader2 } from "lucide-react"; import type { CurrentBroadcastResponse } from "@/lib/types"; import { StatsPanel } from "./stats-panel"; export interface SubtitleTrack { id: number; name: string; lang?: string; } interface VideoPlayerProps { src?: string; poster?: string; className?: string; /** Seconds into the current item to seek on load (broadcast sync). */ initialOffset?: number; /** Active subtitle track index, or -1 to disable. */ subtitleTrack?: number; muted?: boolean; /** Force direct-file mode (skips hls.js even for .m3u8 URLs). */ streamingProtocol?: "hls" | "direct_file"; /** When true, renders the Stats for Nerds overlay. */ showStats?: boolean; /** Current broadcast data passed to the stats panel for slot timing. */ broadcast?: CurrentBroadcastResponse | null; onStreamError?: () => void; onSubtitleTracksChange?: (tracks: SubtitleTrack[]) => void; /** Called when the browser blocks autoplay and user interaction is required. */ onNeedsInteraction?: () => void; /** Called when the video element fires its ended event. */ onEnded?: () => void; } const VideoPlayer = forwardRef( ( { src, poster, className, initialOffset = 0, subtitleTrack = -1, muted = false, streamingProtocol, showStats = false, broadcast, onStreamError, onSubtitleTracksChange, onNeedsInteraction, onEnded, }, ref, ) => { const internalRef = useRef(null); const hlsRef = useRef(null); const [isBuffering, setIsBuffering] = useState(true); const onEndedRef = useRef(onEnded); onEndedRef.current = onEnded; const mutedRef = useRef(muted); mutedRef.current = muted; useEffect(() => { if (internalRef.current) internalRef.current.muted = muted; }, [muted]); const setRef = (el: HTMLVideoElement | null) => { internalRef.current = el; if (typeof ref === "function") ref(el); else if (ref) ref.current = el; }; // Apply subtitle track changes without tearing down the HLS instance useEffect(() => { if (hlsRef.current) { hlsRef.current.subtitleTrack = subtitleTrack; } }, [subtitleTrack]); useEffect(() => { const video = internalRef.current; if (!video || !src) return; hlsRef.current?.destroy(); hlsRef.current = null; onSubtitleTracksChange?.([]); setIsBuffering(true); const isHls = streamingProtocol !== "direct_file" && src.includes(".m3u8"); if (isHls && Hls.isSupported()) { const hls = new Hls({ startPosition: initialOffset > 0 ? initialOffset : -1, maxMaxBufferLength: 30, }); hlsRef.current = hls; hls.on(Hls.Events.MANIFEST_PARSED, () => { video.muted = mutedRef.current; video.play().catch(() => onNeedsInteraction?.()); }); hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { onSubtitleTracksChange?.( data.subtitleTracks.map((t) => ({ id: t.id, name: t.name, lang: t.lang, })), ); }); hls.on(Hls.Events.ERROR, (_event, data) => { if (data.fatal) onStreamError?.(); }); hls.loadSource(src); hls.attachMedia(video); } else if (isHls && video.canPlayType("application/vnd.apple.mpegurl")) { // Native HLS (Safari) video.src = src; video.addEventListener( "loadedmetadata", () => { if (initialOffset > 0) video.currentTime = initialOffset; video.play().catch(() => onNeedsInteraction?.()); }, { once: true }, ); } else { // Plain MP4 / direct file: seek to offset after metadata loads. video.src = src; video.addEventListener( "loadedmetadata", () => { if (initialOffset > 0) video.currentTime = initialOffset; video.muted = mutedRef.current; video.play().catch(() => onNeedsInteraction?.()); }, { once: true }, ); video.load(); } return () => { hlsRef.current?.destroy(); hlsRef.current = null; }; // initialOffset intentionally excluded: only seek when src changes (new slot/mount) // eslint-disable-next-line react-hooks/exhaustive-deps }, [src]); return (
); }, ); VideoPlayer.displayName = "VideoPlayer"; export { VideoPlayer }; export type { VideoPlayerProps };