163 lines
4.6 KiB
TypeScript
163 lines
4.6 KiB
TypeScript
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;
|
|
muted?: boolean;
|
|
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<HTMLVideoElement, VideoPlayerProps>(
|
|
(
|
|
{
|
|
src,
|
|
poster,
|
|
className,
|
|
initialOffset = 0,
|
|
subtitleTrack = -1,
|
|
muted = false,
|
|
onStreamError,
|
|
onSubtitleTracksChange,
|
|
onNeedsInteraction,
|
|
onEnded,
|
|
},
|
|
ref,
|
|
) => {
|
|
const internalRef = useRef<HTMLVideoElement | null>(null);
|
|
const hlsRef = useRef<Hls | null>(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 = 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 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 (
|
|
<div className={`relative h-full w-full bg-black ${className ?? ""}`}>
|
|
<video
|
|
ref={setRef}
|
|
poster={poster}
|
|
playsInline
|
|
onPlaying={() => setIsBuffering(false)}
|
|
onWaiting={() => setIsBuffering(true)}
|
|
onError={onStreamError}
|
|
onEnded={() => onEndedRef.current?.()}
|
|
className="h-full w-full object-contain"
|
|
x-webkit-airplay="allow"
|
|
/>
|
|
|
|
{/* Buffering spinner — shown until frames are actually rendering */}
|
|
{isBuffering && (
|
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
<Loader2 className="h-10 w-10 animate-spin text-zinc-500" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
VideoPlayer.displayName = "VideoPlayer";
|
|
|
|
export { VideoPlayer };
|
|
export type { VideoPlayerProps };
|