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

@@ -39,10 +39,9 @@ services:
build: build:
context: ./k-tv-frontend context: ./k-tv-frontend
args: args:
# Browser-visible backend URL — must be reachable from the user's browser. # Browser-visible backend URL — baked into the client bundle at build time.
# If running on a server: http://your-server-ip:3000/api/v1 # Rebuild the image after changing this.
# Baked into the client bundle at build time; rebuild after changing. NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000/api/v1}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000/api/v1}
ports: ports:
- "${FRONTEND_PORT:-3001}:3001" - "${FRONTEND_PORT:-3001}:3001"
environment: environment:

View File

@@ -15,8 +15,8 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# NEXT_PUBLIC_* vars are baked into the client bundle at build time. # NEXT_PUBLIC_* vars are baked into the client bundle at build time.
# Pass the public backend URL via --build-arg (see compose.yml). # Pass the public backend URL via --build-arg.
ARG NEXT_PUBLIC_API_URL=http://localhost:3000/api/v1 ARG NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1

View File

@@ -20,6 +20,8 @@ interface VideoPlayerProps {
onSubtitleTracksChange?: (tracks: SubtitleTrack[]) => void; onSubtitleTracksChange?: (tracks: SubtitleTrack[]) => void;
/** Called when the browser blocks autoplay and user interaction is required. */ /** Called when the browser blocks autoplay and user interaction is required. */
onNeedsInteraction?: () => void; onNeedsInteraction?: () => void;
/** Called when the video element fires its ended event. */
onEnded?: () => void;
} }
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>( const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
@@ -33,12 +35,15 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
onStreamError, onStreamError,
onSubtitleTracksChange, onSubtitleTracksChange,
onNeedsInteraction, onNeedsInteraction,
onEnded,
}, },
ref, ref,
) => { ) => {
const internalRef = useRef<HTMLVideoElement | null>(null); const internalRef = useRef<HTMLVideoElement | null>(null);
const hlsRef = useRef<Hls | null>(null); const hlsRef = useRef<Hls | null>(null);
const [isBuffering, setIsBuffering] = useState(true); const [isBuffering, setIsBuffering] = useState(true);
const onEndedRef = useRef(onEnded);
onEndedRef.current = onEnded;
const setRef = (el: HTMLVideoElement | null) => { const setRef = (el: HTMLVideoElement | null) => {
internalRef.current = el; internalRef.current = el;
@@ -126,6 +131,7 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
onPlaying={() => setIsBuffering(false)} onPlaying={() => setIsBuffering(false)}
onWaiting={() => setIsBuffering(true)} onWaiting={() => setIsBuffering(true)}
onError={onStreamError} onError={onStreamError}
onEnded={() => onEndedRef.current?.()}
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef, Suspense } from "react";
import { useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { import {
VideoPlayer, VideoPlayer,
@@ -37,22 +37,37 @@ const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this %
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function TvPage() { export default function TvPage() {
return (
<Suspense>
<TvPageContent />
</Suspense>
);
}
function TvPageContent() {
const { token } = useAuthContext(); const { token } = useAuthContext();
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// Channel list // Channel list
const { data: channels, isLoading: isLoadingChannels } = useChannels(); const { data: channels, isLoading: isLoadingChannels } = useChannels();
// Channel navigation — seed from ?channel=<id> query param if present // URL is the single source of truth for the active channel.
const [channelIdx, setChannelIdx] = useState(0); // channelIdx is derived — never stored in state.
useEffect(() => { const channelId = searchParams.get("channel");
const id = searchParams.get("channel"); const channelIdx = channels && channelId
if (!id || !channels) return; ? Math.max(0, channels.findIndex((c) => c.id === channelId))
const idx = channels.findIndex((c) => c.id === id); : 0;
if (idx !== -1) setChannelIdx(idx);
}, [channels, searchParams]);
const channel = channels?.[channelIdx]; 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 // Overlay / idle state
const [showOverlays, setShowOverlays] = useState(true); const [showOverlays, setShowOverlays] = useState(true);
const [showSchedule, setShowSchedule] = useState(false); const [showSchedule, setShowSchedule] = useState(false);
@@ -64,6 +79,9 @@ export default function TvPage() {
// Stream error recovery // Stream error recovery
const [streamError, setStreamError] = useState(false); 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 // Autoplay blocked by browser — cleared on first interaction via resetIdle
const [needsInteraction, setNeedsInteraction] = useState(false); const [needsInteraction, setNeedsInteraction] = useState(false);
@@ -129,9 +147,10 @@ export default function TvPage() {
const { data: epgSlots } = useEpg(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) // Clear transient states when a new slot is detected
useEffect(() => { useEffect(() => {
setStreamError(false); setStreamError(false);
setVideoEnded(false);
}, [broadcast?.slot.id]); }, [broadcast?.slot.id]);
// Reset subtitle state when channel or slot changes // Reset subtitle state when channel or slot changes
@@ -185,14 +204,14 @@ export default function TvPage() {
const channelCount = channels?.length ?? 0; const channelCount = channels?.length ?? 0;
const prevChannel = useCallback(() => { 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(); resetIdle();
}, [channelCount, resetIdle]); }, [channelIdx, channelCount, switchChannel, resetIdle]);
const nextChannel = useCallback(() => { const nextChannel = useCallback(() => {
setChannelIdx((i) => (i + 1) % Math.max(channelCount, 1)); switchChannel((channelIdx + 1) % Math.max(channelCount, 1));
resetIdle(); resetIdle();
}, [channelCount, resetIdle]); }, [channelIdx, channelCount, switchChannel, resetIdle]);
const toggleSchedule = useCallback(() => { const toggleSchedule = useCallback(() => {
setShowSchedule((s) => !s); setShowSchedule((s) => !s);
@@ -242,7 +261,7 @@ export default function TvPage() {
channelInputTimer.current = setTimeout(() => { channelInputTimer.current = setTimeout(() => {
const num = parseInt(next, 10); const num = parseInt(next, 10);
if (num >= 1 && num <= Math.max(channelCount, 1)) { if (num >= 1 && num <= Math.max(channelCount, 1)) {
setChannelIdx(num - 1); switchChannel(num - 1);
resetIdle(); resetIdle();
} }
setChannelInput(""); setChannelInput("");
@@ -259,7 +278,7 @@ export default function TvPage() {
window.removeEventListener("keydown", handleKey); window.removeEventListener("keydown", handleKey);
if (channelInputTimer.current) clearTimeout(channelInputTimer.current); 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) // Touch swipe (swipe up = next channel, swipe down = prev channel)
@@ -288,6 +307,12 @@ export default function TvPage() {
setStreamError(true); 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(() => { const handleRetry = useCallback(() => {
// Bust the cached stream URL so it refetches with the current offset // Bust the cached stream URL so it refetches with the current offset
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -335,6 +360,9 @@ export default function TvPage() {
</NoSignal> </NoSignal>
); );
} }
if (videoEnded) {
return <NoSignal variant="loading" message="Up next, stay tuned…" />;
}
if (streamUrl) { if (streamUrl) {
return ( return (
<VideoPlayer <VideoPlayer
@@ -345,6 +373,7 @@ export default function TvPage() {
subtitleTrack={activeSubtitleTrack} subtitleTrack={activeSubtitleTrack}
onSubtitleTracksChange={setSubtitleTracks} onSubtitleTracksChange={setSubtitleTracks}
onStreamError={handleStreamError} onStreamError={handleStreamError}
onEnded={handleVideoEnded}
onNeedsInteraction={() => setNeedsInteraction(true)} onNeedsInteraction={() => setNeedsInteraction(true)}
/> />
); );