feat(tv): update video player to handle ended event and improve channel navigation
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user