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
+43 -4
View File
@@ -63,6 +63,9 @@ export default function PlayerPage() {
const playerRef = useRef<MediaPlayerInstance>(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<number | null>(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={{