feat: enhance stream URL handling and add initial offset support in VideoPlayer

This commit is contained in:
2026-03-11 19:51:51 +01:00
parent c9aa36bb5f
commit 4789dca679
5 changed files with 47 additions and 13 deletions

View File

@@ -158,10 +158,14 @@ impl IMediaProvider for JellyfinMediaProvider {
/// Requests H.264 video + AAC audio in an MP4 container so that all /// 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 /// major browsers can play it natively. Jellyfin will direct-play if the
/// source already matches; otherwise it transcodes on the fly. /// 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. /// 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<String> { async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String> {
Ok(format!( 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, self.config.base_url,
item_id.as_ref(), item_id.as_ref(),
self.config.api_key, self.config.api_key,

View File

@@ -1,6 +1,7 @@
export { VideoPlayer } from "./video-player"; export { VideoPlayer } from "./video-player";
export type { VideoPlayerProps } from "./video-player"; export type { VideoPlayerProps } from "./video-player";
export { ChannelInfo } from "./channel-info"; export { ChannelInfo } from "./channel-info";
export type { ChannelInfoProps } from "./channel-info"; export type { ChannelInfoProps } from "./channel-info";

View File

@@ -4,10 +4,12 @@ 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). */
initialOffset?: number;
} }
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>( const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
({ src, poster, className }, ref) => { ({ src, poster, className, initialOffset }, ref) => {
return ( return (
<div className={`relative h-full w-full bg-black ${className ?? ""}`}> <div className={`relative h-full w-full bg-black ${className ?? ""}`}>
<video <video
@@ -16,6 +18,11 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
poster={poster} poster={poster}
autoPlay autoPlay
playsInline playsInline
onLoadedMetadata={(e) => {
if (initialOffset && initialOffset > 0) {
e.currentTarget.currentTime = initialOffset;
}
}}
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />
</div> </div>

View File

@@ -57,7 +57,11 @@ 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(channel?.id, token); const { data: streamUrl } = useStreamUrl(
channel?.id,
token,
broadcast?.slot.id,
);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Derived display values // Derived display values
@@ -176,7 +180,11 @@ export default function TvPage() {
} }
if (streamUrl) { if (streamUrl) {
return ( return (
<VideoPlayer src={streamUrl} className="absolute inset-0 h-full w-full" /> <VideoPlayer
src={streamUrl}
className="absolute inset-0 h-full w-full"
initialOffset={broadcast?.offset_secs}
/>
); );
} }
// Broadcast exists but stream URL resolving — show no-signal until ready // Broadcast exists but stream URL resolving — show no-signal until ready

View File

@@ -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 backend's GET /channels/:id/stream endpoint returns a 307 redirect to
* the Jellyfin stream URL. Since browsers can't read redirect Location headers * 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 * from fetch(), we proxy through /api/stream/[channelId] (a Next.js route that
* runs server-side) and return the final URL as JSON. * 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). * 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({ return useQuery({
queryKey: ["stream-url", channelId], queryKey: ["stream-url", channelId, slotId],
queryFn: async (): Promise<string | null> => { queryFn: async (): Promise<string | null> => {
const res = await fetch( const params = new URLSearchParams({ token: token! });
`/api/stream/${channelId}?token=${encodeURIComponent(token!)}`, const res = await fetch(`/api/stream/${channelId}?${params}`, {
{ cache: "no-store" }, cache: "no-store",
); });
if (res.status === 204) return null; if (res.status === 204) return null;
if (!res.ok) throw new Error(`Stream resolve failed: ${res.status}`); if (!res.ok) throw new Error(`Stream resolve failed: ${res.status}`);
const { url } = await res.json(); const { url } = await res.json();
return url as string; return url as string;
}, },
enabled: !!channelId && !!token, enabled: !!channelId && !!token && !!slotId,
refetchInterval: 30_000, staleTime: Infinity,
retry: false, retry: false,
}); });
} }