145 lines
3.9 KiB
TypeScript
145 lines
3.9 KiB
TypeScript
// app/components/hls.tsx
|
|
"use client";
|
|
import React from "react";
|
|
import Hls from "hls.js";
|
|
|
|
type Props = {
|
|
src: string;
|
|
fallbackSrc?: string; // MP4 fallback URL
|
|
// optional props if you want
|
|
controls?: boolean;
|
|
autoPlay?: boolean;
|
|
videoId?: string; // Auto-load subtitles from /subtitles/{videoId}.vtt
|
|
subtitles?: Array<{
|
|
src: string;
|
|
kind?: "subtitles" | "captions" | "descriptions" | "chapters" | "metadata";
|
|
srclang?: string;
|
|
label?: string;
|
|
}>;
|
|
};
|
|
|
|
export const HlsPlayer = React.forwardRef<HTMLVideoElement, Props>(function HlsPlayer(
|
|
{ src, fallbackSrc, controls = true, autoPlay = false, videoId, subtitles = [] },
|
|
ref
|
|
) {
|
|
const internalRef = React.useRef<HTMLVideoElement | null>(null);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
// allow parent ref to point to the underlying video
|
|
React.useImperativeHandle(ref, () => internalRef.current || ({} as HTMLVideoElement), [internalRef.current]);
|
|
|
|
React.useEffect(() => {
|
|
const video = internalRef.current;
|
|
if (!video) return;
|
|
|
|
// avoid attaching multiple Hls instances if src didn't change
|
|
let hls: Hls | null = null;
|
|
let hasError = false;
|
|
|
|
const loadHls = (sourceUrl: string) => {
|
|
// Check if it's an HLS URL
|
|
if (sourceUrl.endsWith('.m3u8')) {
|
|
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
|
// native HLS (Safari)
|
|
video.src = sourceUrl;
|
|
} else if (Hls.isSupported()) {
|
|
hls = new Hls();
|
|
|
|
// Handle HLS errors with fallback
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
console.error('HLS Error:', event, data);
|
|
if (data.fatal) {
|
|
hasError = true;
|
|
// Try fallback if available
|
|
if (fallbackSrc) {
|
|
console.log('Falling back to MP4:', fallbackSrc);
|
|
setError(null);
|
|
loadMp4(fallbackSrc);
|
|
} else {
|
|
setError('Failed to load HLS stream');
|
|
}
|
|
}
|
|
});
|
|
|
|
hls.loadSource(sourceUrl);
|
|
hls.attachMedia(video);
|
|
} else {
|
|
// HLS not supported, try fallback
|
|
if (fallbackSrc) {
|
|
console.log('HLS not supported, using MP4 fallback');
|
|
loadMp4(fallbackSrc);
|
|
} else {
|
|
setError('HLS streaming not supported on this device');
|
|
// Try to load as MP4 anyway
|
|
video.src = sourceUrl;
|
|
}
|
|
}
|
|
} else {
|
|
// Non-HLS URL, load as MP4
|
|
loadMp4(sourceUrl);
|
|
}
|
|
};
|
|
|
|
const loadMp4 = (sourceUrl: string) => {
|
|
if (hls) {
|
|
hls.destroy();
|
|
hls = null;
|
|
}
|
|
video.src = sourceUrl;
|
|
};
|
|
|
|
loadHls(src);
|
|
|
|
// cleanup
|
|
return () => {
|
|
if (hls) {
|
|
hls.destroy();
|
|
hls = null;
|
|
}
|
|
// optionally pause and clear src
|
|
if (video) {
|
|
try { video.pause(); } catch {}
|
|
// video.src = "";
|
|
}
|
|
};
|
|
}, [src, fallbackSrc]);
|
|
|
|
return (
|
|
<>
|
|
<video
|
|
ref={internalRef}
|
|
controls={controls}
|
|
autoPlay={autoPlay}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
controlsList="nodownload"
|
|
className="w-full"
|
|
>
|
|
{videoId && (
|
|
<track
|
|
src={`/subtitles/${videoId}.vtt`}
|
|
kind="subtitles"
|
|
srcLang="en"
|
|
label="Subtitles"
|
|
default
|
|
/>
|
|
)}
|
|
{subtitles.map((subtitle, index) => (
|
|
<track
|
|
key={index}
|
|
src={subtitle.src}
|
|
kind={subtitle.kind || "subtitles"}
|
|
srcLang={subtitle.srclang || "en"}
|
|
label={subtitle.label || `Subtitle ${index + 1}`}
|
|
default={!videoId && index === 0}
|
|
/>
|
|
))}
|
|
</video>
|
|
{error && (
|
|
<div className="text-red-500 text-sm mt-2">{error}</div>
|
|
)}
|
|
</>
|
|
);
|
|
});
|
|
|
|
export default HlsPlayer;
|