Files
k-tv/k-tv-frontend/app/(main)/tv/components/video-player.tsx
Gabriel Kaszewski 8f42164bce feat: add local files provider with indexing and rescan functionality
- Implemented LocalFilesProvider to manage local video files.
- Added LocalIndex for in-memory and SQLite-backed indexing of video files.
- Introduced scanning functionality to detect video files and extract metadata.
- Added API endpoints for listing collections, genres, and series based on provider capabilities.
- Enhanced existing routes to check for provider capabilities before processing requests.
- Updated frontend to utilize provider capabilities for conditional rendering of UI elements.
- Implemented rescan functionality to refresh the local files index.
- Added database migration for local files index schema.
2026-03-14 03:44:32 +01:00

174 lines
5.1 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;
/** Force direct-file mode (skips hls.js even for .m3u8 URLs). */
streamingProtocol?: "hls" | "direct_file";
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,
streamingProtocol,
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 = 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 (
<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 };