diff --git a/web-ui/src/components/player/video-player.tsx b/web-ui/src/components/player/video-player.tsx index ef994c1a..38b9a3eb 100644 --- a/web-ui/src/components/player/video-player.tsx +++ b/web-ui/src/components/player/video-player.tsx @@ -101,7 +101,10 @@ interface PlaybackErrorDisplay { suggestion?: string; } -const MAX_RETRIES = 3; +// [local patch] A freeze retries immediately now, so the budget must not run out +// after a few hiccups. The stock player resets the baseline after 30s of stable +// playback, so this only bounds a truly hopeless stream. +const MAX_RETRIES = 100; const INACTIVE_RENDER_STATE: PlayerRenderState = { active: false, deinterlacing: false }; type SlotId = "a" | "b"; @@ -1252,6 +1255,30 @@ function VideoPlayerComponent({ } }); + // Watchdog asked for a recovery. Do exactly what tapping a channel does: load + // into the hidden slot and hard-switch when it is ready, which also releases + // the wedged connection instead of stacking a retry on top of it. + useEffect(() => { + const onStallRestart = (event: Event) => { + // Cancel the default "no listener" behaviour: we handled it. + event.preventDefault?.(); + if (playMode !== "live") { + return; + } + const segments = getRetrySegments(); + if (!segments.length) { + return; + } + // Pretend the stream just changed so handleLoadSegments takes the seamless + // switch branch (hidden slot + hard switch) instead of reloading in place. + prevStreamRef.current = { channelId: "__r2h_restall__", sourceIndex: -1 }; + shouldAutoPlayRef.current = true; + handleLoadSegments(segments); + }; + window.addEventListener("r2h-playback-stall", onStallRestart); + return () => window.removeEventListener("r2h-playback-stall", onStallRestart); + }, [playMode]); + const handleVideoEnterPiP = useEffectEvent((slotId: SlotId) => { if (slotId !== getActiveSlotId()) return; setIsPiP(true); diff --git a/web-ui/src/playback-engine/mse/playback-controller.ts b/web-ui/src/playback-engine/mse/playback-controller.ts index 8ee1dbf0..d9d64085 100644 --- a/web-ui/src/playback-engine/mse/playback-controller.ts +++ b/web-ui/src/playback-engine/mse/playback-controller.ts @@ -49,6 +49,31 @@ export function createMSEPlaybackController( let watermarkPaused = false; let bufferFullPaused = false; + // ---- [local patch] playback stall watchdog ------------------------------- + // Mobile browsers can stop consuming frames while the network keeps flowing + // (no error event fires), so the session looks alive but the picture freezes. + // Poll the media clock and recover instead of waiting for a user channel switch. + const STALL_TICK_MS = 1000; + const STALL_DETECT_SEC = 1; + let stallTimer: number | null = null; + let stallLastPos = -1; + let stallLastMoveAt = 0; + let stallJumpCount = 0; + /** Seconds of continuous playback required before stall judgement is allowed at all. */ + const STALL_STEADY_SEC = 8; + /** If nothing has played at all this long after loading, the stream never comes up. */ + const STALL_STARTUP_LIMIT_MS = 12000; + let stallSteady = false; + /** Position where this session's playback started, to measure continuous progress. */ + let stallArmedPos = -1; + /** When the current load started (reset while the slot is off screen). */ + let stallLoadAt = 0; + /** Last seen end of the buffered range, to tell "clock stuck" from "pipeline dead". */ + let stallBufferedEnd = -1; + let stallBufferedEndAt = 0; + /** When the watchdog armed for this session (startup grace window). */ + let stallArmedAt = 0; + // PCM audio player for software-decoded audio (MP2) let pcmPlayer: PCMAudioPlayer | null = null; let pcmPlayerInitPromise: Promise | null = null; @@ -450,6 +475,167 @@ export function createMSEPlaybackController( } } + function clearStallWatchdog(): void { + if (stallTimer !== null) { + window.clearInterval(stallTimer); + stallTimer = null; + } + stallLastPos = -1; + stallLastMoveAt = 0; + stallJumpCount = 0; + stallSteady = false; + stallArmedPos = -1; + stallArmedAt = 0; + stallBufferedEnd = -1; + stallBufferedEndAt = 0; + } + + function stallTick(): void { + if (!mse) { + return; + } + const now = performance.now(); + const pos = video.currentTime; + + // A slot that is no longer on screen must never be judged. Seamless channel + // switching (dual slots A/B) keeps the previous element alive in the DOM, so + // its clock freezes while it is detached — judging it would raise an error + // for the *player page*, interrupting the channel that is actually playing. + if (!video.isConnected || video.getClientRects().length === 0) { + stallLastPos = pos; + stallLastMoveAt = now; + stallArmedPos = pos; + stallSteady = false; + // Start the "never came up" clock when this slot becomes visible. + stallLoadAt = now; + return; + } + + // Paused / hidden / still aligning audio: nothing to judge, and every + // startup attempt restarts the steady-playback measurement. + if ( + video.paused || + video.ended || + document.visibilityState !== "visible" || + startupRateControlActive + ) { + stallLastPos = pos; + stallLastMoveAt = now; + stallArmedPos = pos; + stallSteady = false; + return; + } + + if (stallArmedPos < 0) { + stallArmedPos = pos; + } + + if (Math.abs(pos - stallLastPos) > 0.05) { + stallLastPos = pos; + stallLastMoveAt = now; + stallJumpCount = 0; + // The stock startup path is left completely alone: only after this session + // has really played STALL_STEADY_SEC of media do we start watching for + // freezes. Startup retries reset currentTime to ~0, which keeps this false. + if (!stallSteady && pos - stallArmedPos >= STALL_STEADY_SEC) { + stallSteady = true; + stallArmedAt = now; + } + return; + } + + if (!stallSteady) { + // Still starting up. Do not touch the stock startup path — but if nothing has + // played at all within the limit, the stream will never come up, so recover + // instead of sitting on "buffering" forever. + if (stallLoadAt > 0 && now - stallLoadAt > STALL_STARTUP_LIMIT_MS && pos < 1) { + stallLoadAt = now; + stallLastMoveAt = now; + console.warn(`[r2h-watchdog] startup produced no video for ${STALL_STARTUP_LIMIT_MS}ms -> recover`); + let handled = false; + try { + handled = !window.dispatchEvent(new CustomEvent("r2h-playback-stall", { cancelable: true })); + } catch { + handled = false; + } + if (!handled) { + impl.suspend(); + impl.onError?.({ + category: "media", + detail: PlayerErrors.MEDIA_MSE_ERROR, + info: "Startup stalled without producing video (watchdog)", + }); + } + return; + } + stallLastMoveAt = now; + return; + } + if (stallLastMoveAt === 0) { + stallLastMoveAt = now; + return; + } + // Track whether the buffered range is still growing: if it is frozen, the + // append pipeline is dead and seeking inside the buffer cannot help. + { + const range = getLastBufferedRange(); + if (range && range.end > stallBufferedEnd + 0.05) { + stallBufferedEnd = range.end; + stallBufferedEndAt = now; + } else if (stallBufferedEndAt === 0) { + stallBufferedEndAt = now; + } + } + + const stalledSec = (now - stallLastMoveAt) / 1000; + if (stalledSec < STALL_DETECT_SEC) { + return; + } + + // During the first seconds after arming a thin buffer is normal (startup, + // channel change, post-seek refill) and must not be judged. Later on, a thin + // buffer is exactly what a dead pipeline looks like — judge it, just slower. + const aheadRange = getLastBufferedRange(); + const aheadSec = aheadRange ? aheadRange.end - pos : 0; + if (aheadSec < 2 && stalledSec < STALL_DETECT_SEC * 2) { + return; + } + + // A silent freeze is turned into the SAME error the stock player already + // retries on, so the built-in retry path issues a brand new request + // (identical to tapping the channel again). No cooldown, no downgrade. + console.warn(`[r2h-watchdog] stall ${stalledSec.toFixed(1)}s at ${pos.toFixed(2)} -> stock retry`); + stallLastMoveAt = now; + stallSteady = false; + stallArmedPos = -1; + // Let the page recover on a FRESH slot. Reloading segments inside the wedged + // slot is slow (it keeps the same video element, worker and audio context); + // loading into the hidden slot and hard-switching — the channel-tap path — + // recovers noticeably faster. onError stays as a fallback if nobody listens. + let handled = false; + try { + // dispatchEvent() returns false when a listener called preventDefault(), + // i.e. the page took over the recovery. + handled = !window.dispatchEvent(new CustomEvent("r2h-playback-stall", { cancelable: true })); + } catch { + handled = false; + } + if (!handled) { + impl.suspend(); + impl.onError?.({ + category: "media", + detail: PlayerErrors.MEDIA_MSE_ERROR, + info: "Playback stalled without progress (watchdog)", + }); + } + } + + function startStallWatchdog(): void { + clearStallWatchdog(); + stallLoadAt = performance.now(); + stallTimer = window.setInterval(stallTick, STALL_TICK_MS); + } + const onVideoPlay = () => { markPlaybackUnlocked(); updateLiveState(); @@ -479,6 +665,7 @@ export function createMSEPlaybackController( destroyPCMPlayer(); initMSE(); initLiveHelpers(); + startStallWatchdog(); }, setLiveSync(enabled: boolean) { @@ -525,6 +712,7 @@ export function createMSEPlaybackController( destroyPCMPlayer(); destroyLiveSync?.(); destroyLiveSync = null; + clearStallWatchdog(); }, destroy() {