feat: enhance stream URL handling and add initial offset support in VideoPlayer
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user