feat(tv): update video player to handle ended event and improve channel navigation

This commit is contained in:
2026-03-12 04:19:56 +01:00
parent 8754758254
commit 79ced7b77b
4 changed files with 57 additions and 23 deletions

View File

@@ -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)}
/>
);