feat: implement HLS streaming support in VideoPlayer and enhance stream URL handling
This commit is contained in:
@@ -42,7 +42,10 @@ impl JellyfinMediaProvider {
|
|||||||
pub fn new(config: JellyfinConfig) -> Self {
|
pub fn new(config: JellyfinConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::new(),
|
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))
|
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
|
/// Returns a `master.m3u8` playlist URL. Jellyfin transcodes to H.264/AAC
|
||||||
/// major browsers can play it natively. Jellyfin will direct-play if the
|
/// segments on the fly. HLS is preferred over a single MP4 stream because
|
||||||
/// source already matches; otherwise it transcodes on the fly.
|
/// `StartTimeTicks` works reliably with HLS — each segment is independent,
|
||||||
/// A high `VideoBitRate` (40 Mbps) preserves quality on local networks.
|
/// so Jellyfin can begin the playlist at the correct broadcast offset
|
||||||
/// The API key is embedded in the URL so the player needs no separate auth.
|
/// without needing to byte-range seek into an in-progress transcode.
|
||||||
///
|
///
|
||||||
/// Note: the caller (stream proxy route) may append `StartTimeTicks` to
|
/// The API key is embedded so the player needs no separate auth header.
|
||||||
/// seek to the correct broadcast offset before returning the URL to the client.
|
/// 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<String> {
|
async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String> {
|
||||||
Ok(format!(
|
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,
|
self.config.base_url,
|
||||||
item_id.as_ref(),
|
item_id.as_ref(),
|
||||||
|
item_id.as_ref(),
|
||||||
self.config.api_key,
|
self.config.api_key,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type NoSignalVariant = "no-signal" | "error" | "loading";
|
|||||||
interface NoSignalProps {
|
interface NoSignalProps {
|
||||||
variant?: NoSignalVariant;
|
variant?: NoSignalVariant;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANTS: Record<
|
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];
|
const { icon, heading, defaultMessage } = VARIANTS[variant];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,6 +53,8 @@ export function NoSignal({ variant = "no-signal", message }: NoSignalProps) {
|
|||||||
</p>
|
</p>
|
||||||
<p className="max-w-xs text-xs text-zinc-700">{message ?? defaultMessage}</p>
|
<p className="max-w-xs text-xs text-zinc-700">{message ?? defaultMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,90 @@
|
|||||||
import { forwardRef } from "react";
|
import { forwardRef, useEffect, useRef } from "react";
|
||||||
|
import Hls from "hls.js";
|
||||||
|
|
||||||
interface VideoPlayerProps {
|
interface VideoPlayerProps {
|
||||||
src?: string;
|
src?: string;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
className?: 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;
|
initialOffset?: number;
|
||||||
|
onStreamError?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
|
||||||
({ src, poster, className, initialOffset }, ref) => {
|
({ src, poster, className, initialOffset = 0, onStreamError }, ref) => {
|
||||||
|
const internalRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const hlsRef = useRef<Hls | null>(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 (
|
return (
|
||||||
<div className={`relative h-full w-full bg-black ${className ?? ""}`}>
|
<div className={`relative h-full w-full bg-black ${className ?? ""}`}>
|
||||||
<video
|
<video
|
||||||
ref={ref}
|
ref={setRef}
|
||||||
src={src}
|
|
||||||
poster={poster}
|
poster={poster}
|
||||||
autoPlay
|
|
||||||
playsInline
|
playsInline
|
||||||
onLoadedMetadata={(e) => {
|
onError={onStreamError}
|
||||||
if (initialOffset && initialOffset > 0) {
|
|
||||||
e.currentTarget.currentTime = initialOffset;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
VideoPlayer.displayName = "VideoPlayer";
|
VideoPlayer.displayName = "VideoPlayer";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
VideoPlayer,
|
VideoPlayer,
|
||||||
ChannelInfo,
|
ChannelInfo,
|
||||||
@@ -46,6 +47,13 @@ export default function TvPage() {
|
|||||||
const [showSchedule, setShowSchedule] = useState(false);
|
const [showSchedule, setShowSchedule] = useState(false);
|
||||||
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Video ref — used to resume playback if autoplay was blocked on load
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(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)
|
// Tick for live progress calculation (every 30 s is fine for the progress bar)
|
||||||
const [, setTick] = useState(0);
|
const [, setTick] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,11 +65,12 @@ export default function TvPage() {
|
|||||||
const { data: broadcast, isLoading: isLoadingBroadcast } =
|
const { data: broadcast, isLoading: isLoadingBroadcast } =
|
||||||
useCurrentBroadcast(channel?.id ?? "");
|
useCurrentBroadcast(channel?.id ?? "");
|
||||||
const { data: epgSlots } = useEpg(channel?.id ?? "");
|
const { data: epgSlots } = useEpg(channel?.id ?? "");
|
||||||
const { data: streamUrl } = useStreamUrl(
|
const { data: streamUrl } = useStreamUrl(channel?.id, token, broadcast?.slot.id);
|
||||||
channel?.id,
|
|
||||||
token,
|
// Clear stream error when the slot changes (next item started)
|
||||||
broadcast?.slot.id,
|
useEffect(() => {
|
||||||
);
|
setStreamError(false);
|
||||||
|
}, [broadcast?.slot.id]);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Derived display values
|
// Derived display values
|
||||||
@@ -87,6 +96,8 @@ export default function TvPage() {
|
|||||||
() => setShowOverlays(false),
|
() => setShowOverlays(false),
|
||||||
IDLE_TIMEOUT_MS,
|
IDLE_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
// Resume playback if autoplay was blocked (e.g. on page refresh with no prior interaction)
|
||||||
|
videoRef.current?.play().catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -151,6 +162,22 @@ export default function TvPage() {
|
|||||||
return () => window.removeEventListener("keydown", handleKey);
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
}, [nextChannel, prevChannel, toggleSchedule]);
|
}, [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
|
// Render helpers
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -178,16 +205,30 @@ export default function TvPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (streamError) {
|
||||||
|
return (
|
||||||
|
<NoSignal variant="error" message="Stream failed to load.">
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="mt-2 rounded-md border border-zinc-700 bg-zinc-800/80 px-4 py-2 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</NoSignal>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (streamUrl) {
|
if (streamUrl) {
|
||||||
return (
|
return (
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
|
ref={videoRef}
|
||||||
src={streamUrl}
|
src={streamUrl}
|
||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
initialOffset={broadcast?.offset_secs}
|
initialOffset={broadcast?.offset_secs ?? 0}
|
||||||
|
onStreamError={handleStreamError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Broadcast exists but stream URL resolving — show no-signal until ready
|
// Broadcast exists but stream URL resolving — show loading until ready
|
||||||
return <NoSignal variant="loading" message="Loading stream…" />;
|
return <NoSignal variant="loading" message="Loading stream…" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
"name": "k-tv-frontend",
|
"name": "k-tv-frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/hls.js": "^1.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -80,9 +80,13 @@ export function findNextSlot(
|
|||||||
* Returns null when the channel is in a gap (no-signal / 204).
|
* 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
|
* Resolves the stream URL for the current slot, with StartTimeTicks set so
|
||||||
* changes (new slot starts), but stays stable while the same item runs —
|
* Jellyfin begins transcoding at the correct broadcast offset.
|
||||||
* no mid-item restarts.
|
*
|
||||||
|
* 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(
|
export function useStreamUrl(
|
||||||
channelId: string | undefined,
|
channelId: string | undefined,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/hls.js": "^1.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
Reference in New Issue
Block a user