From b81359405974eb072311eb13e336ac750131ed9e Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 11 Mar 2026 20:51:06 +0100 Subject: [PATCH] feat: implement HLS streaming support in VideoPlayer and enhance stream URL handling --- k-tv-backend/infra/src/jellyfin.rs | 25 +++--- .../app/(main)/tv/components/no-signal.tsx | 5 +- .../app/(main)/tv/components/video-player.tsx | 81 ++++++++++++++++--- k-tv-frontend/app/(main)/tv/page.tsx | 55 +++++++++++-- k-tv-frontend/bun.lock | 16 ++++ k-tv-frontend/hooks/use-tv.ts | 10 ++- k-tv-frontend/package.json | 2 + 7 files changed, 161 insertions(+), 33 deletions(-) diff --git a/k-tv-backend/infra/src/jellyfin.rs b/k-tv-backend/infra/src/jellyfin.rs index 3e1f005..8846e87 100644 --- a/k-tv-backend/infra/src/jellyfin.rs +++ b/k-tv-backend/infra/src/jellyfin.rs @@ -42,7 +42,10 @@ impl JellyfinMediaProvider { pub fn new(config: JellyfinConfig) -> Self { Self { client: reqwest::Client::new(), - config, + config: JellyfinConfig { + base_url: config.base_url.trim_end_matches('/').to_string(), + ..config + }, } } } @@ -153,21 +156,23 @@ impl IMediaProvider for JellyfinMediaProvider { Ok(body.items.into_iter().next().and_then(map_jellyfin_item)) } - /// Build a stream URL for a Jellyfin item. + /// Build an HLS stream URL for a Jellyfin item. /// - /// 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. + /// Returns a `master.m3u8` playlist URL. Jellyfin transcodes to H.264/AAC + /// segments on the fly. HLS is preferred over a single MP4 stream because + /// `StartTimeTicks` works reliably with HLS — each segment is independent, + /// so Jellyfin can begin the playlist at the correct broadcast offset + /// without needing to byte-range seek into an in-progress transcode. /// - /// Note: the caller (stream proxy route) may append `StartTimeTicks` to - /// seek to the correct broadcast offset before returning the URL to the client. + /// The API key is embedded so the player needs no separate auth header. + /// The caller (stream proxy route) appends `StartTimeTicks` when there is + /// a non-zero broadcast offset. async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult { Ok(format!( - "{}/Videos/{}/stream?videoCodec=h264&audioCodec=aac&container=mp4&VideoBitRate=40000000&api_key={}", + "{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&VideoBitRate=40000000&mediaSourceId={}&api_key={}", self.config.base_url, item_id.as_ref(), + item_id.as_ref(), self.config.api_key, )) } diff --git a/k-tv-frontend/app/(main)/tv/components/no-signal.tsx b/k-tv-frontend/app/(main)/tv/components/no-signal.tsx index 0078ff5..78bdbff 100644 --- a/k-tv-frontend/app/(main)/tv/components/no-signal.tsx +++ b/k-tv-frontend/app/(main)/tv/components/no-signal.tsx @@ -5,6 +5,7 @@ type NoSignalVariant = "no-signal" | "error" | "loading"; interface NoSignalProps { variant?: NoSignalVariant; message?: string; + children?: React.ReactNode; } const VARIANTS: Record< @@ -28,7 +29,7 @@ const VARIANTS: Record< }, }; -export function NoSignal({ variant = "no-signal", message }: NoSignalProps) { +export function NoSignal({ variant = "no-signal", message, children }: NoSignalProps) { const { icon, heading, defaultMessage } = VARIANTS[variant]; return ( @@ -52,6 +53,8 @@ export function NoSignal({ variant = "no-signal", message }: NoSignalProps) {

{message ?? defaultMessage}

