Files
jellybloom/src/hooks/use-prebuffer.ts
T

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])
}