diff --git a/k-tv-backend/infra/src/jellyfin.rs b/k-tv-backend/infra/src/jellyfin.rs index 52a4c47..3e1f005 100644 --- a/k-tv-backend/infra/src/jellyfin.rs +++ b/k-tv-backend/infra/src/jellyfin.rs @@ -158,10 +158,14 @@ impl IMediaProvider for JellyfinMediaProvider { /// Requests H.264 video + AAC audio in an MP4 container so that all /// major browsers can play it natively. Jellyfin will direct-play if the /// source already matches; otherwise it transcodes on the fly. + /// A high `VideoBitRate` (40 Mbps) preserves quality on local networks. /// The API key is embedded in the URL so the player needs no separate auth. + /// + /// Note: the caller (stream proxy route) may append `StartTimeTicks` to + /// seek to the correct broadcast offset before returning the URL to the client. async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult { Ok(format!( - "{}/Videos/{}/stream?videoCodec=h264&audioCodec=aac&container=mp4&api_key={}", + "{}/Videos/{}/stream?videoCodec=h264&audioCodec=aac&container=mp4&VideoBitRate=40000000&api_key={}", self.config.base_url, item_id.as_ref(), self.config.api_key, diff --git a/k-tv-frontend/app/(main)/tv/components/index.ts b/k-tv-frontend/app/(main)/tv/components/index.ts index 9644f29..56d0450 100644 --- a/k-tv-frontend/app/(main)/tv/components/index.ts +++ b/k-tv-frontend/app/(main)/tv/components/index.ts @@ -1,6 +1,7 @@ export { VideoPlayer } from "./video-player"; export type { VideoPlayerProps } from "./video-player"; + export { ChannelInfo } from "./channel-info"; export type { ChannelInfoProps } from "./channel-info"; 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 10536ad..bcf60b6 100644 --- a/k-tv-frontend/app/(main)/tv/components/video-player.tsx +++ b/k-tv-frontend/app/(main)/tv/components/video-player.tsx @@ -4,10 +4,12 @@ interface VideoPlayerProps { src?: string; poster?: string; className?: string; + /** Seek to this many seconds after metadata loads (broadcast sync on refresh). */ + initialOffset?: number; } const VideoPlayer = forwardRef( - ({ src, poster, className }, ref) => { + ({ src, poster, className, initialOffset }, ref) => { return (
diff --git a/k-tv-frontend/app/(main)/tv/page.tsx b/k-tv-frontend/app/(main)/tv/page.tsx index 85ea91e..6854587 100644 --- a/k-tv-frontend/app/(main)/tv/page.tsx +++ b/k-tv-frontend/app/(main)/tv/page.tsx @@ -57,7 +57,11 @@ export default function TvPage() { const { data: broadcast, isLoading: isLoadingBroadcast } = useCurrentBroadcast(channel?.id ?? ""); const { data: epgSlots } = useEpg(channel?.id ?? ""); - const { data: streamUrl } = useStreamUrl(channel?.id, token); + const { data: streamUrl } = useStreamUrl( + channel?.id, + token, + broadcast?.slot.id, + ); // ------------------------------------------------------------------ // Derived display values @@ -176,7 +180,11 @@ export default function TvPage() { } if (streamUrl) { return ( - + ); } // Broadcast exists but stream URL resolving — show no-signal until ready diff --git a/k-tv-frontend/hooks/use-tv.ts b/k-tv-frontend/hooks/use-tv.ts index 9bfca69..7a2d881 100644 --- a/k-tv-frontend/hooks/use-tv.ts +++ b/k-tv-frontend/hooks/use-tv.ts @@ -65,30 +65,44 @@ export function findNextSlot( // --------------------------------------------------------------------------- /** - * Resolves the live stream URL for a channel. + * Resolves the live stream URL for a channel, starting at the correct + * broadcast offset so refresh doesn't replay from the beginning. * * The backend's GET /channels/:id/stream endpoint returns a 307 redirect to * the Jellyfin stream URL. Since browsers can't read redirect Location headers * from fetch(), we proxy through /api/stream/[channelId] (a Next.js route that * runs server-side) and return the final URL as JSON. * + * slotId is included in the query key so the URL is re-fetched automatically + * when the current item changes (the next scheduled item starts playing). + * Within the same slot, the URL stays stable — no mid-item restarts. + * * Returns null when the channel is in a gap (no-signal / 204). */ -export function useStreamUrl(channelId: string | undefined, token: string | null) { +/** + * slotId is in the query key so the URL refetches when the playing item + * changes (new slot starts), but stays stable while the same item runs — + * no mid-item restarts. + */ +export function useStreamUrl( + channelId: string | undefined, + token: string | null, + slotId: string | undefined, +) { return useQuery({ - queryKey: ["stream-url", channelId], + queryKey: ["stream-url", channelId, slotId], queryFn: async (): Promise => { - const res = await fetch( - `/api/stream/${channelId}?token=${encodeURIComponent(token!)}`, - { cache: "no-store" }, - ); + const params = new URLSearchParams({ token: token! }); + const res = await fetch(`/api/stream/${channelId}?${params}`, { + cache: "no-store", + }); if (res.status === 204) return null; if (!res.ok) throw new Error(`Stream resolve failed: ${res.status}`); const { url } = await res.json(); return url as string; }, - enabled: !!channelId && !!token, - refetchInterval: 30_000, + enabled: !!channelId && !!token && !!slotId, + staleTime: Infinity, retry: false, }); }