add hls 3-step recovery and stuck watchdog with auto-advance

This commit is contained in:
2026-06-09 12:27:31 +03:00
parent ec06cf69a1
commit d7ffb6f90d
+163 -34
View File
@@ -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).
hls.startLoad() // Nothing to recover on the client side.
break const code = data?.response?.code
case HLS.ErrorTypes.MEDIA_ERROR: if (code && code >= 400) {
// fragParsingError ("Found no media") means the console.warn('[HLS] server returned', code, 'giving up')
// transcoder returned an empty segment - usually a return
// cold-start race. Give it a second and retry from
// the current position before doing the heavy
// recoverMediaError reset (which causes a visible skip).
if (data.details === 'fragParsingError') {
const pos = hls.media?.currentTime
setTimeout(() => {
if (hls.destroyed) return
try {
hls.loadSource(hls.url)
if (pos != null && hls.media) {
hls.media.currentTime = pos
}
hls.startLoad()
} catch { hls.recoverMediaError() }
}, 1000)
} else {
hls.recoverMediaError()
}
break
default:
hls.destroy()
break
} }
// 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()
return
} }
if (data.type === HLS.ErrorTypes.MEDIA_ERROR) {
if (!recoverDecodingErrorAt || now - recoverDecodingErrorAt > RECOVERY_WINDOW_MS) {
recoverDecodingErrorAt = now
console.warn('[HLS] fatal media error, try to recover')
try { hls.recoverMediaError() } catch { /* ignore */ }
} else if (!recoverSwapAudioCodecAt || now - recoverSwapAudioCodecAt > RECOVERY_WINDOW_MS) {
recoverSwapAudioCodecAt = now
console.warn('[HLS] fatal media error again, swap audio codec and recover')
try {
hls.swapAudioCodec()
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')
}
return
}
// Anything else (mux error, key load error) is fatal and
// hls.js can't auto-recover. Let the watchdog handle it.
}) })
} }
} }