diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index 21c6252..b66c7e3 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -288,6 +288,34 @@ export default function PlayerPage() { !upNextDismissed 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 token = auth?.token || '' const serverUrl = auth?.serverUrl || '' @@ -377,6 +405,7 @@ export default function PlayerPage() { const resumePromptShownRef = useRef(null) const resumeItemId = item?.Id const resumePositionTicks = item?.UserData?.PlaybackPositionTicks + const stuckRecoveryAttemptsRef = useRef(0) /* Reset transient flags on item change */ useEffect(() => { @@ -387,6 +416,7 @@ export default function PlayerPage() { setEndCardOpen(false) pendingSeekRef.current = null resumePromptShownRef.current = null + stuckRecoveryAttemptsRef.current = 0 usePlayerRuntimeStore.getState().resetForNewItem() }, [id, setPanel]) @@ -647,6 +677,79 @@ export default function PlayerPage() { } }, [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() * ourselves because doing so before the can-play event throws "media not * 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. // Default fragLoadingTimeOut of 10s is too aggressive when the // 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 = { startLevel: -1, maxBufferLength: 30, @@ -1130,9 +1236,11 @@ export default function PlayerPage() { lowLatencyMode: false, backBufferLength: 15, fragLoadingTimeOut: 60_000, - fragLoadingMaxRetry: 6, + fragLoadingMaxRetry: 8, manifestLoadingTimeOut: 30_000, + manifestLoadingMaxRetry: 6, levelLoadingTimeOut: 30_000, + levelLoadingMaxRetry: 6, } } }} @@ -1140,6 +1248,17 @@ export default function PlayerPage() { if (isHLSProvider(provider)) { const hls = (provider as any).instance as any 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) => { console.warn('[HLS]', data?.type, data?.details, { fatal: data?.fatal, @@ -1147,40 +1266,50 @@ export default function PlayerPage() { url: data?.url || data?.frag?.url, response: data?.response, }) - // Non-fatal errors (buffer stalls, fragment load failures) - // can often be recovered without user intervention. - if (data?.fatal) { - switch (data.type) { - case HLS.ErrorTypes.NETWORK_ERROR: - hls.startLoad() - break - case HLS.ErrorTypes.MEDIA_ERROR: - // fragParsingError ("Found no media") means the - // transcoder returned an empty segment - usually a - // 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 + if (!data?.fatal) return + const now = performance.now() + if (data.type === HLS.ErrorTypes.NETWORK_ERROR) { + // HTTP >= 400 from the segment URL means the server + // 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() + 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. }) } }