82 lines
3.3 KiB
TypeScript
82 lines
3.3 KiB
TypeScript
import { useEffect } from 'react'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { jellyfinClient, getMediaInfoApi } from '../api/jellyfin'
|
|
import { browserDeviceProfile } from '../lib/device-profile'
|
|
import type { BaseItemDto } from '../api/types'
|
|
|
|
/**
|
|
* Pre-fire Jellyfin's PlaybackInfo handshake (and a tiny range request
|
|
* against the resolved stream URL) when the user has been hovering a
|
|
* card long enough that they're likely to play it. The handshake is
|
|
* the longest sync part of opening playback, so cache-warming it makes
|
|
* the click→playing transition feel near-instant.
|
|
*
|
|
* - Skips synthetic TMDB-only items (no real Jellyfin id).
|
|
* - Idempotent per item: React Query dedups, so multiple armed cards
|
|
* pointing at the same id share one request.
|
|
* - Best-effort: any error is swallowed silently; the player will
|
|
* re-issue PlaybackInfo as it normally does if the cache is missing.
|
|
*/
|
|
export function usePrebuffer(item: BaseItemDto | null | undefined, armed: boolean) {
|
|
const qc = useQueryClient()
|
|
|
|
useEffect(() => {
|
|
if (!armed) return
|
|
if (!item || !item.Id || String(item.Id).startsWith('tmdb-')) return
|
|
// Episodes / movies are the only item kinds that go through PlaybackInfo
|
|
// for streaming; series / seasons would need a child resolution first.
|
|
if (item.Type !== 'Movie' && item.Type !== 'Episode') return
|
|
|
|
const itemId = item.Id
|
|
let cancelled = false
|
|
|
|
const key = ['jellyfin', 'playback-info', itemId, undefined, undefined, undefined]
|
|
qc.fetchQuery({
|
|
queryKey: key,
|
|
queryFn: async () => {
|
|
const api = jellyfinClient.getApi()
|
|
if (!api) return null
|
|
const res = await getMediaInfoApi(api).getPostedPlaybackInfo({
|
|
itemId,
|
|
playbackInfoDto: {
|
|
UserId: jellyfinClient.getAuthState()!.userId,
|
|
MaxStreamingBitrate: 140_000_000,
|
|
DeviceProfile: browserDeviceProfile() as any,
|
|
AutoOpenLiveStream: true,
|
|
EnableDirectPlay: true,
|
|
EnableDirectStream: true,
|
|
EnableTranscoding: true,
|
|
AllowVideoStreamCopy: true,
|
|
AllowAudioStreamCopy: true,
|
|
},
|
|
} as any)
|
|
return res.data
|
|
},
|
|
staleTime: 60_000,
|
|
}).then(playbackInfo => {
|
|
if (cancelled || !playbackInfo) return
|
|
const auth = jellyfinClient.getAuthState()
|
|
if (!auth) return
|
|
const source: any = (playbackInfo as any).MediaSources?.[0]
|
|
if (!source) return
|
|
// Build the stream URL the way PlayerPage does and request the
|
|
// first kilobyte. For direct-play this primes the HTTP cache; for
|
|
// HLS transcodes this triggers Jellyfin to start the transcode
|
|
// segment 0 ahead of time.
|
|
const url = source.SupportsDirectPlay
|
|
? `${auth.serverUrl}/Videos/${itemId}/stream?static=true&MediaSourceId=${source.Id}&api_key=${auth.token}`
|
|
: source.TranscodingUrl
|
|
? `${auth.serverUrl}${source.TranscodingUrl}`
|
|
: null
|
|
if (!url) return
|
|
fetch(url, {
|
|
method: 'GET',
|
|
headers: { Range: 'bytes=0-32767' },
|
|
}).catch(() => { /* warm-only; ignore */ })
|
|
}).catch(() => { /* warm-only; ignore */ })
|
|
|
|
return () => { cancelled = true }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [armed, item?.Id])
|
|
}
|