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