fix resume starting from beginning
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
Reference in New Issue
Block a user