import { forwardRef, useEffect, useRef, useState } from "react"; import Hls from "hls.js"; import { Loader2 } from "lucide-react"; 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; 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, 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 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 = 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.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 fallback video.src = src; video.load(); video.play().catch(() => {}); } 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 };