+ + {children} ); } 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 bcf60b6..71a2695 100644 --- a/k-tv-frontend/app/(main)/tv/components/video-player.tsx +++ b/k-tv-frontend/app/(main)/tv/components/video-player.tsx @@ -1,33 +1,90 @@ -import { forwardRef } from "react"; +import { forwardRef, useEffect, useRef } from "react"; +import Hls from "hls.js"; interface VideoPlayerProps { src?: string; poster?: string; className?: string; - /** Seek to this many seconds after metadata loads (broadcast sync on refresh). */ + /** Seconds into the current item to seek on load (broadcast sync). */ initialOffset?: number; + onStreamError?: () => void; } const VideoPlayer = forwardRef( - ({ src, poster, className, initialOffset }, ref) => { + ({ src, poster, className, initialOffset = 0, onStreamError }, ref) => { + const internalRef = useRef(null); + const hlsRef = useRef(null); + + const setRef = (el: HTMLVideoElement | null) => { + internalRef.current = el; + if (typeof ref === "function") ref(el); + else if (ref) ref.current = el; + }; + + useEffect(() => { + const video = internalRef.current; + if (!video || !src) return; + + hlsRef.current?.destroy(); + hlsRef.current = null; + + const isHls = src.includes(".m3u8"); + + if (isHls && Hls.isSupported()) { + const hls = new Hls({ + startPosition: initialOffset > 0 ? initialOffset : -1, + maxMaxBufferLength: 30, + }); + hlsRef.current = hls; + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + video.play().catch(() => {}); + }); + + hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.fatal) onStreamError?.(); + }); + + hls.loadSource(src); + hls.attachMedia(video); + } else if (isHls && video.canPlayType("application/vnd.apple.mpegurl")) { + // Native HLS (Safari) + video.src = src; + video.addEventListener( + "loadedmetadata", + () => { + if (initialOffset > 0) video.currentTime = initialOffset; + video.play().catch(() => {}); + }, + { once: true }, + ); + } else { + // Plain MP4 fallback + video.src = src; + video.load(); + video.play().catch(() => {}); + } + + return () => { + hlsRef.current?.destroy(); + hlsRef.current = null; + }; + // initialOffset intentionally excluded: only seek when src changes (new slot/mount) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [src]); + return (
); - } + }, ); VideoPlayer.displayName = "VideoPlayer"; diff --git a/k-tv-frontend/app/(main)/tv/page.tsx b/k-tv-frontend/app/(main)/tv/page.tsx index 6854587..c800df1 100644 --- a/k-tv-frontend/app/(main)/tv/page.tsx +++ b/k-tv-frontend/app/(main)/tv/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { VideoPlayer, ChannelInfo, @@ -46,6 +47,13 @@ export default function TvPage() { const [showSchedule, setShowSchedule] = useState(false); const idleTimer = useRef | null>(null); + // Video ref — used to resume playback if autoplay was blocked on load + const videoRef = useRef(null); + + // Stream error recovery + const [streamError, setStreamError] = useState(false); + const queryClient = useQueryClient(); + // Tick for live progress calculation (every 30 s is fine for the progress bar) const [, setTick] = useState(0); useEffect(() => { @@ -57,11 +65,12 @@ 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, - broadcast?.slot.id, - ); + const { data: streamUrl } = useStreamUrl(channel?.id, token, broadcast?.slot.id); + + // Clear stream error when the slot changes (next item started) + useEffect(() => { + setStreamError(false); + }, [broadcast?.slot.id]); // ------------------------------------------------------------------ // Derived display values @@ -87,6 +96,8 @@ export default function TvPage() { () => setShowOverlays(false), IDLE_TIMEOUT_MS, ); + // Resume playback if autoplay was blocked (e.g. on page refresh with no prior interaction) + videoRef.current?.play().catch(() => {}); }, []); useEffect(() => { @@ -151,6 +162,22 @@ export default function TvPage() { return () => window.removeEventListener("keydown", handleKey); }, [nextChannel, prevChannel, toggleSchedule]); + // ------------------------------------------------------------------ + // Stream error recovery + // ------------------------------------------------------------------ + + const handleStreamError = useCallback(() => { + setStreamError(true); + }, []); + + const handleRetry = useCallback(() => { + // Bust the cached stream URL so it refetches with the current offset + queryClient.invalidateQueries({ + queryKey: ["stream-url", channel?.id, broadcast?.slot.id], + }); + setStreamError(false); + }, [queryClient, channel?.id, broadcast?.slot.id]); + // ------------------------------------------------------------------ // Render helpers // ------------------------------------------------------------------ @@ -178,16 +205,30 @@ export default function TvPage() { /> ); } + if (streamError) { + return ( + + + + ); + } if (streamUrl) { return ( ); } - // Broadcast exists but stream URL resolving — show no-signal until ready + // Broadcast exists but stream URL resolving — show loading until ready return ; }; diff --git a/k-tv-frontend/bun.lock b/k-tv-frontend/bun.lock index 6dd4812..359da8a 100644 --- a/k-tv-frontend/bun.lock +++ b/k-tv-frontend/bun.lock @@ -5,11 +5,14 @@ "name": "k-tv-frontend", "dependencies": { "@base-ui/react": "^1.2.0", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query-devtools": "^5.91.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "hls.js": "^1.6.15", "input-otp": "^1.4.2", "lucide-react": "^0.577.0", "next": "16.1.6", @@ -28,6 +31,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/hls.js": "^1.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -433,6 +437,14 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.93.0", "", {}, "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.3", "", { "dependencies": { "@tanstack/query-devtools": "5.93.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA=="], + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -457,6 +469,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/hls.js": ["@types/hls.js@1.0.0", "", { "dependencies": { "hls.js": "*" } }, "sha512-EGY2QJefX+Z9XH4PAxI7RFoNqBlQEk16UpYR3kbr82CIgMX5SlMe0PjFdFV0JytRhyVPQCiwSyONuI6S1KdSag=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], @@ -925,6 +939,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="], + "hono": ["hono@4.12.7", "", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], diff --git a/k-tv-frontend/hooks/use-tv.ts b/k-tv-frontend/hooks/use-tv.ts index 7a2d881..63c95d1 100644 --- a/k-tv-frontend/hooks/use-tv.ts +++ b/k-tv-frontend/hooks/use-tv.ts @@ -80,9 +80,13 @@ export function findNextSlot( * Returns null when the channel is in a gap (no-signal / 204). */ /** - * 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. + * Resolves the stream URL for the current slot, with StartTimeTicks set so + * Jellyfin begins transcoding at the correct broadcast offset. + * + * slotId is in the query key: the URL refetches when the item changes (new + * slot), but stays stable while the same slot is playing — no mid-item + * restarts. offsetSecs is captured once when the query first runs for a + * given slot, so 30-second broadcast refetches don't disturb playback. */ export function useStreamUrl( channelId: string | undefined, diff --git a/k-tv-frontend/package.json b/k-tv-frontend/package.json index b04c2e7..6ba14f7 100644 --- a/k-tv-frontend/package.json +++ b/k-tv-frontend/package.json @@ -17,6 +17,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "hls.js": "^1.6.15", "input-otp": "^1.4.2", "lucide-react": "^0.577.0", "next": "16.1.6", @@ -35,6 +36,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/hls.js": "^1.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19",