From 5d12a5edc6b5f3065baad67e9fbb425a19284584 Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 6 Jun 2026 21:43:20 +0300 Subject: [PATCH] fix resume starting from beginning --- src/hooks/use-jellyfin.ts | 9 +++++++- src/pages/PlayerPage.tsx | 47 +++++++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/hooks/use-jellyfin.ts b/src/hooks/use-jellyfin.ts index f3f0537..96b9dda 100644 --- a/src/hooks/use-jellyfin.ts +++ b/src/hooks/use-jellyfin.ts @@ -616,12 +616,19 @@ export function useSimilarItems(itemId?: string, limit = 12) { * server uses our DeviceProfile to decide direct-play vs HLS transcoding. * Without this call, the bare /main.m3u8 endpoint generates a playlist with * runtimeTicks=0, which makes segment fetches fail with 400. + * + * `enabled` lets callers gate the call on the saved resume position + * being loaded: if a user lands on /play/:id?resume=true, the item + * has to load first so the first PlaybackInfo request includes the + * saved StartTimeTicks - otherwise the video streams from 0 and + * reloads mid-playback when the second query returns. */ export function usePlaybackInfo( itemId?: string, startTimeTicks?: number, audioStreamIndex?: number, maxStreamingBitrate?: number, + enabled: boolean = true, ) { const api = useApi() const audioPassthrough = usePreferencesStore(s => s.audioPassthrough) @@ -659,7 +666,7 @@ export function usePlaybackInfo( } as any) return res.data }, - enabled: !!api && !!itemId, + enabled: !!api && !!itemId && enabled, staleTime: 0, // Override the global 30-minute gcTime: each PlaybackInfoResponse // includes full MediaSources with encoding params, audio/video diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index 70c13ec..9b2946a 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -63,6 +63,9 @@ export default function PlayerPage() { const playerRef = useRef(null) const qc = useQueryClient() const progressRef = useRef(0) + // Seek the in-player resume prompt asks for, but only applied on the + // next canPlay so the seek survives when the source isn't ready yet. + const pendingSeekRef = useRef(null) // Start paused so the play icon shows until the player actually begins // playback - if autoplay succeeds, the onPlay handler flips this to false. @@ -291,6 +294,21 @@ export default function PlayerPage() { const resume = searchParams.get('resume') === 'true' const positionTicks = item?.UserData?.PlaybackPositionTicks const startTimeTicks = resume && positionTicks ? Number(positionTicks) : undefined + // If the user clicked Resume from a deep link, the item hasn't + // returned yet on the first render and startTimeTicks is undefined. + // Block the first PlaybackInfo call until the saved position is + // known - otherwise the server returns a stream that starts at 0 + // and the video buffers before the second query refires. + const playbackInfoReady = !resume || startTimeTicks !== undefined + if (typeof window !== 'undefined') { + console.log('[player] resume state', { + id, + resume, + positionTicks, + startTimeTicks, + playbackInfoReady, + }) + } /* Resolve the proper stream URL from Jellyfin's PlaybackInfo endpoint. * The server picks direct-play / direct-stream / transcoded HLS based on @@ -312,6 +330,7 @@ export default function PlayerPage() { startTimeTicks, streamAudioIndex ?? undefined, maxBitrate, + playbackInfoReady, ) const resolvedSource = playbackInfo?.MediaSources?.[0] const streamUrl = (() => { @@ -337,6 +356,7 @@ export default function PlayerPage() { setAudioIndex(null) setStreamAudioIndex(null) setEndCardOpen(false) + pendingSeekRef.current = null usePlayerRuntimeStore.getState().resetForNewItem() }, [id, setPanel]) @@ -1140,6 +1160,15 @@ export default function PlayerPage() { // Apply the saved playback rate (default 1) so users who like // 1.5x get it on every episode without re-setting. if (p) applyPlaybackRate(playbackRate) + // Apply any deferred seek queued by the resume prompt. + if (p && pendingSeekRef.current != null) { + const target = pendingSeekRef.current + pendingSeekRef.current = null + if (p.currentTime < target - 0.5 || p.currentTime > target + 0.5) { + try { p.currentTime = target } catch { /* ignore */ } + } + p.play().catch(() => {}) + } }} onEnded={() => { // Queue takes priority - playlist play/shuffle should always @@ -1374,14 +1403,24 @@ export default function PlayerPage() { setResumePromptOpen(false) const p = playerRef.current const pos = Number(item?.UserData?.PlaybackPositionTicks ?? 0) - if (p && pos > 0) p.currentTime = pos / 10_000_000 - p?.play().catch(() => {}) + if (p) { + // Queue the seek: if the video isn't ready yet, onCanPlay + // picks it up. The setter works on a paused player in + // vidstack, but buffering the seek target can race the + // canPlay event for the initial source. + pendingSeekRef.current = pos > 0 ? pos / 10_000_000 : 0 + try { p.currentTime = pendingSeekRef.current } catch { /* ignore */ } + p.play().catch(() => {}) + } }, onRestart: () => { setResumePromptOpen(false) const p = playerRef.current - if (p) p.currentTime = 0 - p?.play().catch(() => {}) + if (p) { + pendingSeekRef.current = 0 + try { p.currentTime = 0 } catch { /* ignore */ } + p.play().catch(() => {}) + } }, }} recap={{