add hls 3-step recovery and stuck watchdog with auto-advance
This commit is contained in:
+160
-31
@@ -288,6 +288,34 @@ export default function PlayerPage() {
|
|||||||
!upNextDismissed
|
!upNextDismissed
|
||||||
const upNextCountdown = Math.max(0, Math.ceil(duration - currentTime))
|
const upNextCountdown = Math.max(0, Math.ceil(duration - currentTime))
|
||||||
|
|
||||||
|
/* Auto-advance to the next item. Used by the onEnded handler and by the
|
||||||
|
* HLS stuck watchdog when recovery has failed and the user is in a
|
||||||
|
* queue / autoplay context. Mirrors the onEnded logic so a stuck stream
|
||||||
|
* behaves the same as a stream that finished normally. */
|
||||||
|
const autoAdvanceToNext = useCallback(() => {
|
||||||
|
if (queueNext?.Id) {
|
||||||
|
if (areYouStillWatching && autoAdvanceCountRef.current >= 2) {
|
||||||
|
stillWatchingTargetRef.current = queueNext.Id
|
||||||
|
setStillWatchingOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autoAdvanceCountRef.current++
|
||||||
|
navigate(`/play/${queueNext.Id}`, { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (autoplayNext && nextItem?.Id && nextItem.Id !== item?.Id) {
|
||||||
|
if (areYouStillWatching && autoAdvanceCountRef.current >= 2) {
|
||||||
|
stillWatchingTargetRef.current = nextItem.Id
|
||||||
|
setStillWatchingOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autoAdvanceCountRef.current++
|
||||||
|
navigate(`/play/${nextItem.Id}`, { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autoAdvanceCountRef.current = 0
|
||||||
|
}, [queueNext, autoplayNext, nextItem, item, areYouStillWatching, navigate])
|
||||||
|
|
||||||
const auth = jellyfinClient.getAuthState()
|
const auth = jellyfinClient.getAuthState()
|
||||||
const token = auth?.token || ''
|
const token = auth?.token || ''
|
||||||
const serverUrl = auth?.serverUrl || ''
|
const serverUrl = auth?.serverUrl || ''
|
||||||
@@ -377,6 +405,7 @@ export default function PlayerPage() {
|
|||||||
const resumePromptShownRef = useRef<string | null>(null)
|
const resumePromptShownRef = useRef<string | null>(null)
|
||||||
const resumeItemId = item?.Id
|
const resumeItemId = item?.Id
|
||||||
const resumePositionTicks = item?.UserData?.PlaybackPositionTicks
|
const resumePositionTicks = item?.UserData?.PlaybackPositionTicks
|
||||||
|
const stuckRecoveryAttemptsRef = useRef(0)
|
||||||
|
|
||||||
/* Reset transient flags on item change */
|
/* Reset transient flags on item change */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -387,6 +416,7 @@ export default function PlayerPage() {
|
|||||||
setEndCardOpen(false)
|
setEndCardOpen(false)
|
||||||
pendingSeekRef.current = null
|
pendingSeekRef.current = null
|
||||||
resumePromptShownRef.current = null
|
resumePromptShownRef.current = null
|
||||||
|
stuckRecoveryAttemptsRef.current = 0
|
||||||
usePlayerRuntimeStore.getState().resetForNewItem()
|
usePlayerRuntimeStore.getState().resetForNewItem()
|
||||||
}, [id, setPanel])
|
}, [id, setPanel])
|
||||||
|
|
||||||
@@ -647,6 +677,79 @@ export default function PlayerPage() {
|
|||||||
}
|
}
|
||||||
}, [streamUrl])
|
}, [streamUrl])
|
||||||
|
|
||||||
|
/* Stuck watchdog. The HLS error handler does its best to recover, but
|
||||||
|
* the transcoder can wedge in ways that don't surface as a fatal
|
||||||
|
* error - the video just stops making progress while the spinner
|
||||||
|
* spins. If that happens and we're in a queue / autoplay context,
|
||||||
|
* auto-advance to the next item so the user doesn't have to babysit
|
||||||
|
* a frozen stream. The intent: "I started a playlist, walk away, come
|
||||||
|
* back, and it's still going through items." */
|
||||||
|
useEffect(() => {
|
||||||
|
const STUCK_CHECK_MS = 5_000
|
||||||
|
const STUCK_THRESHOLD_MS = 15_000
|
||||||
|
const STUCK_RECOVERY_COOLDOWN_MS = 5_000
|
||||||
|
let lastProgressAt = performance.now()
|
||||||
|
let lastBufferedAt = performance.now()
|
||||||
|
let lastCurrentTime = 0
|
||||||
|
let lastBuffered = 0
|
||||||
|
let lastRecoveryAt = 0
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
const p = playerRef.current
|
||||||
|
if (!p || p.paused) {
|
||||||
|
lastProgressAt = performance.now()
|
||||||
|
lastBufferedAt = performance.now()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const t = p.currentTime
|
||||||
|
const b = (p as any).media?.buffered
|
||||||
|
let bufferedEnd = 0
|
||||||
|
if (b && b.length > 0) {
|
||||||
|
try { bufferedEnd = b.end(b.length - 1) } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
if (Math.abs(t - lastCurrentTime) > 0.25) {
|
||||||
|
lastProgressAt = performance.now()
|
||||||
|
lastCurrentTime = t
|
||||||
|
}
|
||||||
|
if (bufferedEnd - lastBuffered > 0.25) {
|
||||||
|
lastBufferedAt = performance.now()
|
||||||
|
lastBuffered = bufferedEnd
|
||||||
|
}
|
||||||
|
const stuckMs = Math.min(
|
||||||
|
performance.now() - lastProgressAt,
|
||||||
|
performance.now() - lastBufferedAt,
|
||||||
|
)
|
||||||
|
if (stuckMs < STUCK_THRESHOLD_MS) return
|
||||||
|
// Stuck. Call recoverMediaError on the hls.js instance - this is
|
||||||
|
// the same recovery the error handler uses for fatal media errors,
|
||||||
|
// just triggered by a wall-clock stall instead of an hls.js
|
||||||
|
// internal error. After 3 attempts, give up and auto-advance.
|
||||||
|
const now = performance.now()
|
||||||
|
if (now - lastRecoveryAt < STUCK_RECOVERY_COOLDOWN_MS) return
|
||||||
|
lastRecoveryAt = now
|
||||||
|
const provider = (p as any).provider
|
||||||
|
const hls = provider && isHLSProvider(provider) ? (provider as any).instance : null
|
||||||
|
console.warn('[player] stuck for', Math.round(stuckMs / 1000), 's, nudging', {
|
||||||
|
currentTime: t,
|
||||||
|
bufferedEnd,
|
||||||
|
attempts: stuckRecoveryAttemptsRef.current,
|
||||||
|
})
|
||||||
|
if (hls && !hls.destroyed) {
|
||||||
|
try { hls.recoverMediaError() } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
stuckRecoveryAttemptsRef.current++
|
||||||
|
if (stuckRecoveryAttemptsRef.current >= 3) {
|
||||||
|
console.warn('[player] stuck recovery exhausted, auto-advancing')
|
||||||
|
stuckRecoveryAttemptsRef.current = 0
|
||||||
|
lastProgressAt = performance.now()
|
||||||
|
lastBufferedAt = performance.now()
|
||||||
|
if (queueNext?.Id || (autoplayNext && nextItem?.Id)) {
|
||||||
|
autoAdvanceToNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, STUCK_CHECK_MS)
|
||||||
|
return () => clearInterval(tick)
|
||||||
|
}, [streamUrl, queueNext, nextItem, autoplayNext, autoAdvanceToNext])
|
||||||
|
|
||||||
/* Vidstack handles autoplay via the autoPlay prop - we don't kick play()
|
/* Vidstack handles autoplay via the autoPlay prop - we don't kick play()
|
||||||
* ourselves because doing so before the can-play event throws "media not
|
* ourselves because doing so before the can-play event throws "media not
|
||||||
* ready". The autoplay-blocked case is handled by onAutoPlayFail below. */
|
* ready". The autoplay-blocked case is handled by onAutoPlayFail below. */
|
||||||
@@ -1121,7 +1224,10 @@ export default function PlayerPage() {
|
|||||||
// hls.js configuration tuned for Jellyfin transcoded streams.
|
// hls.js configuration tuned for Jellyfin transcoded streams.
|
||||||
// Default fragLoadingTimeOut of 10s is too aggressive when the
|
// Default fragLoadingTimeOut of 10s is too aggressive when the
|
||||||
// server is doing HEVC -> h264 transcoding from a cold start
|
// server is doing HEVC -> h264 transcoding from a cold start
|
||||||
// (ffmpeg needs time to spin up before the first segment).
|
// (ffmpeg needs time to spin up before the first segment), and
|
||||||
|
// the default retry counts of 3 burn out fast when a transcoder
|
||||||
|
// hiccups mid-stream. Mirrors what jellyfin-web's HlsController
|
||||||
|
// sets in production.
|
||||||
;(provider as any).config = {
|
;(provider as any).config = {
|
||||||
startLevel: -1,
|
startLevel: -1,
|
||||||
maxBufferLength: 30,
|
maxBufferLength: 30,
|
||||||
@@ -1130,9 +1236,11 @@ export default function PlayerPage() {
|
|||||||
lowLatencyMode: false,
|
lowLatencyMode: false,
|
||||||
backBufferLength: 15,
|
backBufferLength: 15,
|
||||||
fragLoadingTimeOut: 60_000,
|
fragLoadingTimeOut: 60_000,
|
||||||
fragLoadingMaxRetry: 6,
|
fragLoadingMaxRetry: 8,
|
||||||
manifestLoadingTimeOut: 30_000,
|
manifestLoadingTimeOut: 30_000,
|
||||||
|
manifestLoadingMaxRetry: 6,
|
||||||
levelLoadingTimeOut: 30_000,
|
levelLoadingTimeOut: 30_000,
|
||||||
|
levelLoadingMaxRetry: 6,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -1140,6 +1248,17 @@ export default function PlayerPage() {
|
|||||||
if (isHLSProvider(provider)) {
|
if (isHLSProvider(provider)) {
|
||||||
const hls = (provider as any).instance as any
|
const hls = (provider as any).instance as any
|
||||||
if (hls?.on && HLS?.Events) {
|
if (hls?.on && HLS?.Events) {
|
||||||
|
// Recovery timestamps mirror jellyfin-web's
|
||||||
|
// handleHlsJsMediaError (src/components/htmlMediaHelper.js).
|
||||||
|
// First fatal media error -> recoverMediaError(). If another
|
||||||
|
// fatal media error arrives within 3s, swap audio codec
|
||||||
|
// (the transcode pipeline might have started muxing a
|
||||||
|
// different audio track) and retry. If even that fails, the
|
||||||
|
// stream is genuinely broken and the stuck watchdog below
|
||||||
|
// will auto-advance to the next queue item.
|
||||||
|
let recoverDecodingErrorAt = 0
|
||||||
|
let recoverSwapAudioCodecAt = 0
|
||||||
|
const RECOVERY_WINDOW_MS = 3000
|
||||||
hls.on(HLS.Events.ERROR, (_event: unknown, data: any) => {
|
hls.on(HLS.Events.ERROR, (_event: unknown, data: any) => {
|
||||||
console.warn('[HLS]', data?.type, data?.details, {
|
console.warn('[HLS]', data?.type, data?.details, {
|
||||||
fatal: data?.fatal,
|
fatal: data?.fatal,
|
||||||
@@ -1147,40 +1266,50 @@ export default function PlayerPage() {
|
|||||||
url: data?.url || data?.frag?.url,
|
url: data?.url || data?.frag?.url,
|
||||||
response: data?.response,
|
response: data?.response,
|
||||||
})
|
})
|
||||||
// Non-fatal errors (buffer stalls, fragment load failures)
|
if (!data?.fatal) return
|
||||||
// can often be recovered without user intervention.
|
const now = performance.now()
|
||||||
if (data?.fatal) {
|
if (data.type === HLS.ErrorTypes.NETWORK_ERROR) {
|
||||||
switch (data.type) {
|
// HTTP >= 400 from the segment URL means the server
|
||||||
case HLS.ErrorTypes.NETWORK_ERROR:
|
// rejected our request (transcoder died, auth expired).
|
||||||
|
// Nothing to recover on the client side.
|
||||||
|
const code = data?.response?.code
|
||||||
|
if (code && code >= 400) {
|
||||||
|
console.warn('[HLS] server returned', code, 'giving up')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Code 0 is usually a CORS / network blackout. The
|
||||||
|
// stream is unrecoverable from here.
|
||||||
|
if (code === 0) {
|
||||||
|
console.warn('[HLS] network blackout (CORS or offline)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Generic network blip - re-issue the request. hls.js
|
||||||
|
// will keep retrying per fragLoadingMaxRetry.
|
||||||
hls.startLoad()
|
hls.startLoad()
|
||||||
break
|
return
|
||||||
case HLS.ErrorTypes.MEDIA_ERROR:
|
}
|
||||||
// fragParsingError ("Found no media") means the
|
if (data.type === HLS.ErrorTypes.MEDIA_ERROR) {
|
||||||
// transcoder returned an empty segment - usually a
|
if (!recoverDecodingErrorAt || now - recoverDecodingErrorAt > RECOVERY_WINDOW_MS) {
|
||||||
// cold-start race. Give it a second and retry from
|
recoverDecodingErrorAt = now
|
||||||
// the current position before doing the heavy
|
console.warn('[HLS] fatal media error, try to recover')
|
||||||
// recoverMediaError reset (which causes a visible skip).
|
try { hls.recoverMediaError() } catch { /* ignore */ }
|
||||||
if (data.details === 'fragParsingError') {
|
} else if (!recoverSwapAudioCodecAt || now - recoverSwapAudioCodecAt > RECOVERY_WINDOW_MS) {
|
||||||
const pos = hls.media?.currentTime
|
recoverSwapAudioCodecAt = now
|
||||||
setTimeout(() => {
|
console.warn('[HLS] fatal media error again, swap audio codec and recover')
|
||||||
if (hls.destroyed) return
|
|
||||||
try {
|
try {
|
||||||
hls.loadSource(hls.url)
|
hls.swapAudioCodec()
|
||||||
if (pos != null && hls.media) {
|
|
||||||
hls.media.currentTime = pos
|
|
||||||
}
|
|
||||||
hls.startLoad()
|
|
||||||
} catch { hls.recoverMediaError() }
|
|
||||||
}, 1000)
|
|
||||||
} else {
|
|
||||||
hls.recoverMediaError()
|
hls.recoverMediaError()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
// 3rd fatal within the window: stop trying. The
|
||||||
|
// stuck watchdog will auto-advance if the player
|
||||||
|
// is actually wedged.
|
||||||
|
console.warn('[HLS] media error recovery exhausted, watchdog will take over')
|
||||||
}
|
}
|
||||||
break
|
return
|
||||||
default:
|
|
||||||
hls.destroy()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Anything else (mux error, key load error) is fatal and
|
||||||
|
// hls.js can't auto-recover. Let the watchdog handle it.
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user