feat: implement HLS streaming support in VideoPlayer and enhance stream URL handling
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
VideoPlayer,
|
||||
ChannelInfo,
|
||||
@@ -46,6 +47,13 @@ export default function TvPage() {
|
||||
const [showSchedule, setShowSchedule] = useState(false);
|
||||
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Video ref — used to resume playback if autoplay was blocked on load
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Stream error recovery
|
||||
const [streamError, setStreamError] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Tick for live progress calculation (every 30 s is fine for the progress bar)
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
@@ -57,11 +65,12 @@ export default function TvPage() {
|
||||
const { data: broadcast, isLoading: isLoadingBroadcast } =
|
||||
useCurrentBroadcast(channel?.id ?? "");
|
||||
const { data: epgSlots } = useEpg(channel?.id ?? "");
|
||||
const { data: streamUrl } = useStreamUrl(
|
||||
channel?.id,
|
||||
token,
|
||||
broadcast?.slot.id,
|
||||
);
|
||||
const { data: streamUrl } = useStreamUrl(channel?.id, token, broadcast?.slot.id);
|
||||
|
||||
// Clear stream error when the slot changes (next item started)
|
||||
useEffect(() => {
|
||||
setStreamError(false);
|
||||
}, [broadcast?.slot.id]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Derived display values
|
||||
@@ -87,6 +96,8 @@ export default function TvPage() {
|
||||
() => setShowOverlays(false),
|
||||
IDLE_TIMEOUT_MS,
|
||||
);
|
||||
// Resume playback if autoplay was blocked (e.g. on page refresh with no prior interaction)
|
||||
videoRef.current?.play().catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -151,6 +162,22 @@ export default function TvPage() {
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [nextChannel, prevChannel, toggleSchedule]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Stream error recovery
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const handleStreamError = useCallback(() => {
|
||||
setStreamError(true);
|
||||
}, []);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
// Bust the cached stream URL so it refetches with the current offset
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["stream-url", channel?.id, broadcast?.slot.id],
|
||||
});
|
||||
setStreamError(false);
|
||||
}, [queryClient, channel?.id, broadcast?.slot.id]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Render helpers
|
||||
// ------------------------------------------------------------------
|
||||
@@ -178,16 +205,30 @@ export default function TvPage() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (streamError) {
|
||||
return (
|
||||
<NoSignal variant="error" message="Stream failed to load.">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="mt-2 rounded-md border border-zinc-700 bg-zinc-800/80 px-4 py-2 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</NoSignal>
|
||||
);
|
||||
}
|
||||
if (streamUrl) {
|
||||
return (
|
||||
<VideoPlayer
|
||||
ref={videoRef}
|
||||
src={streamUrl}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
initialOffset={broadcast?.offset_secs}
|
||||
initialOffset={broadcast?.offset_secs ?? 0}
|
||||
onStreamError={handleStreamError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Broadcast exists but stream URL resolving — show no-signal until ready
|
||||
// Broadcast exists but stream URL resolving — show loading until ready
|
||||
return <NoSignal variant="loading" message="Loading stream…" />;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user