feat(tv): update video player to handle ended event and improve channel navigation
This commit is contained in:
@@ -15,8 +15,8 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# NEXT_PUBLIC_* vars are baked into the client bundle at build time.
|
||||
# Pass the public backend URL via --build-arg (see compose.yml).
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:3000/api/v1
|
||||
# Pass the public backend URL via --build-arg.
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ interface VideoPlayerProps {
|
||||
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>(
|
||||
@@ -33,12 +35,15 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
||||
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 setRef = (el: HTMLVideoElement | null) => {
|
||||
internalRef.current = el;
|
||||
@@ -126,6 +131,7 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
||||
onPlaying={() => setIsBuffering(false)}
|
||||
onWaiting={() => setIsBuffering(true)}
|
||||
onError={onStreamError}
|
||||
onEnded={() => onEndedRef.current?.()}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect, useCallback, useRef, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
VideoPlayer,
|
||||
@@ -37,22 +37,37 @@ const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this %
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function TvPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<TvPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function TvPageContent() {
|
||||
const { token } = useAuthContext();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Channel list
|
||||
const { data: channels, isLoading: isLoadingChannels } = useChannels();
|
||||
|
||||
// Channel navigation — seed from ?channel=<id> query param if present
|
||||
const [channelIdx, setChannelIdx] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = searchParams.get("channel");
|
||||
if (!id || !channels) return;
|
||||
const idx = channels.findIndex((c) => c.id === id);
|
||||
if (idx !== -1) setChannelIdx(idx);
|
||||
}, [channels, searchParams]);
|
||||
// URL is the single source of truth for the active channel.
|
||||
// channelIdx is derived — never stored in state.
|
||||
const channelId = searchParams.get("channel");
|
||||
const channelIdx = channels && channelId
|
||||
? Math.max(0, channels.findIndex((c) => c.id === channelId))
|
||||
: 0;
|
||||
const channel = channels?.[channelIdx];
|
||||
|
||||
// Write a channel switch back to the URL so keyboard, buttons, and
|
||||
// guide links all stay in sync and the page is bookmarkable/refreshable.
|
||||
const switchChannel = useCallback((idx: number, list = channels) => {
|
||||
const target = list?.[idx];
|
||||
if (!target) return;
|
||||
router.replace(`/tv?channel=${target.id}`, { scroll: false });
|
||||
}, [channels, router]);
|
||||
|
||||
// Overlay / idle state
|
||||
const [showOverlays, setShowOverlays] = useState(true);
|
||||
const [showSchedule, setShowSchedule] = useState(false);
|
||||
@@ -64,6 +79,9 @@ export default function TvPage() {
|
||||
// Stream error recovery
|
||||
const [streamError, setStreamError] = useState(false);
|
||||
|
||||
// When the video ends, show no-signal until the next broadcast is detected.
|
||||
const [videoEnded, setVideoEnded] = useState(false);
|
||||
|
||||
// Autoplay blocked by browser — cleared on first interaction via resetIdle
|
||||
const [needsInteraction, setNeedsInteraction] = useState(false);
|
||||
|
||||
@@ -129,9 +147,10 @@ export default function TvPage() {
|
||||
const { data: epgSlots } = useEpg(channel?.id ?? "");
|
||||
const { data: streamUrl } = useStreamUrl(channel?.id, token, broadcast?.slot.id);
|
||||
|
||||
// Clear stream error when the slot changes (next item started)
|
||||
// Clear transient states when a new slot is detected
|
||||
useEffect(() => {
|
||||
setStreamError(false);
|
||||
setVideoEnded(false);
|
||||
}, [broadcast?.slot.id]);
|
||||
|
||||
// Reset subtitle state when channel or slot changes
|
||||
@@ -185,14 +204,14 @@ export default function TvPage() {
|
||||
const channelCount = channels?.length ?? 0;
|
||||
|
||||
const prevChannel = useCallback(() => {
|
||||
setChannelIdx((i) => (i - 1 + Math.max(channelCount, 1)) % Math.max(channelCount, 1));
|
||||
switchChannel((channelIdx - 1 + Math.max(channelCount, 1)) % Math.max(channelCount, 1));
|
||||
resetIdle();
|
||||
}, [channelCount, resetIdle]);
|
||||
}, [channelIdx, channelCount, switchChannel, resetIdle]);
|
||||
|
||||
const nextChannel = useCallback(() => {
|
||||
setChannelIdx((i) => (i + 1) % Math.max(channelCount, 1));
|
||||
switchChannel((channelIdx + 1) % Math.max(channelCount, 1));
|
||||
resetIdle();
|
||||
}, [channelCount, resetIdle]);
|
||||
}, [channelIdx, channelCount, switchChannel, resetIdle]);
|
||||
|
||||
const toggleSchedule = useCallback(() => {
|
||||
setShowSchedule((s) => !s);
|
||||
@@ -242,7 +261,7 @@ export default function TvPage() {
|
||||
channelInputTimer.current = setTimeout(() => {
|
||||
const num = parseInt(next, 10);
|
||||
if (num >= 1 && num <= Math.max(channelCount, 1)) {
|
||||
setChannelIdx(num - 1);
|
||||
switchChannel(num - 1);
|
||||
resetIdle();
|
||||
}
|
||||
setChannelInput("");
|
||||
@@ -259,7 +278,7 @@ export default function TvPage() {
|
||||
window.removeEventListener("keydown", handleKey);
|
||||
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
|
||||
};
|
||||
}, [nextChannel, prevChannel, toggleSchedule, toggleFullscreen, toggleMute, channelCount, resetIdle]);
|
||||
}, [nextChannel, prevChannel, toggleSchedule, toggleFullscreen, toggleMute, channelCount, switchChannel, resetIdle]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Touch swipe (swipe up = next channel, swipe down = prev channel)
|
||||
@@ -288,6 +307,12 @@ export default function TvPage() {
|
||||
setStreamError(true);
|
||||
}, []);
|
||||
|
||||
const handleVideoEnded = useCallback(() => {
|
||||
setVideoEnded(true);
|
||||
// Immediately poll for the next broadcast instead of waiting up to 30 s.
|
||||
queryClient.invalidateQueries({ queryKey: ["broadcast", channel?.id] });
|
||||
}, [queryClient, channel?.id]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
// Bust the cached stream URL so it refetches with the current offset
|
||||
queryClient.invalidateQueries({
|
||||
@@ -335,6 +360,9 @@ export default function TvPage() {
|
||||
</NoSignal>
|
||||
);
|
||||
}
|
||||
if (videoEnded) {
|
||||
return <NoSignal variant="loading" message="Up next, stay tuned…" />;
|
||||
}
|
||||
if (streamUrl) {
|
||||
return (
|
||||
<VideoPlayer
|
||||
@@ -345,6 +373,7 @@ export default function TvPage() {
|
||||
subtitleTrack={activeSubtitleTrack}
|
||||
onSubtitleTracksChange={setSubtitleTracks}
|
||||
onStreamError={handleStreamError}
|
||||
onEnded={handleVideoEnded}
|
||||
onNeedsInteraction={() => setNeedsInteraction(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user