diff --git a/compose.yml b/compose.yml index c7f4570..f3948c8 100644 --- a/compose.yml +++ b/compose.yml @@ -39,10 +39,9 @@ services: build: context: ./k-tv-frontend args: - # Browser-visible backend URL — must be reachable from the user's browser. - # If running on a server: http://your-server-ip:3000/api/v1 - # Baked into the client bundle at build time; rebuild after changing. - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000/api/v1} + # Browser-visible backend URL — baked into the client bundle at build time. + # Rebuild the image after changing this. + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000/api/v1} ports: - "${FRONTEND_PORT:-3001}:3001" environment: diff --git a/k-tv-frontend/Dockerfile b/k-tv-frontend/Dockerfile index a40e5bb..f845658 100644 --- a/k-tv-frontend/Dockerfile +++ b/k-tv-frontend/Dockerfile @@ -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 diff --git a/k-tv-frontend/app/(main)/tv/components/video-player.tsx b/k-tv-frontend/app/(main)/tv/components/video-player.tsx index 89b2791..93d6f1a 100644 --- a/k-tv-frontend/app/(main)/tv/components/video-player.tsx +++ b/k-tv-frontend/app/(main)/tv/components/video-player.tsx @@ -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( @@ -33,12 +35,15 @@ const VideoPlayer = forwardRef( onStreamError, onSubtitleTracksChange, onNeedsInteraction, + onEnded, }, ref, ) => { const internalRef = useRef(null); const hlsRef = useRef(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( onPlaying={() => setIsBuffering(false)} onWaiting={() => setIsBuffering(true)} onError={onStreamError} + onEnded={() => onEndedRef.current?.()} className="h-full w-full object-contain" /> diff --git a/k-tv-frontend/app/(main)/tv/page.tsx b/k-tv-frontend/app/(main)/tv/page.tsx index 8243ed8..a561d29 100644 --- a/k-tv-frontend/app/(main)/tv/page.tsx +++ b/k-tv-frontend/app/(main)/tv/page.tsx @@ -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 ( + + + + ); +} + +function TvPageContent() { const { token } = useAuthContext(); + const router = useRouter(); const searchParams = useSearchParams(); // Channel list const { data: channels, isLoading: isLoadingChannels } = useChannels(); - // Channel navigation — seed from ?channel= 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() { ); } + if (videoEnded) { + return ; + } if (streamUrl) { return ( setNeedsInteraction(true)} /> );