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 (
{
- if (initialOffset && initialOffset > 0) {
- e.currentTarget.currentTime = initialOffset;
- }
- }}
+ onError={onStreamError}
className="h-full w-full object-contain"
/>
);
- }
+ },
);
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 (
+
+
+ Retry
+
+
+ );
+ }
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",