Files
k-tv/k-tv-frontend/app/(main)/tv/components/video-player.tsx

144 lines
3.9 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;
onStreamError?: () => void;
onSubtitleTracksChange?: (tracks: SubtitleTrack[]) => void;
}
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
(
{
src,
poster,
className,
initialOffset = 0,
subtitleTrack = -1,
onStreamError,
onSubtitleTracksChange,
},
ref,
) => {
const internalRef = useRef<HTMLVideoElement | null>(null);
const hlsRef = useRef<Hls | null>(null);
const [isBuffering, setIsBuffering] = useState(true);
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(() => {});
});
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(() => {});
},
{ 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}
className="h-full w-full object-contain"
/>
{/* 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 };