fix resume starting from beginning

This commit is contained in:
2026-06-06 21:43:20 +03:00
parent 0700226477
commit 5d12a5edc6
2 changed files with 51 additions and 5 deletions
+8 -1
View File
@@ -616,12 +616,19 @@ export function useSimilarItems(itemId?: string, limit = 12) {
* server uses our DeviceProfile to decide direct-play vs HLS transcoding. * server uses our DeviceProfile to decide direct-play vs HLS transcoding.
* Without this call, the bare /main.m3u8 endpoint generates a playlist with * Without this call, the bare /main.m3u8 endpoint generates a playlist with
* runtimeTicks=0, which makes segment fetches fail with 400. * 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( export function usePlaybackInfo(
itemId?: string, itemId?: string,
startTimeTicks?: number, startTimeTicks?: number,
audioStreamIndex?: number, audioStreamIndex?: number,
maxStreamingBitrate?: number, maxStreamingBitrate?: number,
enabled: boolean = true,
) { ) {
const api = useApi() const api = useApi()
const audioPassthrough = usePreferencesStore(s => s.audioPassthrough) const audioPassthrough = usePreferencesStore(s => s.audioPassthrough)
@@ -659,7 +666,7 @@ export function usePlaybackInfo(
} as any) } as any)
return res.data return res.data
}, },
enabled: !!api && !!itemId, enabled: !!api && !!itemId && enabled,
staleTime: 0, staleTime: 0,
// Override the global 30-minute gcTime: each PlaybackInfoResponse // Override the global 30-minute gcTime: each PlaybackInfoResponse
// includes full MediaSources with encoding params, audio/video // includes full MediaSources with encoding params, audio/video
+43 -4
View File
@@ -63,6 +63,9 @@ export default function PlayerPage() {
const playerRef = useRef<MediaPlayerInstance>(null) const playerRef = useRef<MediaPlayerInstance>(null)
const qc = useQueryClient() const qc = useQueryClient()
const progressRef = useRef(0) 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<number | null>(null)
// Start paused so the play icon shows until the player actually begins // Start paused so the play icon shows until the player actually begins
// playback - if autoplay succeeds, the onPlay handler flips this to false. // 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 resume = searchParams.get('resume') === 'true'
const positionTicks = item?.UserData?.PlaybackPositionTicks const positionTicks = item?.UserData?.PlaybackPositionTicks
const startTimeTicks = resume && positionTicks ? Number(positionTicks) : undefined 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. /* Resolve the proper stream URL from Jellyfin's PlaybackInfo endpoint.
* The server picks direct-play / direct-stream / transcoded HLS based on * The server picks direct-play / direct-stream / transcoded HLS based on
@@ -312,6 +330,7 @@ export default function PlayerPage() {
startTimeTicks, startTimeTicks,
streamAudioIndex ?? undefined, streamAudioIndex ?? undefined,
maxBitrate, maxBitrate,
playbackInfoReady,
) )
const resolvedSource = playbackInfo?.MediaSources?.[0] const resolvedSource = playbackInfo?.MediaSources?.[0]
const streamUrl = (() => { const streamUrl = (() => {
@@ -337,6 +356,7 @@ export default function PlayerPage() {
setAudioIndex(null) setAudioIndex(null)
setStreamAudioIndex(null) setStreamAudioIndex(null)
setEndCardOpen(false) setEndCardOpen(false)
pendingSeekRef.current = null
usePlayerRuntimeStore.getState().resetForNewItem() usePlayerRuntimeStore.getState().resetForNewItem()
}, [id, setPanel]) }, [id, setPanel])
@@ -1140,6 +1160,15 @@ export default function PlayerPage() {
// Apply the saved playback rate (default 1) so users who like // Apply the saved playback rate (default 1) so users who like
// 1.5x get it on every episode without re-setting. // 1.5x get it on every episode without re-setting.
if (p) applyPlaybackRate(playbackRate) 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={() => { onEnded={() => {
// Queue takes priority - playlist play/shuffle should always // Queue takes priority - playlist play/shuffle should always
@@ -1374,14 +1403,24 @@ export default function PlayerPage() {
setResumePromptOpen(false) setResumePromptOpen(false)
const p = playerRef.current const p = playerRef.current
const pos = Number(item?.UserData?.PlaybackPositionTicks ?? 0) const pos = Number(item?.UserData?.PlaybackPositionTicks ?? 0)
if (p && pos > 0) p.currentTime = pos / 10_000_000 if (p) {
p?.play().catch(() => {}) // 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: () => { onRestart: () => {
setResumePromptOpen(false) setResumePromptOpen(false)
const p = playerRef.current const p = playerRef.current
if (p) p.currentTime = 0 if (p) {
p?.play().catch(() => {}) pendingSeekRef.current = 0
try { p.currentTime = 0 } catch { /* ignore */ }
p.play().catch(() => {})
}
}, },
}} }}
recap={{ recap={{