feat: implement HLS streaming support in VideoPlayer and enhance stream URL handling

This commit is contained in:
2026-03-11 20:51:06 +01:00
parent 4789dca679
commit b813594059
7 changed files with 161 additions and 33 deletions

View File

@@ -5,6 +5,7 @@ type NoSignalVariant = "no-signal" | "error" | "loading";
interface NoSignalProps {
variant?: NoSignalVariant;
message?: string;
children?: React.ReactNode;
}
const VARIANTS: Record<
@@ -28,7 +29,7 @@ const VARIANTS: Record<
},
};
export function NoSignal({ variant = "no-signal", message }: NoSignalProps) {
export function NoSignal({ variant = "no-signal", message, children }: NoSignalProps) {
const { icon, heading, defaultMessage } = VARIANTS[variant];
return (
@@ -52,6 +53,8 @@ export function NoSignal({ variant = "no-signal", message }: NoSignalProps) {
</p>
<p className="max-w-xs text-xs text-zinc-700">{message ?? defaultMessage}</p>
</div>
{children}
</div>
);
}

View File

@@ -1,33 +1,90 @@
import { forwardRef } from "react";
import { forwardRef, useEffect, useRef } from "react";
import Hls from "hls.js";
interface VideoPlayerProps {
src?: string;
poster?: string;
className?: string;
/** Seek to this many seconds after metadata loads (broadcast sync on refresh). */
/** Seconds into the current item to seek on load (broadcast sync). */
initialOffset?: number;
onStreamError?: () => void;
}
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
({ src, poster, className, initialOffset }, ref) => {
({ src, poster, className, initialOffset = 0, onStreamError }, ref) => {
const internalRef = useRef<HTMLVideoElement | null>(null);
const hlsRef = useRef<Hls | null>(null);
const setRef = (el: HTMLVideoElement | null) => {
internalRef.current = el;
if (typeof ref === "function") ref(el);
else if (ref) ref.current = el;
};
useEffect(() => {
const video = internalRef.current;
if (!video || !src) return;
hlsRef.current?.destroy();
hlsRef.current = null;
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.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={ref}
src={src}
ref={setRef}
poster={poster}
autoPlay
playsInline
onLoadedMetadata={(e) => {
if (initialOffset && initialOffset > 0) {
e.currentTarget.currentTime = initialOffset;
}
}}
onError={onStreamError}
className="h-full w-full object-contain"
/>
</div>
);
}
},
);
VideoPlayer.displayName = "VideoPlayer";