Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion web-ui/src/components/player/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
188 changes: 188 additions & 0 deletions web-ui/src/playback-engine/mse/playback-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | null = null;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -479,6 +665,7 @@ export function createMSEPlaybackController(
destroyPCMPlayer();
initMSE();
initLiveHelpers();
startStallWatchdog();
},

setLiveSync(enabled: boolean) {
Expand Down Expand Up @@ -525,6 +712,7 @@ export function createMSEPlaybackController(
destroyPCMPlayer();
destroyLiveSync?.();
destroyLiveSync = null;
clearStallWatchdog();
},

destroy() {
Expand Down