openvibers/openvibe.live Commits
Viewers recover from a frozen picture, and the player says what it saw (#470)
Changes
@@@ -55,6 +55,8 @@"js": ["/shared/loading.js","/js/player-shape.js",++ "/js/ov-viewer-health.js",++ "/js/ov-mini-player.js","/js/stream-player.js","/js/stream-pip.js"]@@@ -880,7 +880,12 @@ function teardownRoute(nextPath) {// released, and loaders still awaiting data for it see they are stale.if (window.ov) ov.nextRoute();ovClearStrandedOverlays();-- if (typeof destroyPlayer === 'function') destroyPlayer();++ if (typeof destroyPlayer === 'function') {++ // A live picture follows the viewer into the mini player instead of dying with the page++ // (the owner's rule, 2026-10-31). Every other case tears the player down as before.++ const kept = typeof window.OVMiniPlayer !== 'undefined' && window.OVMiniPlayer.keepAlive(nextPath);++ if (!kept) destroyPlayer();++ }if (typeof destroyChat === 'function') destroyChat();if (typeof stopCoinHeartbeat === 'function') stopCoinHeartbeat();if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(null);@@@ -941,6 +946,7 @@ document.addEventListener('visibilitychange', ovCheckRelease);window.addEventListener('pageshow', (e) => { if (e.persisted) ovCheckRelease(); });function _ovHoldsMedia() {try { if ([...broadcastState.streams.values()].some((s) => s.localStream || s.signalingWs || s.vodUploading)) return true; } catch { /* */ }++ try { if (window.OVMiniPlayer && window.OVMiniPlayer.isMini()) return true; } catch { /* */ }try { return !!(callState.joined || callState.connecting); } catch { return false; }}@@@ -26,7 +26,7 @@const POLL_MS = 30000;const FRAME_MS = 10000;-- const st = { el: null, current: null, poll: 0, frame: 0, player: null, unmuted: false, visible: true, io: null, gen: 0, skip: null };++ const st = { el: null, current: null, poll: 0, frame: 0, player: null, unmuted: false, visible: true, io: null, gen: 0, skip: null, retryTimer: 0, retries: 0, broken: false };const $ = (sel) => st.el && st.el.querySelector(sel);const escText = (v) => (typeof esc === 'function' ? esc(String(v == null ? '' : v)) : String(v == null ? '' : v));const constrained = () => {@@@ -72,6 +72,13 @@const player = flvjs.createPlayer({ type: 'flv', url, isLive: true }, { enableStashBuffer: false, stashInitialSize: 128, lazyLoad: false, autoCleanupSourceBuffer: true, autoCleanupMaxBackwardDuration: 20, autoCleanupMinBackwardDuration: 8 });player.attachMediaElement(video);player.load();++ // A dropped network or a source that went quiet leaves the picture standing still: the++ // player picks itself up (the owner's rule, 2026-10-31) instead of asking for a reload.++ player.on('error', (errType, errDetail) => {++ console.warn('[Featured] The live feed failed:', errType, errDetail);++ scheduleRetry();++ });++ video.addEventListener('error', () => { console.warn('[Featured] The live video failed'); scheduleRetry(); });video.play().catch(() => { /* muted autoplay is allowed; a refusal only means no sound */ });return {video,@@@ -179,9 +186,33 @@}if (gen !== st.gen) { try { p.destroy(); } catch { /* */ } return; }st.player = p;++ st.broken = false;if (!st.visible || document.hidden) p.pause();}++ /**++ * The live feed failed (a dropped network, a source that went away): try again by ourselves,++ * and fall back to the live frames when it keeps failing — a picture that stands still is++ * better than a broken player, and the reader can always open the channel.++ */++ function scheduleRetry(delay = 4000) {++ if (st.retryTimer) return;++ st.broken = true;++ st.retryTimer = setTimeout(() => {++ st.retryTimer = 0;++ if (!st.current || !st.el || st.el.hidden) return;++ st.retries++;++ if (st.retries > 4) {++ console.warn('[Featured] The live feed keeps failing — showing the live frames instead');++ destroyPlayer();++ st.player = playFrames(st.current, $('.hf-media'));++ return;++ }++ console.warn(`[Featured] Trying the live feed again (${st.retries}/4)`);++ startPlayer(st.current);++ }, delay);++ }++/** Only the parts that change between polls; the player stays put unless the stream changed. */function update(d) {const s = d.stream;@@@ -215,6 +246,7 @@const media = $('.hf-media');if (media) { media.classList.add('is-switching'); await new Promise((r) => setTimeout(r, 220)); }st.current = d.stream;++ st.retries = 0; // a new stream starts with a clean sheetrender(d);} else update(d);st.el.hidden = false;@@@ -230,6 +262,8 @@st.io.observe(st.el);} catch { st.io = { disconnect() { } }; }document.addEventListener('ov:page', (e) => { if (!e.detail || e.detail.page !== 'home') stop(); });++ // The network came back: a feed that had failed tries again by itself.++ window.addEventListener('online', () => { if (st.broken) scheduleRetry(500); });}/**@@@ -257,6 +291,7 @@}function stop() {clearInterval(st.poll); st.poll = 0;++ if (st.retryTimer) { clearTimeout(st.retryTimer); st.retryTimer = 0; }destroyPlayer();st.current = null;if (st.el) { st.el.hidden = true; st.el.innerHTML = ''; }@@@ -435,6 +435,31 @@ function retryPlayback() {loadPlayer(current);}++/**++ * A dropped network, or a tab that comes back to the front, must not leave the viewer with a++ * picture that stands still (the owner's rule, 2026-10-31): the player picks itself up.++ * • the media is ready again → play it (a tab that was hidden comes back paused),++ * • the media errored → start it over,++ * • the media is stuck → load the source again.++ */++function mpRecover(reason) {++ const media = mpMedia;++ if (!media || !mpCurrentRequestId) return;++ if (media.ended) return;++ if (media.error) { console.warn(`[MediaPlayer] The media failed (${reason}) — starting it over`); retryPlayback(); return; }++ if (media.readyState >= 3) {++ if (media.paused) media.play().catch(() => {});++ return;++ }++ console.warn(`[MediaPlayer] The media stalled (${reason}) — loading the source again`);++ try { media.load(); } catch { /* the browser will try anyway */ }++ media.play().catch(() => {});++}++++// The network came back, or the tab came back to the front: look at the media now.++window.addEventListener('online', () => mpRecover('the network came back'));++document.addEventListener('visibilitychange', () => { if (!document.hidden) mpRecover('the tab came back to the front'); });++async function reportFailure(requestId, errorMsg) {try {await mpApi(`/api/media/queue/${requestId}/fail`, {nnew file mode 100644@@@ -0,0 +1,146 @@++/* ───────────────────────────────────────────────────────────────++ ov-mini-player.js — the live player follows the viewer.++++ A viewer who walks away from a channel that is live does not have to come back to keep++ watching: the picture moves into a small box in the corner of the page and keeps playing++ until the viewer closes it (the owner's rule, 2026-10-31).++++ The box is not a second player: it holds the very same <video> element, moved out of the++ channel's container, so there is one session, one SFU transport and one sound. Coming back++ to the channel puts the element back where the channel expects it (and the channel then++ starts its own session, as it always did).++++ Rules:++ • only a live picture that is playing right now is worth keeping;++ • not on the way to the broadcast desk — that page needs the machine for its own video;++ • not on the way to another channel — that page brings its own player.++ ─────────────────────────────────────────────────────────────── */++++(function () {++ 'use strict';++ if (window.OVMiniPlayer) return;++++ const st = { box: null, video: null, home: null, title: '', href: '', styleEl: null };++++ /** A channel page: /@name or /stream/... */++ function isChannelPath(path) {++ return /^\/(?:@|stream)/.test(String(path || ''));++ }++++ /** The player is a live stream that is playing right now (SFU MediaStream, HLS or FLV). */++ function isPlaying() {++ const video = document.getElementById('video-element');++ if (!video) return false;++ if (!video.srcObject && !video.currentSrc && !video.src) return false;++ if (video.paused || video.ended) return false;++ return video.readyState >= 2;++ }++++ function ensureStyles() {++ if (st.styleEl || typeof document === 'undefined') return;++ st.styleEl = document.createElement('style');++ st.styleEl.id = 'ov-mini-player-style';++ st.styleEl.textContent = [++ '.ov-mini-player{position:fixed;right:14px;bottom:14px;width:min(46vw,320px);z-index:70;',++ 'aspect-ratio:16/9;border-radius:12px;overflow:hidden;background:#000;',++ 'box-shadow:0 10px 30px rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.12)}',++ '.ov-mini-player.video-container #video-element{width:100%;height:100%;object-fit:contain;display:block;',++ 'opacity:1;filter:none;transform:none;pointer-events:auto}',++ '.ov-mini-player-bar{position:absolute;left:0;right:0;top:0;display:flex;align-items:center;gap:6px;',++ 'padding:5px 6px;background:linear-gradient(rgba(0,0,0,.7),rgba(0,0,0,0));color:#fff;font-size:12px}',++ '.ov-mini-player-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',++ 'background:none;border:0;color:inherit;font:inherit;text-align:left;cursor:pointer;padding:2px}',++ '.ov-mini-player-close{flex:0 0 auto;border:0;background:rgba(255,255,255,.16);color:#fff;border-radius:6px;',++ 'width:22px;height:22px;line-height:1;cursor:pointer}',++ '.ov-mini-player-close:hover{background:rgba(255,255,255,.3)}',++ '@media (max-width:600px){.ov-mini-player{width:min(52vw,240px);right:8px;bottom:8px}}',++ ].join('');++ (document.head || document.documentElement).appendChild(st.styleEl);++ }++++ function build() {++ ensureStyles();++ const box = document.createElement('div');++ box.className = 'video-container ov-mini-player';++ box.id = 'ov-mini-player';++++ const bar = document.createElement('div');++ bar.className = 'ov-mini-player-bar';++++ const name = document.createElement('button');++ name.type = 'button';++ name.className = 'ov-mini-player-name';++ name.textContent = st.title || 'Live';++ name.title = st.href ? 'Back to the channel' : '';++ name.addEventListener('click', () => {++ if (st.href && typeof navigate === 'function') navigate(st.href);++ });++++ const close = document.createElement('button');++ close.type = 'button';++ close.className = 'ov-mini-player-close';++ close.setAttribute('aria-label', 'Close the mini player');++ close.textContent = '\u00d7';++ close.addEventListener('click', () => {++ // The viewer closed it: the player goes away for good.++ if (typeof destroyPlayer === 'function') { try { destroyPlayer(); } catch { /* */ } }++ release();++ });++++ bar.appendChild(name);++ bar.appendChild(close);++ box.appendChild(bar);++ st.box = box;++ document.body.appendChild(box);++ }++++ /**++ * Keep the player alive across a route change. Returns true when the player was kept (the++ * caller must then NOT tear it down).++ */++ function keepAlive(nextPath) {++ // A channel page (or the studio) brings its own player: give the element back and let++ // the normal teardown destroy this session. This comes before the "already a mini++ // player" test — otherwise walking back to the channel left two players fighting over++ // the one <video> element.++ const onPlayerPage = nextPath && (isChannelPath(nextPath) || String(nextPath).startsWith('/broadcast'));++ if (onPlayerPage) {++ if (st.box) release();++ return false;++ }++ if (st.box) return true; // already a mini player++ if (!isPlaying()) return false;++ const video = document.getElementById('video-element');++ const home = document.getElementById('video-container');++ if (!video || !home || video.parentElement !== home) return false;++++ st.video = video;++ st.home = home;++ st.title = (document.getElementById('ch-display-name')?.textContent || '').trim()++ || (document.getElementById('ch-stream-title')?.textContent || '').trim()++ || 'Live';++ st.href = location.pathname;++++ build();++ st.box.appendChild(video); // the picture follows the viewer++ return true;++ }++++ /** Put the video back and take the box away (the channel page is opening its own player). */++ function release() {++ if (!st.box) return;++ const box = st.box;++ st.box = null;++ if (st.video && st.home && st.video.parentElement === box) {++ try { st.home.appendChild(st.video); } catch { /* the page is gone */ }++ }++ st.video = null;++ st.home = null;++ try { box.remove(); } catch { /* */ }++ }++++ /** Is the picture in the mini player right now? */++ function isMini() { return !!st.box; }++++ window.OVMiniPlayer = { keepAlive, release, isMini, isChannelPath };++})();nnew file mode 100644@@@ -0,0 +1,511 @@++/* ───────────────────────────────────────────────────────────────++ ov-viewer-health.js — the shared "is the picture actually moving?" layer for every player.++++ The channel's live player, the home page's featured block and the VOD/clip player all show a++ picture that can stop moving while everything else looks fine: the signaling websocket is open,++ the transport says "connected", the audio keeps playing. The viewer sees a frozen picture and++ nothing else explains it (plan L23, 2026-10-31: stream 2833 froze a few seconds in and a++ reload played fine).++++ This module is the one place that knows how to answer "is it frozen?" and what to do about it:++++ · telemetry every 5 s the player sends one small message with the decoder's own numbers++ (framesDecoded, framesDropped, packetsLost, jitter, jitterBufferDelay, the codec++ and the decoder implementation). The server counts the freezes it sees.++ · recovery a ladder, in steps, cheapest first:++ 1. ask for a keyframe (the server asks the broadcaster's encoder for one,++ at most one ask per consumer every 2 s),++ 2. restart the receive transport (a fresh one, the media session rebuilt),++ 3. rebuild the whole viewer session.++ The page is never reloaded.++ · notice a small "The picture froze. Reconnecting…" line with a "Copy debug info" button++ that copies a report the owner can paste to us: the stream, the time, the++ browser, the codec, the decoder, the last telemetry sample and the steps taken.++ The report never carries a token, a cookie or anything else personal.++++ The ladder is a plain state machine with no DOM in it, so a test can drive it with a fake++ clock (test/viewer-health.test.js) and check the order of the steps.++ ─────────────────────────────────────────────────────────────── */++++(function () {++ 'use strict';++ if (window.OVViewerHealth) return;++++ /* How long a picture may stand still before we call it frozen, how many keyframes we ask++ for before we restart the transport, and how long each step is given. */++ const T = {++ freezeMs: 1500, // no new decoded frame for this long while connected = frozen++ serverFreezeMs: 2000, // the server's own threshold (a freeze it counts and logs)++ keyframeLimit: 3, // three keyframe asks, then the transport is restarted++ keyframeIntervalMs: 2000,// the server takes one keyframe ask per consumer every 2 s++ restartAfterMs: 8000, // eight seconds of asking = restart the transport anyway++ rebuildAfterMs: 8000, // a restarted transport gets eight seconds before a full rebuild++ silentMs: 20000, // no video packets at all for this long = the transport may be dead: climb the ladder++ telemetryMs: 5000, // one telemetry message every five seconds++ pollMs: 1000, // how often the decoder's numbers are read++ };++++ const STAGE_LABEL = {++ 0: 'playing',++ 1: 'asking for a keyframe',++ 2: 'restarting the transport',++ 3: 'rebuilding the session',++ };++++ /* ── the recovery ladder ─────────────────────────────────────────────── */++++ /**++ * The recovery ladder. Pure: it takes the time from opts.now() and returns the next step.++ * markFrame() a new decoded frame arrived (the picture moved)++ * setConnected() the transport is up (or down)++ * tick() → { action: 'keyframe' | 'restart-transport' | 'rebuild' | 'wait' | 'none' }++ */++ function createLadder(opts = {}) {++ const now = opts.now || (() => Date.now());++ const th = Object.assign({}, T, opts.thresholds || {});++ // A fresh transport that follows a ladder restart starts at stage 2: the keyframe asks++ // were already spent on the previous transport, so a still-frozen picture rebuilds the++ // whole session after the stage-2 grace period instead of asking all over again.++ let stage = Math.max(0, Math.min(3, Number(opts.startStage) || 0));++ let stageAt = now();++ let lastFrameAt = now();++ let lastAskAt = 0;++ let keyframeAsks = 0;++ let freezeCount = 0;++ let freezeMs = 0;++ let freezeStartedAt = 0;++ let connected = false;++ // Video packets since the last decoded frame, and when the last one came. A still picture with no packets is a++ // quiet sender (a static screen share sends frames only when something changes), not a frozen decoder: the++ // ladder climbs only when packets arrive and no frame decodes, or when nothing at all has arrived for silentMs.++ let packetsSinceFrame = false;++ let lastPacketAt = now();++++ const closeFreeze = (at) => {++ if (!freezeStartedAt) return;++ freezeMs += Math.max(0, at - freezeStartedAt);++ freezeStartedAt = 0;++ };++++ return {++ thresholds: th,++ get stage() { return stage; },++ get stageLabel() { return STAGE_LABEL[stage] || 'playing'; },++ get keyframeAsks() { return keyframeAsks; },++ get freezeCount() { return freezeCount; },++ /** Total time the picture stood still, including the freeze that is running now. */++ get freezeMs() { return freezeMs + (freezeStartedAt ? Math.max(0, now() - freezeStartedAt) : 0); },++ get connected() { return connected; },++ get stalledFor() { return now() - lastFrameAt; },++ /** The transport is up (or went down): a fresh transport has not delivered a frame yet. */++ setConnected(v) {++ const next = !!v;++ if (next === connected) return;++ connected = next;++ if (connected) { lastFrameAt = now(); lastPacketAt = now(); packetsSinceFrame = false; }++ else { stage = 0; stageAt = 0; keyframeAsks = 0; lastAskAt = 0; }++ },++ /** A new decoded frame: the picture moved. */++ markFrame(at) {++ const t0 = typeof at === 'number' ? at : now();++ lastFrameAt = t0;++ packetsSinceFrame = false;++ if (stage !== 0) { stage = 0; stageAt = 0; keyframeAsks = 0; lastAskAt = 0; }++ closeFreeze(t0);++ },++ /** Video packets arrived (the inbound packet count rose). */++ markPackets(at) {++ lastPacketAt = typeof at === 'number' ? at : now();++ packetsSinceFrame = true;++ },++ /** One step of the ladder. Returns what the caller should do now. */++ tick() {++ const t0 = now();++ if (!connected) return { action: 'none', stage, reason: 'not-connected', stalledFor: 0 };++ const stalledFor = t0 - lastFrameAt;++ if (stalledFor < th.freezeMs) {++ closeFreeze(t0);++ return { action: 'none', stage, stalledFor };++ }++ // Nothing arrived since the last frame and not for long: a quiet sender, not a freeze.++ if (!packetsSinceFrame && t0 - lastPacketAt < th.silentMs) {++ closeFreeze(t0);++ return { action: 'none', stage, stalledFor, reason: 'quiet' };++ }++ // A freeze lasts from the moment the picture stopped. The 2 s threshold is the++ // server's rule for calling it a freeze; the duration is measured from the stop.++ if (!freezeStartedAt) {++ freezeStartedAt = lastFrameAt;++ freezeCount++;++ }++ if (stage === 0) {++ stage = 1; stageAt = t0; keyframeAsks = 1; lastAskAt = t0;++ return { action: 'keyframe', stage, stalledFor, keyframeAsks };++ }++ if (stage === 1) {++ if (keyframeAsks >= th.keyframeLimit || t0 - stageAt >= th.restartAfterMs) {++ stage = 2; stageAt = t0;++ return { action: 'restart-transport', stage, stalledFor, keyframeAsks };++ }++ if (t0 - lastAskAt >= th.keyframeIntervalMs) {++ keyframeAsks++; lastAskAt = t0;++ return { action: 'keyframe', stage, stalledFor, keyframeAsks };++ }++ return { action: 'wait', stage, stalledFor, keyframeAsks };++ }++ if (stage === 2) {++ if (t0 - stageAt >= th.rebuildAfterMs) {++ stage = 3; stageAt = t0;++ return { action: 'rebuild', stage, stalledFor, keyframeAsks };++ }++ return { action: 'wait', stage, stalledFor, keyframeAsks };++ }++ return { action: 'wait', stage, stalledFor, keyframeAsks };++ },++ };++ }++++ /* ── the telemetry sample ───────────────────────────────────────────── */++++ const num = (v) => (typeof v === 'number' && isFinite(v) ? v : null);++ const str = (v, max = 64) => (typeof v === 'string' || typeof v === 'number' ? String(v).slice(0, max) : '');++++ /**++ * The numbers we send every 5 s, read from the browser's own RTCInboundRtpStreamStats.++ * Everything is optional: a browser that does not report a field simply leaves it out.++ */++ function readStats(sample) {++ const s = sample || {};++ return {++ framesDecoded: num(s.framesDecoded),++ framesDropped: num(s.framesDropped),++ framesPerSecond: num(s.framesPerSecond),++ packetsReceived: num(s.packetsReceived),++ packetsLost: num(s.packetsLost),++ jitter: num(s.jitter),++ jitterBufferDelay: num(s.jitterBufferDelay),++ nackCount: num(s.nackCount),++ pliCount: num(s.pliCount),++ decoderImplementation: str(s.decoderImplementation, 48),++ codecId: str(s.codecId, 24),++ };++ }++++ /** A telemetry message, ready to be sent over the signaling websocket. */++ function buildTelemetry({ streamId, stats = {}, freezeCount = 0, freezeMs = 0, codec = '', decoder = '' }) {++ return {++ type: 'sfu-viewer-telemetry',++ streamId: str(streamId, 32),++ data: {++ ...readStats(stats),++ codec: str(codec || stats.codecId, 32),++ decoder: str(decoder || stats.decoderImplementation, 48),++ freezeCount: num(freezeCount) || 0,++ freezeMs: num(freezeMs) || 0,++ at: Date.now(),++ },++ };++ }++++ /* ── the freeze notice ──────────────────────────────────────────────── */++++ let noticeEl = null;++ let styleEl = null;++++ function ensureStyles() {++ if (styleEl || typeof document === 'undefined') return;++ styleEl = document.createElement('style');++ styleEl.id = 'ov-viewer-health-style';++ styleEl.textContent = [++ '.ov-freeze-notice{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:40;',++ 'display:flex;align-items:center;gap:10px;max-width:min(92%,420px);padding:9px 12px;border-radius:10px;',++ 'background:rgba(12,12,16,.86);color:#fff;font:13px/1.35 system-ui,-apple-system,Segoe UI,sans-serif;',++ 'box-shadow:0 6px 20px rgba(0,0,0,.35)}',++ '.ov-freeze-notice[hidden]{display:none}',++ '.ov-freeze-notice-text{flex:1 1 auto;min-width:0}',++ '.ov-freeze-notice b{display:block;font-weight:600}',++ '.ov-freeze-notice small{opacity:.75;font-size:11px}',++ '.ov-freeze-notice button{flex:0 0 auto;border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.1);',++ 'color:#fff;border-radius:7px;padding:5px 9px;font:inherit;font-size:12px;cursor:pointer}',++ '.ov-freeze-notice button:hover{background:rgba(255,255,255,.2)}',++ ].join('');++ (document.head || document.documentElement).appendChild(styleEl);++ }++++ /**++ * Show the "The picture froze. Reconnecting…" line with its Copy debug info button.++ * Returns the notice element (so the caller can remove it).++ */++ function showFreezeNotice({ container, title = 'The picture froze. Reconnecting…', detail = '', onCopy }) {++ if (typeof document === 'undefined') return null;++ ensureStyles();++ // The host can be a function: a picture that moved into the mini player must show its++ // notice in the mini player, not in the page it came from.++ const host = (typeof container === 'function' ? container() : container) || document.body;++ if (!host) return null;++ if (!noticeEl || noticeEl.parentElement !== host) {++ if (noticeEl && noticeEl.parentElement) { try { noticeEl.remove(); } catch { /* */ } }++ noticeEl = document.createElement('div');++ noticeEl.className = 'ov-freeze-notice';++ noticeEl.setAttribute('role', 'status');++ const text = document.createElement('div');++ text.className = 'ov-freeze-notice-text';++ const b = document.createElement('b');++ const small = document.createElement('small');++ text.appendChild(b);++ text.appendChild(small);++ const btn = document.createElement('button');++ btn.type = 'button';++ btn.textContent = 'Copy debug info';++ btn.addEventListener('click', () => { try { onCopy && onCopy(); } catch (e) { /* nothing to do */ } });++ noticeEl.appendChild(text);++ noticeEl.appendChild(btn);++ noticeEl._title = b;++ noticeEl._detail = small;++ }++ noticeEl._title.textContent = title;++ noticeEl._detail.textContent = detail || '';++ if (!noticeEl.parentElement) host.appendChild(noticeEl);++ noticeEl.hidden = false;++ return noticeEl;++ }++++ function hideFreezeNotice() {++ if (noticeEl) noticeEl.hidden = true;++ }++++ /* ── the debug report ───────────────────────────────────────────────── */++++ /**++ * The report the "Copy debug info" button copies. It names the stream, the time, the browser,++ * the codec and the decoder, the last telemetry sample and the recovery steps taken — and++ * nothing else: no token, no cookie, no account, no personal data.++ */++ function buildDebugReport({ streamId, codec, decoder, telemetry, steps, browser, at }) {++ const t = telemetry || {};++ const lines = [++ 'OpenVibe.Live viewer report',++ `time=${new Date(at || Date.now()).toISOString()}`,++ `stream_id=${streamId || 'unknown'}`,++ `browser=${browser || (typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown')}`,++ `codec=${codec || t.codec || 'unknown'}`,++ `decoder=${decoder || t.decoder || t.decoderImplementation || 'unknown'}`,++ 'last_sample:',++ ];++ const fields = ['framesDecoded', 'framesDropped', 'framesPerSecond', 'packetsReceived', 'packetsLost',++ 'jitter', 'jitterBufferDelay', 'nackCount', 'pliCount', 'freezeCount', 'freezeMs'];++ for (const f of fields) {++ if (t[f] !== undefined && t[f] !== null) lines.push(` ${f}=${t[f]}`);++ }++ lines.push('recovery:');++ const list = steps && steps.length ? steps : ['(nothing yet)'];++ for (const s of list) lines.push(` - ${s}`);++ return lines.join('\n');++ }++++ /* ── the monitor ────────────────────────────────────────────────────── */++++ /**++ * Watches one playback session: reads the decoder's numbers, keeps the ladder, sends the++ * telemetry and shows the notice.++ *++ * getReceiver() → the RTCRtpReceiver (or null)++ * send(message) → send one signaling message++ * codec → the negotiated codec, e.g. "video/H264"++ * restartTransport() → a fresh receive transport (ladder step 2)++ * rebuild() → a whole new viewer session (ladder step 3)++ */++ function createMonitor(opts = {}) {++ const now = opts.now || (() => Date.now());++ const ladder = createLadder({ now, thresholds: opts.thresholds, startStage: opts.startStage });++ const steps = [];++ let timer = null;++ let lastStats = null;++ let lastFramesDecoded = null;++ let lastPacketsReceived = null;++ let lastSampleAt = 0;++ let stopped = false;++ let polling = false;++++ const note = (step) => {++ steps.push(`${new Date(now()).toISOString()} — ${step}`);++ if (steps.length > 20) steps.splice(0, steps.length - 20);++ };++++ /** A new decoded frame: the picture moved. The first one also means the media flows. */++ const noteFrame = () => {++ if (!ladder.connected) ladder.setConnected(true);++ ladder.markFrame();++ hideFreezeNotice();++ if (opts.onFrame) { try { opts.onFrame(); } catch { /* the caller's bookkeeping never breaks playback */ } }++ };++++ const readReceiverStats = async () => {++ const receiver = opts.getReceiver && opts.getReceiver();++ if (!receiver || typeof receiver.getStats !== 'function') return null;++ try {++ const stats = await receiver.getStats();++ let inbound = null;++ if (stats && typeof stats.forEach === 'function') {++ stats.forEach((r) => {++ if (r && r.type === 'inbound-rtp' && (r.kind === 'video' || r.mediaType === 'video')) inbound = r;++ });++ }++ return inbound || null;++ } catch { return null; }++ };++++ const poll = async () => {++ // getStats() can take longer than the 1 s tick under load; a second poll must not++ // tick the same ladder twice and ask for two keyframes.++ if (stopped || polling) return;++ polling = true;++ try {++ const sample = await readReceiverStats();++ if (stopped) return;++ // No video receiver (an audio-only stream) or no stats yet: nothing to judge, so the ladder waits.++ if (!sample) return;++ lastStats = sample;++ const pr = num(sample.packetsReceived);++ if (pr !== null && lastPacketsReceived !== null && pr > lastPacketsReceived) ladder.markPackets();++ if (pr !== null) lastPacketsReceived = pr;++ const fd = num(sample.framesDecoded);++ if (fd !== null && fd !== lastFramesDecoded) {++ lastFramesDecoded = fd;++ noteFrame();++ }++ const step = ladder.tick();++ if (step.action === 'keyframe') {++ note('asked for a keyframe');++ if (opts.onStage) opts.onStage(1, step);++ showNotice();++ if (opts.send) opts.send({ type: 'sfu-viewer-keyframe' });++ } else if (step.action === 'restart-transport') {++ note('restarted the receive transport');++ if (opts.onStage) opts.onStage(2, step);++ showNotice();++ if (opts.restartTransport) opts.restartTransport();++ } else if (step.action === 'rebuild') {++ note('rebuilt the viewer session');++ if (opts.onStage) opts.onStage(3, step);++ showNotice();++ if (opts.rebuild) opts.rebuild();++ }++ // The telemetry goes out every 5 s, whatever the ladder is doing.++ if (now() - lastSampleAt >= T.telemetryMs) {++ lastSampleAt = now();++ sendTelemetry();++ }++ } finally {++ polling = false;++ }++ };++++ const sendTelemetry = () => {++ if (!opts.send) return;++ const t = buildTelemetry({++ streamId: opts.streamId,++ stats: lastStats || {},++ freezeCount: ladder.freezeCount,++ freezeMs: ladder.freezeMs,++ codec: opts.codec,++ decoder: lastStats ? lastStats.decoderImplementation : '',++ });++ try { opts.send(t); } catch { /* the socket is gone; the ladder still runs */ }++ };++++ const showNotice = () => {++ if (!opts.notice) return;++ showFreezeNotice({++ container: opts.notice,++ title: 'The picture froze. Reconnecting…',++ detail: `Step: ${ladder.stageLabel}`,++ onCopy: () => {++ const text = buildDebugReport({++ streamId: opts.streamId,++ codec: opts.codec,++ decoder: lastStats ? lastStats.decoderImplementation : '',++ telemetry: {++ ...(lastStats ? readStats(lastStats) : {}),++ freezeCount: ladder.freezeCount,++ freezeMs: ladder.freezeMs,++ },++ steps,++ });++ copyText(text);++ if (opts.onCopy) opts.onCopy(text);++ },++ });++ };++++ return {++ ladder,++ get steps() { return steps.slice(); },++ get lastStats() { return lastStats; },++ /** The transport is up: the ladder starts counting from now. */++ connected() { ladder.setConnected(true); },++ /** The transport is gone: no ladder steps until it is back. */++ disconnected() { ladder.setConnected(false); },++ /** A new frame arrived outside the stats (requestVideoFrameCallback): the picture moved. */++ markFrame() { noteFrame(); },++ start() {++ if (timer || stopped) return;++ timer = setInterval(() => { poll().catch(() => {}); }, T.pollMs);++ },++ stop() {++ stopped = true;++ if (timer) { clearInterval(timer); timer = null; }++ hideFreezeNotice();++ },++ /** One poll by hand (the test drives it, and a page that comes back to the front polls at once). */++ poll,++ report() {++ return buildDebugReport({++ streamId: opts.streamId,++ codec: opts.codec,++ decoder: lastStats ? lastStats.decoderImplementation : '',++ telemetry: {++ ...(lastStats ? readStats(lastStats) : {}),++ freezeCount: ladder.freezeCount,++ freezeMs: ladder.freezeMs,++ },++ steps,++ });++ },++ };++ }++++ /** Copy text to the clipboard, with the old execCommand path for browsers without the async API. */++ function copyText(text) {++ try {++ if (navigator.clipboard && navigator.clipboard.writeText) {++ navigator.clipboard.writeText(text).catch(() => {});++ return true;++ }++ } catch { /* fall through */ }++ try {++ const el = document.createElement('textarea');++ el.value = text;++ el.style.position = 'fixed';++ el.style.opacity = '0';++ document.body.appendChild(el);++ el.focus();++ el.select();++ document.execCommand('copy');++ el.remove();++ return true;++ } catch { return false; }++ }++++ window.OVViewerHealth = {++ thresholds: T,++ createLadder,++ createMonitor,++ readStats,++ buildTelemetry,++ buildDebugReport,++ showFreezeNotice,++ hideFreezeNotice,++ copyText,++ };++})();@@@ -1184,7 +1184,7 @@ async function initWebRTC(stream) {const wsUrl = `${protocol}://${host}${portSuffix}/ws/broadcast?streamId=${streamRef.id}&role=viewer&token=${token}`;const ws = new WebSocket(wsUrl);-- player = { ws, video, myPeerId: null, watchSent: false, _wsUrl: wsUrl, _serverIceServers: null };++ player = { ws, video, myPeerId: null, watchSent: false, _wsUrl: wsUrl, _serverIceServers: null, _viewerRestarts: 0 };const _myGen = _playerGen; // this session's generation; stale once destroyPlayer bumps itlet _broadcasterDisconnectTimer = null;let _viewerReconnectTimer = null;@@@ -1244,6 +1244,35 @@ async function initWebRTC(stream) {}, delay);};++ // A player that comes back to the front, or a network that comes back, recovers by itself++ // (the owner's rule, 2026-10-31): never a reload, never "refresh the page".++ //++ // When the signaling socket is gone we cannot do anything here — the socket's own close++ // handler reconnects it. When it is open but the media transport died (a phone that moved++ // from Wi-Fi to cellular, a laptop that woke up), we watch again and the server hands us++ // a fresh transport. When the transport still looks fine, the monitor's poll decides++ // whether the picture is actually moving; a frozen picture is the ladder's business.++ const _resumeIfNeeded = (why) => {++ if (!player || _myGen !== _playerGen || _viewerIntentionalClose) return;++ if (!player.ws || player.ws.readyState !== WebSocket.OPEN) return;++ const state = player._sfuRecvTransport?.connectionState;++ if (state !== 'connected') {++ console.warn(`[Player] SFU viewer resuming after ${why} (transport=${state || 'none'}) — watching again`);++ player.watchSent = false;++ sendPlayerSignal({ type: 'watch' });++ player.watchSent = true;++ return;++ }++ // The transport looks alive: ask the monitor to look at the decoder's numbers now.++ if (player._viewerHealth) { try { player._viewerHealth.poll(); } catch { /* */ } }++ };++ const _onOnline = () => _resumeIfNeeded('the network came back');++ const _onVisible = () => { if (!document.hidden) _resumeIfNeeded('the tab came back to the front'); };++ window.addEventListener('online', _onOnline);++ document.addEventListener('visibilitychange', _onVisible);++ player._onOnline = _onOnline;++ player._onVisibility = _onVisible;++ws.onopen = () => {console.log('[Player] Broadcast signaling connected');_updateStatus('Looking for the live feed', {@@@ -1494,6 +1523,9 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatcif (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }// Clear any post-play frozen-video checker from the previous SFU sessionif (player._sfuFrozenInterval) { clearInterval(player._sfuFrozenInterval); player._sfuFrozenInterval = null; }++ // The viewer-health monitor (telemetry + the recovery ladder) of the previous session++ if (player._viewerHealth) { try { player._viewerHealth.stop(); } catch { /* */ } player._viewerHealth = null; }++ player._viewerHealthFor = null;// Cancel pending ICE/DTLS connect timeout for any prior SFU transportif (player._sfuTransportConnectTimeout) {clearTimeout(player._sfuTransportConnectTimeout);@@@ -1600,6 +1632,9 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatcif (!player || player._sfuRecvTransport !== recvTransport) return;console.log(`[Player] SFU recv transport state: ${state}`);if (state === 'connected') {++ // The ladder only escalates while the transport is up; the first frame (or the++ // first 1.5 s without one) decides what happens next.++ if (player._viewerHealth) { try { player._viewerHealth.connected(); } catch { /* */ } }updateStatus('Almost there', {phase: 'buffer',detail: 'ICE and DTLS are up. Waiting for the next keyframe and live media packets.',@@@ -1636,6 +1671,7 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc}if (state === 'failed' || state === 'closed') {// Transport is dead — clear both timers before re-watching++ if (player._viewerHealth) { try { player._viewerHealth.disconnected(); } catch { /* */ } }if (player._sfuTransportConnectTimeout) {clearTimeout(player._sfuTransportConnectTimeout);player._sfuTransportConnectTimeout = null;@@@ -1713,6 +1749,7 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc};let consumedCount = 0;++ let videoCodec = ''; // what the SFU negotiated for this viewer's video (e.g. "video/H264")for (const prod of producers) {const consumerParams = await _sfuViewerRequest(ws, 'sfu-viewer-consume', 'sfu-viewer-consumed', {transportId: recvTransport.id,@@@ -1748,7 +1785,10 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc// Keep a handle on the VIDEO receiver so the freeze watchdog can read framesDecoded// when requestVideoFrameCallback isn't available.-- if (consumer.kind === 'video') player._sfuVideoReceiver = consumer.rtpReceiver || null;++ if (consumer.kind === 'video') {++ player._sfuVideoReceiver = consumer.rtpReceiver || null;++ videoCodec = consumerParams?.rtpParameters?.codecs?.[0]?.mimeType || videoCodec;++ }mediaStream.addTrack(consumer.track);console.log(`[Player] SFU consumed ${consumer.kind} track — enabled=${consumer.track.enabled} muted=${consumer.track.muted} readyState=${consumer.track.readyState} paused=${consumer.paused} id=${consumer.id}`);@@@ -1777,6 +1817,77 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc// Set video sourcevideo.srcObject = mediaStream;video.style.display = 'block';++++ // Start the viewer-health monitor with the media, not on the first painted frame: a++ // transport that connects but never delivers a decoded frame is exactly the freeze we++ // must catch (the old watchdog only started on 'playing', so a black picture waited for++ // the 20 s first-frame timer). The monitor asks for a keyframe after 1.5 s without a++ // decoded frame, restarts the transport after three asks (or 8 s of asking), then++ // rebuilds the whole viewer session. Telemetry goes to the server every 5 s.++ let health = null;++ const startViewerHealth = () => {++ if (!window.OVViewerHealth || !player || player._sfuRecvTransport !== recvTransport) return;++ if (player._viewerHealthFor === recvTransport) return;++ player._viewerHealthFor = recvTransport;++ health = OVViewerHealth.createMonitor({++ streamId: streamRef?.id,++ codec: videoCodec,++ // A transport that was restarted by the previous ladder starts at stage 2: the++ // keyframe asks were already spent, so a picture that stays frozen rebuilds the++ // session after the grace period instead of asking the same encoder again.++ startStage: player._viewerRestarts ? 2 : 0,++ getReceiver: () => player?._sfuVideoReceiver || null,++ send: (m) => sendPlayerSignal(m),++ notice: () => document.getElementById('ov-mini-player') || document.getElementById('video-container') || document.body,++ onFrame: () => { if (player) player._viewerRestarts = 0; },++ onStage: (stage) => {++ if (stage === 1) console.warn('[Player] SFU video frozen — asking for a keyframe');++ if (stage === 2) console.warn('[Player] SFU video still frozen — restarting the receive transport');++ if (stage === 3) console.warn('[Player] SFU video still frozen — rebuilding the viewer session');++ },++ // Step 2: a fresh receive transport. The server drops this viewer's old one and++ // sends a new sfu-viewer-ready, so the media session is rebuilt without a reload.++ restartTransport: () => {++ if (!player || player._sfuRecvTransport !== recvTransport) return;++ player._viewerRestarts = (Number(player._viewerRestarts) || 0) + 1;++ player._sfuRecvTransport = null;++ try { recvTransport.close(); } catch { /* */ }++ sendPlayerSignal({ type: 'sfu-viewer-restart' });++ },++ // Step 3: the whole viewer session — a new signaling socket and a new SFU session.++ rebuild: () => {++ const stream = streamRef;++ try { if (player?.ws) player.ws.close(); } catch { /* */ }++ try { if (player?._sfuRecvTransport) player._sfuRecvTransport.close(); } catch { /* */ }++ player = null;++ _playerGen++; // the old session's callbacks must not act any more++ _sfuViewerSetupInProgress = false;++ if (stream) initWebRTC(stream);++ },++ });++ player._viewerHealth = health;++ health.start();++ // The monitor's 1.5 s rule replaces the 20 s first-frame timer.++ if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }++ if (recvTransport.connectionState === 'connected') health.connected();++++ // Hidden tabs never PAINT frames, so requestVideoFrameCallback goes silent even though++ // the decoder keeps running. The monitor's primary signal is the decoded-frame count,++ // which keeps rising in a background tab; rVFC is only an extra, faster "a frame was++ // presented" signal.++ if (typeof video.requestVideoFrameCallback === 'function') {++ const _onFrame = () => {++ if (!player || player._viewerHealth !== health) return;++ health.markFrame();++ if (player._sfuRecvTransport === recvTransport && video.srcObject) {++ try { video.requestVideoFrameCallback(_onFrame); } catch { /* */ }++ }++ };++ try { video.requestVideoFrameCallback(_onFrame); } catch { /* not supported */ }++ }++ };++ startViewerHealth();++startClipRecordingIfNeeded(mediaStream, streamRef?.id);const _onVideoPlaybackProgress = () => {@@@ -1802,112 +1913,16 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc_hidePlayerPlaceholder();if (!video.muted) document.getElementById('unmute-overlay')?.remove();-- // Post-play VIDEO-freeze watchdog.-- //-- // The old detector watched video.currentTime — but for a WebRTC MediaStream that-- // clock keeps advancing as long as the AUDIO track renders. So when only the VIDEO-- // decoder stalls (a lost keyframe after packet loss, a decode hiccup), the screen goes-- // black yet currentTime never stops → the stall was invisible → viewers had to refresh.-- //-- // Instead we watch actual VIDEO frame PRESENTATION via requestVideoFrameCallback-- // (falling back to getStats framesDecoded). On a video-only stall we recover in two-- // stages: (1) a cheap keyframe request via re-watch — the server responds with-- // consumer.requestKeyFrame(), which unsticks a missing-keyframe black frame without a-- // rebuild; (2) if that doesn't help within a few seconds, rebuild the receive transport-- // (full re-consume). No manual refresh needed.-- const _nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());-- const STALL_CHECK_MS = 1500;-- const STALL_TRIGGER_MS = 3500; // no new video frame for this long (while playing) = frozen-- const STALL_ESCALATE_MS = 6000; // still frozen this long after a keyframe nudge = rebuild-- let _lastFrameAt = _nowMs();-- let _lastFramesDecoded = -1;-- let _recoverStage = 0; // 0 = healthy, 1 = keyframe requested-- let _recoverAt = 0;-- let _usingRVFC = false;-- const _startedAt = _nowMs();-- // Hidden tabs never PAINT frames, so rVFC goes silent even though the decoder keeps-- // running. Treating that silence as "frozen" produced an endless nudge → rebuild →-- // reconnect loop (every ~12s, all night, per background tab) that also hammered the-- // broadcaster with keyframe requests. When the tab becomes visible again, restart-- // the clock instead of judging the time it spent hidden.-- const _onVisibility = () => { if (!document.hidden) _lastFrameAt = _nowMs(); };-- document.addEventListener('visibilitychange', _onVisibility);---- // Primary signal: rVFC fires only when a NEW frame is actually presented.-- if (typeof video.requestVideoFrameCallback === 'function') {-- _usingRVFC = true;-- const _onFrame = () => {-- _lastFrameAt = _nowMs();-- if (player?._sfuRecvTransport === recvTransport && video.srcObject) {-- try { video.requestVideoFrameCallback(_onFrame); } catch { /* */ }-- }-- };-- try { video.requestVideoFrameCallback(_onFrame); } catch { _usingRVFC = false; }-- }---- const _frozenInterval = setInterval(async () => {-- if (!player || player._sfuRecvTransport !== recvTransport) { clearInterval(_frozenInterval); return; }-- if (video.paused || !(video.srcObject?.active)) { clearInterval(_frozenInterval); document.removeEventListener('visibilitychange', _onVisibility); return; }---- // Decoded-frame count from getStats is the PRIMARY progress signal: it keeps-- // rising in background tabs, where rVFC (a paint signal) never fires. rVFC is-- // kept as an extra, faster signal when it is available.-- if (player._sfuVideoReceiver?.getStats) {-- try {-- const stats = await player._sfuVideoReceiver.getStats();-- let fd = null;-- stats.forEach((r) => { if (r.type === 'inbound-rtp' && (r.kind === 'video' || r.mediaType === 'video') && typeof r.framesDecoded === 'number') fd = r.framesDecoded; });-- if (fd != null && fd !== _lastFramesDecoded) { _lastFrameAt = _nowMs(); _lastFramesDecoded = fd; }-- } catch { /* */ }-- }---- const stalledFor = _nowMs() - _lastFrameAt;---- if (stalledFor <= STALL_TRIGGER_MS) {-- // Video is progressing — healthy (or recovered after a nudge).-- if (_recoverStage !== 0) { console.log('[Player] SFU video recovered'); _recoverStage = 0; }-- // Frames have flowed for a while on this transport → forget rebuild backoff.-- if (player._sfuRebuilds && _nowMs() - _startedAt > 15000) player._sfuRebuilds = 0;-- return;-- }---- // Never escalate while the page is hidden: nothing is painted, nobody is watching,-- // and a rebuild just churns the SFU. The visibility handler restarts the clock.-- if (document.hidden) return;---- if (_recoverStage === 0) {-- // Stage 1: cheap keyframe request (server calls consumer.requestKeyFrame() on re-watch).-- console.warn(`[Player] SFU video frozen ~${(stalledFor / 1000).toFixed(1)}s (audio may still be playing) — requesting keyframe via re-watch`);-- _recoverStage = 1; _recoverAt = _nowMs();-- player.watchSent = false;-- sendPlayerSignal({ type: 'watch' });-- player.watchSent = true;-- // Intentionally NOT calling _startWatchOfferTimeout() — SFU mode.-- } else if (_recoverStage === 1 && (_nowMs() - _recoverAt) > STALL_ESCALATE_MS) {-- // Stage 2: keyframe didn't unstick it — rebuild the receive transport.-- console.warn('[Player] SFU video still frozen after keyframe nudge — rebuilding receive transport');-- clearInterval(_frozenInterval);-- if (player._sfuFrozenInterval === _frozenInterval) player._sfuFrozenInterval = null;-- _hasVideoFrames = false;-- if (player._sfuRecvTransport === recvTransport) player._sfuRecvTransport = null;-- try { recvTransport.close(); } catch { /* */ }-- document.removeEventListener('visibilitychange', _onVisibility);-- // Exponential backoff between rebuilds (1s, 2s, 4s … 60s) so a transport-- // that genuinely never delivers frames can't loop at full speed.-- const rebuilds = player._sfuRebuilds = (player._sfuRebuilds || 0) + 1;-- const delay = Math.min(60000, 1000 * Math.pow(2, rebuilds - 1));-- console.warn(`[Player] SFU rebuild #${rebuilds} in ${delay}ms`);-- scheduleRewatch(delay);-- }-- }, STALL_CHECK_MS);-- player._sfuFrozenInterval = _frozenInterval;++ // The viewer-health monitor is already running (it starts with the media above);++ // this event only records that the first frame reached the screen.++ player?._viewerHealth?.markFrame();};video.addEventListener('playing', onPlaying);-- // NOTE: the first-frame stall timer is now armed in the 'connected'-- // connectionstatechange handler above, not here. Arming it here (before transport-- // connects) was the root cause of the 8-second re-watch loop: the timer fired before-- // ICE + DTLS completed AND before the first H.264 keyframe could arrive.++ // NOTE: the first-frame stall timer is armed in the 'connected'++ // connectionstatechange handler above. The health monitor takes over as soon as the++ // media is attached, so a connected transport with no frame is recovered in 1.5 s++ // rather than waiting out that 20 s grace window.// Start playback after all tracks consumedif (consumedCount >= 2) {@@@ -3586,6 +3601,8 @@ function getSavedPlayerAudioState() {/* ── Cleanup ──────────────────────────────────────────────────── */function destroyPlayer() {_playerGen++; // invalidate any in-flight WebRTC reconnect/rewatch closures from the old session++ // The mini player holds this session's video element: give it back before the player dies.++ try { window.OVMiniPlayer?.release(); } catch { /* */ }// The overlay owns a websocket and a mediasoup transport of its own, so it has to be// torn down explicitly — leaving it running would keep consuming a second stream.try { window.streamPip?.detach(); } catch { /* */ }@@@ -3610,6 +3627,9 @@ function destroyPlayer() {window._jsmpegDrawInterval = null;}if (player) {++ if (player._onOnline) { window.removeEventListener('online', player._onOnline); player._onOnline = null; }++ if (player._onVisibility) { document.removeEventListener('visibilitychange', player._onVisibility); player._onVisibility = null; }++ if (player._viewerHealth) { try { player._viewerHealth.stop(); } catch { /* */ } player._viewerHealth = null; }if (playerType === 'jsmpeg' && player.destroy) player.destroy();if (playerType === 'webrtc') {if (player.ws) player.ws.close();@@@ -22,9 +22,13 @@ const MAX_VIEWER_SOCKETS_PER_IP = 16;const config = require('../config');const { turnEntries } = require('../net/turn');const whipHandler = require('./whip-handler');++const viewerHealth = require('./viewer-health');const WS_HEARTBEAT_MS = 30000;const MAX_SEND_BACKPRESSURE = 512 * 1024;++// A keyframe ask from a viewer costs every viewer of that stream a large I-frame: one per++// consumer every 2 s (the viewer's own recovery ladder asks at that pace too).++const VIEWER_KEYFRAME_ASK_MS = 2000;class BroadcastServer extends EventEmitter {constructor() {@@@ -496,6 +500,17 @@ class BroadcastServer extends EventEmitter {}break;++ // ── Viewer health (plan L23): telemetry, keyframe asks, transport restarts ──++ case 'sfu-viewer-telemetry':++ if (client.role === 'viewer') this._handleSfuViewerTelemetry(ws, client, msg);++ break;++ case 'sfu-viewer-keyframe':++ if (client.role === 'viewer') this._handleSfuViewerKeyframe(ws, client);++ break;++ case 'sfu-viewer-restart':++ if (client.role === 'viewer') this._handleSfuViewerRestart(ws, client);++ break;++default:break;}@@@ -926,6 +941,87 @@ class BroadcastServer extends EventEmitter {console.log(`[Broadcast] SFU viewer ${client.peerId} consuming ${result.kind} for stream ${client.streamId}`);}++ /**++ * A viewer's telemetry (plan L23): the decoder's own numbers, every 5 s.++ *++ * The freeze test lives in server/streaming/viewer-health.js: no new decoded frame for 2 s++ * while packets keep arriving, or the player's own freezeCount went up. A freeze is logged++ * once — the stream, the peer, the codec — and counted for the metric.++ */++ _handleSfuViewerTelemetry(ws, client, msg, now = Date.now()) {++ const payload = msg && typeof msg.data === 'object' && msg.data ? msg.data : {};++ // A viewer may send these faster than the 5 s the player uses; one every 2 s is plenty.++ if (client._lastViewerTelemetryAt && now - client._lastViewerTelemetryAt < 2000) return;++ client._lastViewerTelemetryAt = now;++++ if (!client._viewerTracker) client._viewerTracker = new viewerHealth.ViewerTracker();++ const seen = client._viewerTracker.observe(payload, now);++ // One freeze, one line, one count — the same freeze seen again in the next sample is++ // not a second freeze.++ if (!seen.freeze || !seen.first) return;++++ const codec = seen.codec || 'unknown';++ const total = viewerHealth.noteFreeze({++ streamId: client.streamId,++ peerId: client.peerId,++ codec,++ decoder: seen.decoder,++ reason: seen.reason,++ detail: seen.detail,++ });++ console.log(`[Broadcast] Viewer ${client.peerId} froze on stream ${client.streamId} — ${seen.reason} | codec=${codec} decoder=${seen.decoder || 'unknown'} | ${seen.detail} | ${total} freeze(s) on this stream`);++ }++++ /**++ * A viewer asks for a keyframe (the first step of its recovery ladder).++ *++ * The ask goes to the broadcaster's encoder as a PLI. Every PLI forces a large I-frame, so it++ * is expensive for every viewer of that stream: one ask per consumer every 2 s, and only for++ * this viewer's own video consumer.++ */++ _handleSfuViewerKeyframe(ws, client) {++ const roomId = client._sfuViewerRoomId || `stream-${client.streamId}`;++ const room = webrtcSFU.rooms?.get(roomId);++ if (!room) { this.safeSend(ws, { type: 'sfu-keyframe-requested', asked: 0, throttled: 0 }); return; }++ const now = Date.now();++ if (!client._lastKeyframeAskAt) client._lastKeyframeAskAt = new Map();++ let asked = 0;++ let throttled = 0;++ for (const cid of (client._sfuViewerConsumerIds || [])) {++ const entry = room.consumers.get(cid);++ if (!entry?.consumer || entry.consumer.closed || entry.consumer.kind !== 'video') continue;++ const last = client._lastKeyframeAskAt.get(cid) || 0;++ if (now - last < VIEWER_KEYFRAME_ASK_MS) { throttled++; continue; }++ client._lastKeyframeAskAt.set(cid, now);++ asked++;++ entry.consumer.requestKeyFrame().catch(err => {++ console.warn(`[Broadcast] Keyframe ask failed for consumer ${cid} (${client.peerId}): ${err.message}`);++ });++ }++ console.log(`[Broadcast] Viewer ${client.peerId} asked for a keyframe on stream ${client.streamId} — asked=${asked} throttled=${throttled}`);++ this.safeSend(ws, { type: 'sfu-keyframe-requested', asked, throttled });++ }++++ /**++ * A viewer asks for a fresh receive transport (the second step of its recovery ladder): the++ * picture froze, the keyframes did not unstick it. The old transport is dropped and a new++ * sfu-viewer-ready goes out, so the media session is rebuilt without the page reloading.++ */++ _handleSfuViewerRestart(ws, client) {++ console.log(`[Broadcast] Viewer ${client.peerId} asked for a fresh transport on stream ${client.streamId}`);++ // The new transport starts a new decoder: its framesDecoded counts from zero. A tracker++ // carried over would read that reset as "no new frames" and invent a freeze.++ client._viewerTracker = null;++ client._lastViewerTelemetryAt = 0;++ this._cleanupSfuViewerTransport(client);++ this._tryCreateSfuViewer(ws, client).then((handled) => {++ if (!handled) this.safeSend(ws, { type: 'sfu-source-unavailable', reason: 'ingest_stale' });++ }).catch(err => {++ console.error(`[Broadcast] SFU viewer restart failed for ${client.peerId}: ${err.message}`);++ this.safeSend(ws, { type: 'sfu-viewer-error', error: err.message });++ });++ }++_cleanupSfuViewerTransport(client) {if (!client._sfuViewerTransportId) return;const roomId = client._sfuViewerRoomId || `stream-${client.streamId}`;nnew file mode 100644@@@ -0,0 +1,168 @@++'use strict';++/**++ * Viewer freezes (plan L23, 2026-10-31).++ *++ * A viewer's picture can stop moving while everything else looks fine: the signaling socket is++ * open, the SFU transport says "connected", the audio keeps playing. The owner saw it on stream++ * 2833 (2026-10-31 04:05 UTC): the picture played for a few seconds and then froze, and a reload++ * played fine. Without a number, "it froze" is a story; with one, it is a bug we can find.++ *++ * The player's own decoder reports every 5 s (public/js/ov-viewer-health.js): how many frames it++ * decoded, how many it dropped, how many packets were lost, the jitter, the jitter buffer delay,++ * the codec and the decoder. This module reads those samples and decides what a freeze is:++ *++ * • no new decoded frame for FREEZE_MS (2 s) while packets keep arriving — the picture is stuck++ * but the media is flowing, so the fault is in the decode or the display, not in the network;++ * • the player's own freezeCount went up — the player saw it too.++ *++ * A freeze is logged once (one line: the stream, the peer, the codec) and counted, per stream and++ * per codec, so /metrics can show it as live_viewer_freezes_total{codec}.++ */++const FREEZE_MS = 2000;++++/** The per-stream rolling count and the last freezes (a ring, so a long night cannot grow). */++const streams = new Map();++const RING = 20;++++let counter = null; // the Prometheus counter, once the registry exists++++/** Attach the metric to the metrics registry (server/web/observability.js calls this). */++function attachCounter(registry) {++ if (counter) return counter;++ if (!registry || typeof registry.counter !== 'function') return null;++ counter = registry.counter({++ name: 'live_viewer_freezes_total',++ help: 'Viewer freezes on live streams (a picture that stopped moving while packets kept arriving)',++ labelNames: ['codec'],++ });++ return counter;++}++++/** The counter itself (null before the registry is attached). */++function freezeCounter() { return counter; }++++const num = (v) => (typeof v === 'number' && isFinite(v) ? v : null);++const str = (v, max = 64) => (typeof v === 'string' ? v.slice(0, max) : '');++++/** A telemetry sample, cleaned up: the browser is not a trusted source of numbers. */++function normalize(sample) {++ const s = sample && typeof sample === 'object' ? sample : {};++ return {++ framesDecoded: num(s.framesDecoded),++ framesDropped: num(s.framesDropped),++ packetsReceived: num(s.packetsReceived),++ packetsLost: num(s.packetsLost),++ jitter: num(s.jitter),++ jitterBufferDelay: num(s.jitterBufferDelay),++ freezeCount: num(s.freezeCount),++ freezeMs: num(s.freezeMs),++ codec: str(s.codec, 32),++ decoder: str(s.decoder, 48),++ };++}++++/**++ * Reads one viewer's samples and says when the picture froze.++ *++ * tracker.observe(sample) → { freeze, first, reason, detail, codec }++ */++class ViewerTracker {++ constructor() {++ this.last = null; // the previous sample++ this.lastFrameAt = 0; // when a new decoded frame was last seen++ this.inFreeze = false; // the freeze is already logged++ this.clientFreezeCount = 0; // the player's own count, to spot a rise++ }++++ /**++ * One sample. Returns what the caller should log (nothing when the picture is moving).++ * @param {object} sample the telemetry payload (already normalized or not)++ * @param {number} now++ */++ observe(sample, now = Date.now()) {++ const s = normalize(sample);++ const out = { freeze: false, first: false, reason: '', detail: '', codec: s.codec, decoder: s.decoder };++ const prev = this.last;++ this.last = { ...s, at: now };++++ // The player saw a freeze: that is the strongest signal, and it counts each freeze once.++ const cf = s.freezeCount;++ if (cf !== null && cf > this.clientFreezeCount) {++ this.clientFreezeCount = cf;++ out.freeze = true;++ out.first = true;++ out.reason = 'the player saw the picture freeze';++ out.detail = `freezeCount=${cf} freezeMs=${s.freezeMs ?? '?'} framesDecoded=${s.framesDecoded ?? '?'} framesDropped=${s.framesDropped ?? '?'} packetsLost=${s.packetsLost ?? '?'} jitter=${s.jitter ?? '?'} jitterBufferDelay=${s.jitterBufferDelay ?? '?'}`;++ this.inFreeze = true;++ return out;++ }++ if (cf !== null) this.clientFreezeCount = cf;++++ if (!prev) { this.lastFrameAt = now; return out; }++++ const fd = s.framesDecoded, pfd = prev.framesDecoded;++ const pk = s.packetsReceived, ppk = prev.packetsReceived;++ if (fd !== null && pfd !== null && fd > pfd) {++ this.lastFrameAt = now;++ this.inFreeze = false;++ return out;++ }++ // No new frame — but are packets still arriving? If they are, the media flows and the++ // picture is stuck in the decoder (a lost keyframe, a decode hiccup). If they are not,++ // the network is the problem and the player recovers on its own.++ if (pk !== null && ppk !== null && pk > ppk) {++ const silent = now - (this.lastFrameAt || prev.at);++ if (silent >= FREEZE_MS) {++ out.freeze = true;++ out.reason = `no new frame for ${Math.round(silent / 1000)}s while packets keep arriving`;++ out.detail = `framesDecoded=${fd ?? '?'} packetsReceived=${pk} packetsLost=${s.packetsLost ?? '?'} jitter=${s.jitter ?? '?'} jitterBufferDelay=${s.jitterBufferDelay ?? '?'}`;++ if (!this.inFreeze) { out.first = true; this.inFreeze = true; }++ }++ }++ return out;++ }++}++++/** Count a freeze for a stream (and for the metric). */++function noteFreeze({ streamId, peerId, codec, reason, detail, decoder }) {++ const key = String(streamId || 'unknown');++ const c = String(codec || 'unknown').slice(0, 32) || 'unknown';++ let s = streams.get(key);++ if (!s) { s = { total: 0, byCodec: {}, recent: [] }; streams.set(key, s); }++ s.total++;++ s.byCodec[c] = (s.byCodec[c] || 0) + 1;++ s.recent.push({ at: Date.now(), peerId: String(peerId || ''), codec: c, decoder: String(decoder || ''), reason: String(reason || ''), detail: String(detail || '') });++ if (s.recent.length > RING) s.recent.splice(0, s.recent.length - RING);++ if (counter) counter.inc({ codec: c });++ return s.total;++}++++/** The freezes of one stream (the rolling count). */++function streamFreezes(streamId) {++ const s = streams.get(String(streamId));++ return s ? s.total : 0;++}++++/** Every stream's count, for a status endpoint or a test. */++function allStreamFreezes() {++ const out = {};++ for (const [k, v] of streams) out[k] = v.total;++ return out;++}++++/** Forget everything (a test, and a fresh process). */++function reset() {++ streams.clear();++}++++module.exports = {++ FREEZE_MS,++ ViewerTracker,++ attachCounter,++ freezeCounter,++ normalize,++ noteFreeze,++ streamFreezes,++ allStreamFreezes,++ reset,++};@@@ -6,7 +6,8 @@* GET /metrics Prometheus text, direct loopback callers only (404 through nginx or any proxy):* http_requests_total{method,route,status_class}, http_request_duration_seconds,* http_requests_in_flight, process/event-loop metrics, release_info{service,release},-- * live_streams_live, live_ws_connections{server}, live_events_outbox{status}++ * live_streams_live, live_ws_connections{server}, live_events_outbox{status},++ * live_viewer_freezes_total{codec} (a viewer's picture that stopped moving)* GET /api/ready 200 while Live can serve (boot finished, the database answers); 503 otherwise.* Media, the WebRTC SFU and the Network signing key are optional: when one is* missing the answer stays 200 with status "degraded" and names it, because Live@@@ -17,6 +18,7 @@*/const metrics = require('openvibe-shared/metrics');const { createReadiness } = require('openvibe-shared/ready');++const viewerHealth = require('../streaming/viewer-health');// Long-lived responses whose "duration" is a viewing session, not a request.const STREAMING_PATHS = [/^\/api\/live-events$/, /^\/api\/streams\/rtmp-proxy\//];@@@ -51,6 +53,8 @@ function mountMetrics(app, { release }) {*/function registerDomainGauges(registry, { liveStreams, wsServers = {}, outboxStatus }) {registry.gauge({ name: 'live_streams_live', help: 'Streams currently marked live (the list /api/streams serves)', collect: () => liveStreams() });++ // A viewer's picture that stopped moving while its packets kept arriving (plan L23).++ viewerHealth.attachCounter(registry);registry.gauge({name: 'live_ws_connections', help: 'Open WebSocket connections per WebSocket server', labelNames: ['server'],collect: () => Object.entries(wsServers)@@@ -206,33 +206,24 @@ assert.ok(playerSrc.includes('_sfuFrozenInterval'),'stream-player.js must have a frozen-video detector (_sfuFrozenInterval)');--// The detector used to tick to 30s and log. It now escalates in two stages, because a--// frozen picture with audio still playing is usually one missing keyframe: ask for one--// first, and only rebuild the transport if that does not unstick it. Assert the two--// stages rather than the old tick counter and its wording.--assert.ok(-- /STALL_TRIGGER_MS\s*=\s*\d+/.test(playerSrc) && /STALL_ESCALATE_MS\s*=\s*\d+/.test(playerSrc),-- 'frozen detector must have a trigger threshold and a later escalation threshold'--);--assert.ok(-- /requesting keyframe via re-watch/.test(playerSrc),-- 'stage 1 must ask for a keyframe before anything more disruptive'--);--assert.ok(-- /still frozen after keyframe nudge — rebuilding receive transport/.test(playerSrc),-- 'stage 2 must rebuild the receive transport when the keyframe did not help'--);++// The freeze ladder (public/js/ov-viewer-health.js, plan L23 R3, 2026-10-31): a picture that stops while video packets++// still arrive asks for a keyframe, then restarts the receive transport, then rebuilds the viewer session; never a++// page reload. test/viewer-health.test.js drives the ladder itself; here, the player wires each step.++assert.ok(/OVViewerHealth\.createMonitor\(/.test(playerSrc), 'the player runs the viewer-health monitor');++assert.ok(/sendPlayerSignal\(\{ type: 'sfu-viewer-restart' \}\)/.test(playerSrc), 'step 2 restarts the receive transport');++assert.ok(/rebuild: \(\) => \{[\s\S]{0,900}initWebRTC\(stream\)/.test(playerSrc), 'step 3 rebuilds the viewer session');++assert.ok(!/location\.reload\(/.test(playerSrc.slice(playerSrc.indexOf('createMonitor('), playerSrc.indexOf('createMonitor(') + 4000)), 'never a page reload');assert.ok(playerSrc.includes('player._sfuFrozenInterval = null') &&playerSrc.includes('clearInterval(player._sfuFrozenInterval)'),'stream-player.js must clear the frozen interval when transport changes');--// Frozen re-watch must NOT start P2P offer timeout--assert.ok(-- playerSrc.includes('Intentionally NOT calling _startWatchOfferTimeout()'),-- 'stream-player.js frozen-video re-watch must NOT call _startWatchOfferTimeout()'--);--console.log('OK I: frozen-video detector nudges with a keyframe, then rebuilds the transport');++// The freeze ladder never starts the P2P offer timeout: the SFU never answers with an 'offer'.++{++ const at = playerSrc.indexOf('OVViewerHealth.createMonitor(');++ assert.ok(!/_startWatchOfferTimeout\(\)/.test(playerSrc.slice(at, at + 3000).replace(/\/\/.*$/gm, '')), 'the freeze ladder must NOT call _startWatchOfferTimeout()');++}++console.log('OK I: the freeze ladder asks for a keyframe, restarts the transport, then rebuilds the session');// ── J: broadcaster auto-publishes into SFU (SFU is the only media path) ──assert.ok(nnew file mode 100644@@@ -0,0 +1,435 @@++'use strict';++/**++ * Viewer health (plan L23, 2026-10-31).++ *++ * The owner watched stream 2833 and the picture froze a few seconds in; a reload played fine.++ * Without a number, "it froze" is a story. These are the numbers and the ladder:++ *++ * A. The recovery ladder, driven by a fake clock: ask for a keyframe, then restart the++ * transport, then rebuild the session — in that order, and never a page reload.++ * B. A frame that arrives puts the ladder back to zero and closes the freeze.++ * C. The telemetry message: the decoder's own numbers, cleaned up.++ * D. The debug report the viewer copies: the stream, the time, the browser, the codec, the++ * decoder, the last sample and the steps — and nothing that identifies the viewer.++ * E. The server: a freeze is one log line and one count, and the count reaches the metric.++ * F. The server's rate limit on keyframe asks (one per consumer every 2 s).++ * G. The server's telemetry handler: one line per freeze, not one per sample.++ *++ * node test/viewer-health.test.js++ */++++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++// ── the player's own module, in a sandbox ────────────────────────────────────────────────────────++const playerSrc = fs.readFileSync(path.join(__dirname, '../public/js/ov-viewer-health.js'), 'utf8');++const sandbox = {++ window: {},++ console,++ setTimeout, clearTimeout, setInterval, clearInterval,++ Date, Math, JSON, Object, Array, String, Number, isFinite, parseInt,++};++sandbox.window.window = sandbox.window;++vm.createContext(sandbox);++vm.runInContext(playerSrc, sandbox, { filename: 'ov-viewer-health.js' });++const VH = sandbox.window.OVViewerHealth;++assert.ok(VH, 'ov-viewer-health.js must expose window.OVViewerHealth');++++let failures = 0;++function check(name, fn) {++ try { fn(); console.log(' ✓', name); }++ catch (e) { failures++; console.log(' ✗', name, '\n ', e.message); }++}++++// ── A: the ladder, with a fake clock ────────────────────────────────────────────────────────────++check('A1 the ladder asks for a keyframe after 1.5 s of a still picture', () => {++ let t = 10000;++ const ladder = VH.createLadder({ now: () => t });++ ladder.setConnected(true);++ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall++ assert.equal(ladder.tick().action, 'none', 'a moving picture asks for nothing');++ t += 1600;++ const step = ladder.tick();++ assert.equal(step.action, 'keyframe', 'a still picture asks for a keyframe');++ assert.equal(ladder.stage, 1);++});++++check('A2 the ladder asks three times, then restarts the transport, then rebuilds', () => {++ let t = 10000;++ const ladder = VH.createLadder({ now: () => t });++ ladder.setConnected(true);++ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall++ t += 1600;++ assert.equal(ladder.tick().action, 'keyframe');++ t += 2000;++ assert.equal(ladder.tick().action, 'keyframe');++ assert.equal(ladder.keyframeAsks, 2);++ t += 2000;++ assert.equal(ladder.tick().action, 'keyframe');++ assert.equal(ladder.keyframeAsks, 3);++ t += 2000;++ const restart = ladder.tick();++ assert.equal(restart.action, 'restart-transport', 'three keyframe asks and the transport is restarted');++ assert.equal(ladder.stage, 2);++ t += 8000;++ const rebuild = ladder.tick();++ assert.equal(rebuild.action, 'rebuild', 'a restarted transport that stays frozen rebuilds the session');++ assert.equal(ladder.stage, 3);++});++++check('A3 the ladder restarts the transport after 8 s of asking, even with fewer asks', () => {++ let t = 10000;++ const ladder = VH.createLadder({ now: () => t });++ ladder.setConnected(true);++ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall++ t += 1600;++ assert.equal(ladder.tick().action, 'keyframe');++ // The player asks again only every 2 s, so the asks are few: the 8 s rule must still fire.++ t += 8000;++ const step = ladder.tick();++ assert.equal(step.action, 'restart-transport');++});++++check('A4 the ladder asks again no more often than every 2 s', () => {++ let t = 10000;++ const ladder = VH.createLadder({ now: () => t });++ ladder.setConnected(true);++ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall++ t += 1600;++ assert.equal(ladder.tick().action, 'keyframe');++ t += 500; // too soon for another ask++ assert.equal(ladder.tick().action, 'wait');++ assert.equal(ladder.keyframeAsks, 1);++ t += 1500;++ assert.equal(ladder.tick().action, 'keyframe');++ assert.equal(ladder.keyframeAsks, 2);++});++++check('A5 a transport that is not connected never escalates', () => {++ let t = 10000;++ const ladder = VH.createLadder({ now: () => t });++ // Not connected: the ladder has nothing to say.++ t += 60000;++ assert.equal(ladder.tick().action, 'none');++ ladder.setConnected(true);++ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall++ t += 1600;++ assert.equal(ladder.tick().action, 'keyframe');++});++++check('A6 a transport restarted by the ladder starts at the rebuild step', () => {++ let t = 10000;++ const ladder = VH.createLadder({ now: () => t, startStage: 2 });++ ladder.setConnected(true);++ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall++ t += 1600;++ assert.equal(ladder.tick().action, 'wait', 'the keyframe asks were already spent on the old transport');++ t += 7000;++ assert.equal(ladder.tick().action, 'rebuild', 'a restarted transport that stays frozen rebuilds the session');++ assert.equal(ladder.stage, 3);++ // A frame that arrives after all that puts the ladder back to zero.++ ladder.markFrame();++ assert.equal(ladder.stage, 0);++});++++// ── B: a frame that arrives ──────────────────────────────────────────────────────────────────────++++check('A7 a quiet sender is not a freeze: no packets and no frames for a while asks for nothing', () => {++ let t = 10000;++ const ladder = VH.createLadder({ now: () => t });++ ladder.setConnected(true);++ t += 5000;++ assert.equal(ladder.tick().action, 'none', 'a static screen share sends nothing until something changes');++ t += 10000;++ assert.equal(ladder.tick().action, 'none', 'still nothing at 15 s');++ assert.equal(ladder.stage, 0);++});++++check('A8 twenty seconds with no video packets at all climbs the ladder (the transport may be dead)', () => {++ let t = 10000;++ const ladder = VH.createLadder({ now: () => t });++ ladder.setConnected(true);++ t += 20500;++ assert.equal(ladder.tick().action, 'keyframe');++});++++check('A9 packets after a frame, then no frame: the stall is caught from the packets', () => {++ let t = 10000;++ const ladder = VH.createLadder({ now: () => t });++ ladder.setConnected(true);++ t += 500; ladder.markFrame();++ t += 300; ladder.markPackets();++ t += 1600;++ assert.equal(ladder.tick().action, 'keyframe', 'packets arrived and nothing decoded');++});++++check('B1 a new frame puts the ladder back to zero and closes the freeze', () => {++ let t = 10000;++ const ladder = VH.createLadder({ now: () => t });++ ladder.setConnected(true);++ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall++ t += 1600;++ ladder.tick();++ assert.equal(ladder.stage, 1);++ assert.equal(ladder.freezeCount, 1, 'one freeze so far');++ t += 1000;++ ladder.markFrame();++ assert.equal(ladder.stage, 0, 'the picture moved: the ladder is back to zero');++ assert.ok(ladder.freezeMs >= 1000, 'the freeze duration is counted');++ ladder.markPackets();++ t += 1600;++ ladder.tick();++ assert.equal(ladder.freezeCount, 2, 'a second freeze is a second freeze');++});++++check('B2 the duration counts the freeze that is still running', () => {++ let t = 10000;++ const ladder = VH.createLadder({ now: () => t });++ ladder.setConnected(true);++ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall++ t += 1600;++ ladder.tick();++ assert.ok(ladder.freezeMs >= 1600, 'the running freeze is already part of the total');++ t += 2000;++ assert.ok(ladder.freezeMs >= 3600, 'and it keeps growing until a frame arrives');++ ladder.markFrame();++ t += 1000;++ assert.ok(ladder.freezeMs >= 3600, 'a frame closes it without losing the time');++});++++// ── C: the telemetry message ────────────────────────────────────────────────────────────────────++check('C1 the telemetry carries the decoder numbers and nothing else', () => {++ const stats = VH.readStats({++ framesDecoded: 120, framesDropped: 3, packetsReceived: 900, packetsLost: 2,++ jitter: 0.01, jitterBufferDelay: 0.05, decoderImplementation: 'FFmpegVideoDecoder',++ codecId: 'H264', somethingElse: 'ignored', framesDecodedX: 'nope',++ });++ assert.equal(stats.framesDecoded, 120);++ assert.equal(stats.decoderImplementation, 'FFmpegVideoDecoder');++ assert.equal(stats.somethingElse, undefined, 'an unknown field is not carried over');++ const msg = VH.buildTelemetry({ streamId: 2833, stats, freezeCount: 1, freezeMs: 2500, codec: 'video/H264' });++ assert.equal(msg.type, 'sfu-viewer-telemetry');++ assert.equal(msg.streamId, '2833');++ assert.equal(msg.data.codec, 'video/H264');++ assert.equal(msg.data.decoder, 'FFmpegVideoDecoder');++ assert.equal(msg.data.freezeCount, 1);++ assert.equal(msg.data.freezeMs, 2500);++ assert.ok(msg.data.at > 0);++});++++check('C2 a browser that reports nothing still sends a usable message', () => {++ const msg = VH.buildTelemetry({ streamId: '7', stats: {} });++ assert.equal(msg.type, 'sfu-viewer-telemetry');++ assert.equal(msg.data.framesDecoded, null);++ assert.equal(msg.data.freezeCount, 0);++ assert.equal(msg.data.codec, '');++});++++// ── D: the report the viewer copies ─────────────────────────────────────────────────────────────++check('D1 the report names the stream, the codec, the decoder, the sample and the steps', () => {++ const text = VH.buildDebugReport({++ streamId: 2833,++ codec: 'video/H264',++ decoder: 'VideoToolboxVideoDecoder',++ telemetry: { framesDecoded: 10, packetsLost: 1, freezeCount: 1, freezeMs: 900 },++ steps: ['asked for a keyframe', 'restarted the receive transport'],++ browser: 'TestBrowser/1.0',++ at: Date.UTC(2026, 9, 31, 4, 6, 22),++ });++ assert.ok(text.includes('stream_id=2833'));++ assert.ok(text.includes('browser=TestBrowser/1.0'));++ assert.ok(text.includes('codec=video/H264'));++ assert.ok(text.includes('decoder=VideoToolboxVideoDecoder'));++ assert.ok(text.includes('framesDecoded=10'));++ assert.ok(text.includes('freezeCount=1'));++ assert.ok(text.includes('asked for a keyframe'));++ assert.ok(text.includes('restarted the receive transport'));++ assert.ok(text.includes('2026-10-31T04:06:22'), 'the time of the freeze is in the report');++});++++check('D2 the report carries no token, no cookie and no personal data', () => {++ const text = VH.buildDebugReport({++ streamId: 2833, codec: 'video/H264', decoder: 'x', telemetry: {}, steps: [], browser: 'UA',++ });++ for (const forbidden of ['token', 'cookie', 'Bearer', 'authorization', 'password']) {++ assert.ok(!text.toLowerCase().includes(forbidden.toLowerCase()), `the report must not mention ${forbidden}`);++ }++});++++// ── E: the server's freeze detection and its metric ─────────────────────────────────────────────++const viewerHealth = require('../server/streaming/viewer-health');++++check('E1 a moving picture is not a freeze', () => {++ const tracker = new viewerHealth.ViewerTracker();++ let seen = tracker.observe({ framesDecoded: 10, packetsReceived: 100, codec: 'video/H264' }, 1000);++ assert.equal(seen.freeze, false);++ seen = tracker.observe({ framesDecoded: 40, packetsReceived: 400, codec: 'video/H264' }, 6000);++ assert.equal(seen.freeze, false);++});++++check('E2 no new frame for 2 s while packets arrive is a freeze, logged once', () => {++ const tracker = new viewerHealth.ViewerTracker();++ tracker.observe({ framesDecoded: 40, packetsReceived: 400, codec: 'video/H264' }, 1000);++ // The frames stop but the packets keep coming: the media flows, the picture is stuck.++ let seen = tracker.observe({ framesDecoded: 40, packetsReceived: 500, codec: 'video/H264' }, 2000);++ assert.equal(seen.freeze, false, 'one second of silence is not yet a freeze');++ seen = tracker.observe({ framesDecoded: 40, packetsReceived: 600, codec: 'video/H264' }, 3500);++ assert.equal(seen.freeze, true, '2.5 s without a frame while packets arrive is a freeze');++ assert.equal(seen.first, true, 'the freeze is logged the first time it is seen');++ assert.ok(seen.reason.includes('no new frame'));++ // The next sample is the same freeze: it is not logged again.++ seen = tracker.observe({ framesDecoded: 40, packetsReceived: 700, codec: 'video/H264' }, 5000);++ assert.equal(seen.freeze, true);++ assert.equal(seen.first, false, 'the same freeze is not logged twice');++});++++check('E3 a picture that moves again ends the freeze', () => {++ const tracker = new viewerHealth.ViewerTracker();++ tracker.observe({ framesDecoded: 40, packetsReceived: 400 }, 1000);++ tracker.observe({ framesDecoded: 40, packetsReceived: 500 }, 3500);++ tracker.observe({ framesDecoded: 90, packetsReceived: 900 }, 4000);++ const seen = tracker.observe({ framesDecoded: 130, packetsReceived: 950 }, 9000);++ assert.equal(seen.freeze, false, 'the picture kept moving: no freeze');++});++++check('E4 the player saying it saw a freeze is a freeze too', () => {++ const tracker = new viewerHealth.ViewerTracker();++ tracker.observe({ framesDecoded: 40, packetsReceived: 400, freezeCount: 0 }, 1000);++ const seen = tracker.observe({ framesDecoded: 40, packetsReceived: 500, freezeCount: 1 }, 6000);++ assert.equal(seen.freeze, true);++ assert.ok(seen.reason.includes('player'));++});++++check('E5 a freeze is counted per stream and reaches the metric', () => {++ viewerHealth.reset();++ const series = [];++ const registry = {++ counter(opts) {++ const s = {};++ const c = {++ name: opts.name,++ inc(labels) {++ const key = JSON.stringify(labels || {});++ s[key] = (s[key] || 0) + 1;++ series.push({ key, count: s[key] });++ },++ };++ return c;++ },++ };++ viewerHealth.attachCounter(registry);++ const total = viewerHealth.noteFreeze({ streamId: 2833, peerId: 'peer-4', codec: 'video/H264', reason: 'no new frame', detail: 'framesDecoded=40' });++ assert.equal(total, 1);++ assert.equal(viewerHealth.streamFreezes(2833), 1);++ assert.equal(viewerHealth.streamFreezes(9999), 0);++ assert.equal(series.length, 1);++ assert.equal(series[0].key, JSON.stringify({ codec: 'video/H264' }));++ assert.equal(series[0].count, 1);++ viewerHealth.noteFreeze({ streamId: 2833, peerId: 'peer-5', codec: 'video/H264' });++ assert.equal(viewerHealth.streamFreezes(2833), 2);++ viewerHealth.reset();++ assert.equal(viewerHealth.streamFreezes(2833), 0);++});++++check('E6 a telemetry payload from the browser is cleaned up before it is counted', () => {++ const s = viewerHealth.normalize({ framesDecoded: 'many', packetsLost: 3, codec: 'x'.repeat(200), freezeCount: null });++ assert.equal(s.framesDecoded, null, 'a number that is not a number is dropped');++ assert.equal(s.packetsLost, 3);++ assert.equal(s.codec.length, 32, 'a long codec name is cut');++ assert.equal(s.freezeCount, null);++});++++// ── F: the server's rate limit on keyframe asks ─────────────────────────────────────────────────++const broadcastServer = require('../server/streaming/broadcast-server');++const webrtcSFU = require('../server/streaming/webrtc-sfu');++const { WebSocket } = require('ws');++++check('F1 one keyframe ask per consumer every 2 s', () => {++ const asked = [];++ const room = {++ consumers: new Map(),++ transports: new Map(),++ producers: new Map(),++ };++ room.consumers.set('c-video', {++ consumer: { kind: 'video', closed: false, requestKeyFrame: async () => { asked.push('c-video'); } },++ });++ room.consumers.set('c-audio', {++ consumer: { kind: 'audio', closed: false, requestKeyFrame: async () => { asked.push('c-audio'); } },++ });++ webrtcSFU.rooms.set('stream-4242', room);++ const sent = [];++ const ws = { readyState: WebSocket.OPEN, bufferedAmount: 0, send: (raw) => sent.push(JSON.parse(raw)) };++ const client = {++ role: 'viewer', peerId: 'peer-test', streamId: 4242,++ _sfuViewerRoomId: 'stream-4242', _sfuViewerConsumerIds: ['c-video', 'c-audio'],++ };++++ broadcastServer._handleSfuViewerKeyframe(ws, client);++ assert.deepEqual(asked, ['c-video'], 'only the video consumer is asked, and only once');++++ // Straight away: the 2 s rate limit holds.++ broadcastServer._handleSfuViewerKeyframe(ws, client);++ assert.deepEqual(asked, ['c-video'], 'a second ask inside the window is throttled');++ assert.ok(sent.length === 2);++ assert.equal(sent[1].throttled, 1, 'the viewer is told the ask was throttled');++++ // After the window: the ask goes through again.++ const last = client._lastKeyframeAskAt.get('c-video');++ client._lastKeyframeAskAt.set('c-video', last - 2000);++ broadcastServer._handleSfuViewerKeyframe(ws, client);++ assert.deepEqual(asked, ['c-video', 'c-video'], 'after 2 s the ask goes through');++ webrtcSFU.rooms.delete('stream-4242');++});++++// ── G: the server's telemetry handler ───────────────────────────────────────────────────────────++check('G1 the server logs one line per freeze and counts it', () => {++ viewerHealth.reset();++ const logged = [];++ const realLog = console.log;++ console.log = (...args) => { logged.push(args.join(' ')); };++ try {++ const sent = [];++ const ws = { readyState: WebSocket.OPEN, bufferedAmount: 0, send: (raw) => sent.push(JSON.parse(raw)) };++ const client = { role: 'viewer', peerId: 'peer-4', streamId: 2833 };++ broadcastServer._handleSfuViewerTelemetry(ws, client, { data: { framesDecoded: 40, packetsReceived: 400, codec: 'video/H264' } }, 1000);++ // Three seconds later the frames stop but the packets keep coming.++ broadcastServer._handleSfuViewerTelemetry(ws, client, { data: { framesDecoded: 40, packetsReceived: 500, codec: 'video/H264' } }, 4000);++ broadcastServer._handleSfuViewerTelemetry(ws, client, { data: { framesDecoded: 40, packetsReceived: 600, codec: 'video/H264' } }, 7000);++++ const freezeLines = logged.filter((l) => l.includes('froze on stream 2833'));++ assert.equal(freezeLines.length, 1, 'the freeze is logged once, not once per sample');++ assert.ok(freezeLines[0].includes('peer-4'));++ assert.ok(freezeLines[0].includes('video/H264'));++ assert.equal(viewerHealth.streamFreezes(2833), 1);++ } finally {++ console.log = realLog;++ viewerHealth.reset();++ }++});++++check('G2 a telemetry message that arrives too fast is ignored', () => {++ viewerHealth.reset();++ const logged = [];++ const realLog = console.log;++ console.log = (...args) => { logged.push(args.join(' ')); };++ try {++ const ws = { readyState: WebSocket.OPEN, bufferedAmount: 0, send: () => {} };++ const client = { role: 'viewer', peerId: 'peer-5', streamId: 2834 };++ broadcastServer._handleSfuViewerTelemetry(ws, client, { data: { framesDecoded: 1, packetsReceived: 1, codec: 'video/VP8' } }, 1000);++ broadcastServer._handleSfuViewerTelemetry(ws, client, { data: { framesDecoded: 1, packetsReceived: 2, codec: 'video/VP8' } }, 1500);++ const freezeLines = logged.filter((l) => l.includes('froze on stream 2834'));++ assert.equal(freezeLines.length, 0, 'a flood of telemetry does not invent freezes');++ } finally {++ console.log = realLog;++ viewerHealth.reset();++ }++});++++if (failures) {++ console.log(`\n${failures} check(s) failed`);++ process.exit(1);++}++console.log('\nviewer health: all checks passed');
@@ -55,6 +55,8 @@
@@ -55,6 +55,8 @@
"js": [
"js": [
"/shared/loading.js",
"/shared/loading.js",
"/js/player-shape.js",
"/js/player-shape.js",
+ "/js/ov-viewer-health.js",
+ "/js/ov-mini-player.js",
"/js/stream-player.js",
"/js/stream-player.js",
"/js/stream-pip.js"
"/js/stream-pip.js"
]
]
@@ -880,7 +880,12 @@ function teardownRoute(nextPath) {
@@ -880,7 +880,12 @@ function teardownRoute(nextPath) {
// released, and loaders still awaiting data for it see they are stale.
// released, and loaders still awaiting data for it see they are stale.
if (window.ov) ov.nextRoute();
if (window.ov) ov.nextRoute();
ovClearStrandedOverlays();
ovClearStrandedOverlays();
- if (typeof destroyPlayer === 'function') destroyPlayer();
+ if (typeof destroyPlayer === 'function') {
+ // A live picture follows the viewer into the mini player instead of dying with the page
+ // (the owner's rule, 2026-10-31). Every other case tears the player down as before.
+ const kept = typeof window.OVMiniPlayer !== 'undefined' && window.OVMiniPlayer.keepAlive(nextPath);
+ if (!kept) destroyPlayer();
+ }
if (typeof destroyChat === 'function') destroyChat();
if (typeof destroyChat === 'function') destroyChat();
if (typeof stopCoinHeartbeat === 'function') stopCoinHeartbeat();
if (typeof stopCoinHeartbeat === 'function') stopCoinHeartbeat();
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(null);
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(null);
@@ -941,6 +946,7 @@ document.addEventListener('visibilitychange', ovCheckRelease);
@@ -941,6 +946,7 @@ document.addEventListener('visibilitychange', ovCheckRelease);
window.addEventListener('pageshow', (e) => { if (e.persisted) ovCheckRelease(); });
window.addEventListener('pageshow', (e) => { if (e.persisted) ovCheckRelease(); });
function _ovHoldsMedia() {
function _ovHoldsMedia() {
try { if ([...broadcastState.streams.values()].some((s) => s.localStream || s.signalingWs || s.vodUploading)) return true; } catch { /* */ }
try { if ([...broadcastState.streams.values()].some((s) => s.localStream || s.signalingWs || s.vodUploading)) return true; } catch { /* */ }
+ try { if (window.OVMiniPlayer && window.OVMiniPlayer.isMini()) return true; } catch { /* */ }
try { return !!(callState.joined || callState.connecting); } catch { return false; }
try { return !!(callState.joined || callState.connecting); } catch { return false; }
}
}
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
const POLL_MS = 30000;
const POLL_MS = 30000;
const FRAME_MS = 10000;
const FRAME_MS = 10000;
- const st = { el: null, current: null, poll: 0, frame: 0, player: null, unmuted: false, visible: true, io: null, gen: 0, skip: null };
+ const st = { el: null, current: null, poll: 0, frame: 0, player: null, unmuted: false, visible: true, io: null, gen: 0, skip: null, retryTimer: 0, retries: 0, broken: false };
const $ = (sel) => st.el && st.el.querySelector(sel);
const $ = (sel) => st.el && st.el.querySelector(sel);
const escText = (v) => (typeof esc === 'function' ? esc(String(v == null ? '' : v)) : String(v == null ? '' : v));
const escText = (v) => (typeof esc === 'function' ? esc(String(v == null ? '' : v)) : String(v == null ? '' : v));
const constrained = () => {
const constrained = () => {
@@ -72,6 +72,13 @@
@@ -72,6 +72,13 @@
const player = flvjs.createPlayer({ type: 'flv', url, isLive: true }, { enableStashBuffer: false, stashInitialSize: 128, lazyLoad: false, autoCleanupSourceBuffer: true, autoCleanupMaxBackwardDuration: 20, autoCleanupMinBackwardDuration: 8 });
const player = flvjs.createPlayer({ type: 'flv', url, isLive: true }, { enableStashBuffer: false, stashInitialSize: 128, lazyLoad: false, autoCleanupSourceBuffer: true, autoCleanupMaxBackwardDuration: 20, autoCleanupMinBackwardDuration: 8 });
player.attachMediaElement(video);
player.attachMediaElement(video);
player.load();
player.load();
+ // A dropped network or a source that went quiet leaves the picture standing still: the
+ // player picks itself up (the owner's rule, 2026-10-31) instead of asking for a reload.
+ player.on('error', (errType, errDetail) => {
+ console.warn('[Featured] The live feed failed:', errType, errDetail);
+ scheduleRetry();
+ });
+ video.addEventListener('error', () => { console.warn('[Featured] The live video failed'); scheduleRetry(); });
video.play().catch(() => { /* muted autoplay is allowed; a refusal only means no sound */ });
video.play().catch(() => { /* muted autoplay is allowed; a refusal only means no sound */ });
return {
return {
video,
video,
@@ -179,9 +186,33 @@
@@ -179,9 +186,33 @@
}
}
if (gen !== st.gen) { try { p.destroy(); } catch { /* */ } return; }
if (gen !== st.gen) { try { p.destroy(); } catch { /* */ } return; }
st.player = p;
st.player = p;
+ st.broken = false;
if (!st.visible || document.hidden) p.pause();
if (!st.visible || document.hidden) p.pause();
}
}
+ /**
+ * The live feed failed (a dropped network, a source that went away): try again by ourselves,
+ * and fall back to the live frames when it keeps failing — a picture that stands still is
+ * better than a broken player, and the reader can always open the channel.
+ */
+ function scheduleRetry(delay = 4000) {
+ if (st.retryTimer) return;
+ st.broken = true;
+ st.retryTimer = setTimeout(() => {
+ st.retryTimer = 0;
+ if (!st.current || !st.el || st.el.hidden) return;
+ st.retries++;
+ if (st.retries > 4) {
+ console.warn('[Featured] The live feed keeps failing — showing the live frames instead');
+ destroyPlayer();
+ st.player = playFrames(st.current, $('.hf-media'));
+ return;
+ }
+ console.warn(`[Featured] Trying the live feed again (${st.retries}/4)`);
+ startPlayer(st.current);
+ }, delay);
+ }
+
/** Only the parts that change between polls; the player stays put unless the stream changed. */
/** Only the parts that change between polls; the player stays put unless the stream changed. */
function update(d) {
function update(d) {
const s = d.stream;
const s = d.stream;
@@ -215,6 +246,7 @@
@@ -215,6 +246,7 @@
const media = $('.hf-media');
const media = $('.hf-media');
if (media) { media.classList.add('is-switching'); await new Promise((r) => setTimeout(r, 220)); }
if (media) { media.classList.add('is-switching'); await new Promise((r) => setTimeout(r, 220)); }
st.current = d.stream;
st.current = d.stream;
+ st.retries = 0; // a new stream starts with a clean sheet
render(d);
render(d);
} else update(d);
} else update(d);
st.el.hidden = false;
st.el.hidden = false;
@@ -230,6 +262,8 @@
@@ -230,6 +262,8 @@
st.io.observe(st.el);
st.io.observe(st.el);
} catch { st.io = { disconnect() { } }; }
} catch { st.io = { disconnect() { } }; }
document.addEventListener('ov:page', (e) => { if (!e.detail || e.detail.page !== 'home') stop(); });
document.addEventListener('ov:page', (e) => { if (!e.detail || e.detail.page !== 'home') stop(); });
+ // The network came back: a feed that had failed tries again by itself.
+ window.addEventListener('online', () => { if (st.broken) scheduleRetry(500); });
}
}
/**
/**
@@ -257,6 +291,7 @@
@@ -257,6 +291,7 @@
}
}
function stop() {
function stop() {
clearInterval(st.poll); st.poll = 0;
clearInterval(st.poll); st.poll = 0;
+ if (st.retryTimer) { clearTimeout(st.retryTimer); st.retryTimer = 0; }
destroyPlayer();
destroyPlayer();
st.current = null;
st.current = null;
if (st.el) { st.el.hidden = true; st.el.innerHTML = ''; }
if (st.el) { st.el.hidden = true; st.el.innerHTML = ''; }
@@ -435,6 +435,31 @@ function retryPlayback() {
@@ -435,6 +435,31 @@ function retryPlayback() {
loadPlayer(current);
loadPlayer(current);
}
}
+/**
+ * A dropped network, or a tab that comes back to the front, must not leave the viewer with a
+ * picture that stands still (the owner's rule, 2026-10-31): the player picks itself up.
+ * • the media is ready again → play it (a tab that was hidden comes back paused),
+ * • the media errored → start it over,
+ * • the media is stuck → load the source again.
+ */
+function mpRecover(reason) {
+ const media = mpMedia;
+ if (!media || !mpCurrentRequestId) return;
+ if (media.ended) return;
+ if (media.error) { console.warn(`[MediaPlayer] The media failed (${reason}) — starting it over`); retryPlayback(); return; }
+ if (media.readyState >= 3) {
+ if (media.paused) media.play().catch(() => {});
+ return;
+ }
+ console.warn(`[MediaPlayer] The media stalled (${reason}) — loading the source again`);
+ try { media.load(); } catch { /* the browser will try anyway */ }
+ media.play().catch(() => {});
+}
+
+// The network came back, or the tab came back to the front: look at the media now.
+window.addEventListener('online', () => mpRecover('the network came back'));
+document.addEventListener('visibilitychange', () => { if (!document.hidden) mpRecover('the tab came back to the front'); });
+
async function reportFailure(requestId, errorMsg) {
async function reportFailure(requestId, errorMsg) {
try {
try {
await mpApi(`/api/media/queue/${requestId}/fail`, {
await mpApi(`/api/media/queue/${requestId}/fail`, {
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
+/* ───────────────────────────────────────────────────────────────
+ ov-mini-player.js — the live player follows the viewer.
+
+ A viewer who walks away from a channel that is live does not have to come back to keep
+ watching: the picture moves into a small box in the corner of the page and keeps playing
+ until the viewer closes it (the owner's rule, 2026-10-31).
+
+ The box is not a second player: it holds the very same <video> element, moved out of the
+ channel's container, so there is one session, one SFU transport and one sound. Coming back
+ to the channel puts the element back where the channel expects it (and the channel then
+ starts its own session, as it always did).
+
+ Rules:
+ • only a live picture that is playing right now is worth keeping;
+ • not on the way to the broadcast desk — that page needs the machine for its own video;
+ • not on the way to another channel — that page brings its own player.
+ ─────────────────────────────────────────────────────────────── */
+
+(function () {
+ 'use strict';
+ if (window.OVMiniPlayer) return;
+
+ const st = { box: null, video: null, home: null, title: '', href: '', styleEl: null };
+
+ /** A channel page: /@name or /stream/... */
+ function isChannelPath(path) {
+ return /^\/(?:@|stream)/.test(String(path || ''));
+ }
+
+ /** The player is a live stream that is playing right now (SFU MediaStream, HLS or FLV). */
+ function isPlaying() {
+ const video = document.getElementById('video-element');
+ if (!video) return false;
+ if (!video.srcObject && !video.currentSrc && !video.src) return false;
+ if (video.paused || video.ended) return false;
+ return video.readyState >= 2;
+ }
+
+ function ensureStyles() {
+ if (st.styleEl || typeof document === 'undefined') return;
+ st.styleEl = document.createElement('style');
+ st.styleEl.id = 'ov-mini-player-style';
+ st.styleEl.textContent = [
+ '.ov-mini-player{position:fixed;right:14px;bottom:14px;width:min(46vw,320px);z-index:70;',
+ 'aspect-ratio:16/9;border-radius:12px;overflow:hidden;background:#000;',
+ 'box-shadow:0 10px 30px rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.12)}',
+ '.ov-mini-player.video-container #video-element{width:100%;height:100%;object-fit:contain;display:block;',
+ 'opacity:1;filter:none;transform:none;pointer-events:auto}',
+ '.ov-mini-player-bar{position:absolute;left:0;right:0;top:0;display:flex;align-items:center;gap:6px;',
+ 'padding:5px 6px;background:linear-gradient(rgba(0,0,0,.7),rgba(0,0,0,0));color:#fff;font-size:12px}',
+ '.ov-mini-player-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
+ 'background:none;border:0;color:inherit;font:inherit;text-align:left;cursor:pointer;padding:2px}',
+ '.ov-mini-player-close{flex:0 0 auto;border:0;background:rgba(255,255,255,.16);color:#fff;border-radius:6px;',
+ 'width:22px;height:22px;line-height:1;cursor:pointer}',
+ '.ov-mini-player-close:hover{background:rgba(255,255,255,.3)}',
+ '@media (max-width:600px){.ov-mini-player{width:min(52vw,240px);right:8px;bottom:8px}}',
+ ].join('');
+ (document.head || document.documentElement).appendChild(st.styleEl);
+ }
+
+ function build() {
+ ensureStyles();
+ const box = document.createElement('div');
+ box.className = 'video-container ov-mini-player';
+ box.id = 'ov-mini-player';
+
+ const bar = document.createElement('div');
+ bar.className = 'ov-mini-player-bar';
+
+ const name = document.createElement('button');
+ name.type = 'button';
+ name.className = 'ov-mini-player-name';
+ name.textContent = st.title || 'Live';
+ name.title = st.href ? 'Back to the channel' : '';
+ name.addEventListener('click', () => {
+ if (st.href && typeof navigate === 'function') navigate(st.href);
+ });
+
+ const close = document.createElement('button');
+ close.type = 'button';
+ close.className = 'ov-mini-player-close';
+ close.setAttribute('aria-label', 'Close the mini player');
+ close.textContent = '\u00d7';
+ close.addEventListener('click', () => {
+ // The viewer closed it: the player goes away for good.
+ if (typeof destroyPlayer === 'function') { try { destroyPlayer(); } catch { /* */ } }
+ release();
+ });
+
+ bar.appendChild(name);
+ bar.appendChild(close);
+ box.appendChild(bar);
+ st.box = box;
+ document.body.appendChild(box);
+ }
+
+ /**
+ * Keep the player alive across a route change. Returns true when the player was kept (the
+ * caller must then NOT tear it down).
+ */
+ function keepAlive(nextPath) {
+ // A channel page (or the studio) brings its own player: give the element back and let
+ // the normal teardown destroy this session. This comes before the "already a mini
+ // player" test — otherwise walking back to the channel left two players fighting over
+ // the one <video> element.
+ const onPlayerPage = nextPath && (isChannelPath(nextPath) || String(nextPath).startsWith('/broadcast'));
+ if (onPlayerPage) {
+ if (st.box) release();
+ return false;
+ }
+ if (st.box) return true; // already a mini player
+ if (!isPlaying()) return false;
+ const video = document.getElementById('video-element');
+ const home = document.getElementById('video-container');
+ if (!video || !home || video.parentElement !== home) return false;
+
+ st.video = video;
+ st.home = home;
+ st.title = (document.getElementById('ch-display-name')?.textContent || '').trim()
+ || (document.getElementById('ch-stream-title')?.textContent || '').trim()
+ || 'Live';
+ st.href = location.pathname;
+
+ build();
+ st.box.appendChild(video); // the picture follows the viewer
+ return true;
+ }
+
+ /** Put the video back and take the box away (the channel page is opening its own player). */
+ function release() {
+ if (!st.box) return;
+ const box = st.box;
+ st.box = null;
+ if (st.video && st.home && st.video.parentElement === box) {
+ try { st.home.appendChild(st.video); } catch { /* the page is gone */ }
+ }
+ st.video = null;
+ st.home = null;
+ try { box.remove(); } catch { /* */ }
+ }
+
+ /** Is the picture in the mini player right now? */
+ function isMini() { return !!st.box; }
+
+ window.OVMiniPlayer = { keepAlive, release, isMini, isChannelPath };
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,511 @@
@@ -0,0 +1,511 @@
+/* ───────────────────────────────────────────────────────────────
+ ov-viewer-health.js — the shared "is the picture actually moving?" layer for every player.
+
+ The channel's live player, the home page's featured block and the VOD/clip player all show a
+ picture that can stop moving while everything else looks fine: the signaling websocket is open,
+ the transport says "connected", the audio keeps playing. The viewer sees a frozen picture and
+ nothing else explains it (plan L23, 2026-10-31: stream 2833 froze a few seconds in and a
+ reload played fine).
+
+ This module is the one place that knows how to answer "is it frozen?" and what to do about it:
+
+ · telemetry every 5 s the player sends one small message with the decoder's own numbers
+ (framesDecoded, framesDropped, packetsLost, jitter, jitterBufferDelay, the codec
+ and the decoder implementation). The server counts the freezes it sees.
+ · recovery a ladder, in steps, cheapest first:
+ 1. ask for a keyframe (the server asks the broadcaster's encoder for one,
+ at most one ask per consumer every 2 s),
+ 2. restart the receive transport (a fresh one, the media session rebuilt),
+ 3. rebuild the whole viewer session.
+ The page is never reloaded.
+ · notice a small "The picture froze. Reconnecting…" line with a "Copy debug info" button
+ that copies a report the owner can paste to us: the stream, the time, the
+ browser, the codec, the decoder, the last telemetry sample and the steps taken.
+ The report never carries a token, a cookie or anything else personal.
+
+ The ladder is a plain state machine with no DOM in it, so a test can drive it with a fake
+ clock (test/viewer-health.test.js) and check the order of the steps.
+ ─────────────────────────────────────────────────────────────── */
+
+(function () {
+ 'use strict';
+ if (window.OVViewerHealth) return;
+
+ /* How long a picture may stand still before we call it frozen, how many keyframes we ask
+ for before we restart the transport, and how long each step is given. */
+ const T = {
+ freezeMs: 1500, // no new decoded frame for this long while connected = frozen
+ serverFreezeMs: 2000, // the server's own threshold (a freeze it counts and logs)
+ keyframeLimit: 3, // three keyframe asks, then the transport is restarted
+ keyframeIntervalMs: 2000,// the server takes one keyframe ask per consumer every 2 s
+ restartAfterMs: 8000, // eight seconds of asking = restart the transport anyway
+ rebuildAfterMs: 8000, // a restarted transport gets eight seconds before a full rebuild
+ silentMs: 20000, // no video packets at all for this long = the transport may be dead: climb the ladder
+ telemetryMs: 5000, // one telemetry message every five seconds
+ pollMs: 1000, // how often the decoder's numbers are read
+ };
+
+ const STAGE_LABEL = {
+ 0: 'playing',
+ 1: 'asking for a keyframe',
+ 2: 'restarting the transport',
+ 3: 'rebuilding the session',
+ };
+
+ /* ── the recovery ladder ─────────────────────────────────────────────── */
+
+ /**
+ * The recovery ladder. Pure: it takes the time from opts.now() and returns the next step.
+ * markFrame() a new decoded frame arrived (the picture moved)
+ * setConnected() the transport is up (or down)
+ * tick() → { action: 'keyframe' | 'restart-transport' | 'rebuild' | 'wait' | 'none' }
+ */
+ function createLadder(opts = {}) {
+ const now = opts.now || (() => Date.now());
+ const th = Object.assign({}, T, opts.thresholds || {});
+ // A fresh transport that follows a ladder restart starts at stage 2: the keyframe asks
+ // were already spent on the previous transport, so a still-frozen picture rebuilds the
+ // whole session after the stage-2 grace period instead of asking all over again.
+ let stage = Math.max(0, Math.min(3, Number(opts.startStage) || 0));
+ let stageAt = now();
+ let lastFrameAt = now();
+ let lastAskAt = 0;
+ let keyframeAsks = 0;
+ let freezeCount = 0;
+ let freezeMs = 0;
+ let freezeStartedAt = 0;
+ let connected = false;
+ // Video packets since the last decoded frame, and when the last one came. A still picture with no packets is a
+ // quiet sender (a static screen share sends frames only when something changes), not a frozen decoder: the
+ // ladder climbs only when packets arrive and no frame decodes, or when nothing at all has arrived for silentMs.
+ let packetsSinceFrame = false;
+ let lastPacketAt = now();
+
+ const closeFreeze = (at) => {
+ if (!freezeStartedAt) return;
+ freezeMs += Math.max(0, at - freezeStartedAt);
+ freezeStartedAt = 0;
+ };
+
+ return {
+ thresholds: th,
+ get stage() { return stage; },
+ get stageLabel() { return STAGE_LABEL[stage] || 'playing'; },
+ get keyframeAsks() { return keyframeAsks; },
+ get freezeCount() { return freezeCount; },
+ /** Total time the picture stood still, including the freeze that is running now. */
+ get freezeMs() { return freezeMs + (freezeStartedAt ? Math.max(0, now() - freezeStartedAt) : 0); },
+ get connected() { return connected; },
+ get stalledFor() { return now() - lastFrameAt; },
+ /** The transport is up (or went down): a fresh transport has not delivered a frame yet. */
+ setConnected(v) {
+ const next = !!v;
+ if (next === connected) return;
+ connected = next;
+ if (connected) { lastFrameAt = now(); lastPacketAt = now(); packetsSinceFrame = false; }
+ else { stage = 0; stageAt = 0; keyframeAsks = 0; lastAskAt = 0; }
+ },
+ /** A new decoded frame: the picture moved. */
+ markFrame(at) {
+ const t0 = typeof at === 'number' ? at : now();
+ lastFrameAt = t0;
+ packetsSinceFrame = false;
+ if (stage !== 0) { stage = 0; stageAt = 0; keyframeAsks = 0; lastAskAt = 0; }
+ closeFreeze(t0);
+ },
+ /** Video packets arrived (the inbound packet count rose). */
+ markPackets(at) {
+ lastPacketAt = typeof at === 'number' ? at : now();
+ packetsSinceFrame = true;
+ },
+ /** One step of the ladder. Returns what the caller should do now. */
+ tick() {
+ const t0 = now();
+ if (!connected) return { action: 'none', stage, reason: 'not-connected', stalledFor: 0 };
+ const stalledFor = t0 - lastFrameAt;
+ if (stalledFor < th.freezeMs) {
+ closeFreeze(t0);
+ return { action: 'none', stage, stalledFor };
+ }
+ // Nothing arrived since the last frame and not for long: a quiet sender, not a freeze.
+ if (!packetsSinceFrame && t0 - lastPacketAt < th.silentMs) {
+ closeFreeze(t0);
+ return { action: 'none', stage, stalledFor, reason: 'quiet' };
+ }
+ // A freeze lasts from the moment the picture stopped. The 2 s threshold is the
+ // server's rule for calling it a freeze; the duration is measured from the stop.
+ if (!freezeStartedAt) {
+ freezeStartedAt = lastFrameAt;
+ freezeCount++;
+ }
+ if (stage === 0) {
+ stage = 1; stageAt = t0; keyframeAsks = 1; lastAskAt = t0;
+ return { action: 'keyframe', stage, stalledFor, keyframeAsks };
+ }
+ if (stage === 1) {
+ if (keyframeAsks >= th.keyframeLimit || t0 - stageAt >= th.restartAfterMs) {
+ stage = 2; stageAt = t0;
+ return { action: 'restart-transport', stage, stalledFor, keyframeAsks };
+ }
+ if (t0 - lastAskAt >= th.keyframeIntervalMs) {
+ keyframeAsks++; lastAskAt = t0;
+ return { action: 'keyframe', stage, stalledFor, keyframeAsks };
+ }
+ return { action: 'wait', stage, stalledFor, keyframeAsks };
+ }
+ if (stage === 2) {
+ if (t0 - stageAt >= th.rebuildAfterMs) {
+ stage = 3; stageAt = t0;
+ return { action: 'rebuild', stage, stalledFor, keyframeAsks };
+ }
+ return { action: 'wait', stage, stalledFor, keyframeAsks };
+ }
+ return { action: 'wait', stage, stalledFor, keyframeAsks };
+ },
+ };
+ }
+
+ /* ── the telemetry sample ───────────────────────────────────────────── */
+
+ const num = (v) => (typeof v === 'number' && isFinite(v) ? v : null);
+ const str = (v, max = 64) => (typeof v === 'string' || typeof v === 'number' ? String(v).slice(0, max) : '');
+
+ /**
+ * The numbers we send every 5 s, read from the browser's own RTCInboundRtpStreamStats.
+ * Everything is optional: a browser that does not report a field simply leaves it out.
+ */
+ function readStats(sample) {
+ const s = sample || {};
+ return {
+ framesDecoded: num(s.framesDecoded),
+ framesDropped: num(s.framesDropped),
+ framesPerSecond: num(s.framesPerSecond),
+ packetsReceived: num(s.packetsReceived),
+ packetsLost: num(s.packetsLost),
+ jitter: num(s.jitter),
+ jitterBufferDelay: num(s.jitterBufferDelay),
+ nackCount: num(s.nackCount),
+ pliCount: num(s.pliCount),
+ decoderImplementation: str(s.decoderImplementation, 48),
+ codecId: str(s.codecId, 24),
+ };
+ }
+
+ /** A telemetry message, ready to be sent over the signaling websocket. */
+ function buildTelemetry({ streamId, stats = {}, freezeCount = 0, freezeMs = 0, codec = '', decoder = '' }) {
+ return {
+ type: 'sfu-viewer-telemetry',
+ streamId: str(streamId, 32),
+ data: {
+ ...readStats(stats),
+ codec: str(codec || stats.codecId, 32),
+ decoder: str(decoder || stats.decoderImplementation, 48),
+ freezeCount: num(freezeCount) || 0,
+ freezeMs: num(freezeMs) || 0,
+ at: Date.now(),
+ },
+ };
+ }
+
+ /* ── the freeze notice ──────────────────────────────────────────────── */
+
+ let noticeEl = null;
+ let styleEl = null;
+
+ function ensureStyles() {
+ if (styleEl || typeof document === 'undefined') return;
+ styleEl = document.createElement('style');
+ styleEl.id = 'ov-viewer-health-style';
+ styleEl.textContent = [
+ '.ov-freeze-notice{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:40;',
+ 'display:flex;align-items:center;gap:10px;max-width:min(92%,420px);padding:9px 12px;border-radius:10px;',
+ 'background:rgba(12,12,16,.86);color:#fff;font:13px/1.35 system-ui,-apple-system,Segoe UI,sans-serif;',
+ 'box-shadow:0 6px 20px rgba(0,0,0,.35)}',
+ '.ov-freeze-notice[hidden]{display:none}',
+ '.ov-freeze-notice-text{flex:1 1 auto;min-width:0}',
+ '.ov-freeze-notice b{display:block;font-weight:600}',
+ '.ov-freeze-notice small{opacity:.75;font-size:11px}',
+ '.ov-freeze-notice button{flex:0 0 auto;border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.1);',
+ 'color:#fff;border-radius:7px;padding:5px 9px;font:inherit;font-size:12px;cursor:pointer}',
+ '.ov-freeze-notice button:hover{background:rgba(255,255,255,.2)}',
+ ].join('');
+ (document.head || document.documentElement).appendChild(styleEl);
+ }
+
+ /**
+ * Show the "The picture froze. Reconnecting…" line with its Copy debug info button.
+ * Returns the notice element (so the caller can remove it).
+ */
+ function showFreezeNotice({ container, title = 'The picture froze. Reconnecting…', detail = '', onCopy }) {
+ if (typeof document === 'undefined') return null;
+ ensureStyles();
+ // The host can be a function: a picture that moved into the mini player must show its
+ // notice in the mini player, not in the page it came from.
+ const host = (typeof container === 'function' ? container() : container) || document.body;
+ if (!host) return null;
+ if (!noticeEl || noticeEl.parentElement !== host) {
+ if (noticeEl && noticeEl.parentElement) { try { noticeEl.remove(); } catch { /* */ } }
+ noticeEl = document.createElement('div');
+ noticeEl.className = 'ov-freeze-notice';
+ noticeEl.setAttribute('role', 'status');
+ const text = document.createElement('div');
+ text.className = 'ov-freeze-notice-text';
+ const b = document.createElement('b');
+ const small = document.createElement('small');
+ text.appendChild(b);
+ text.appendChild(small);
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.textContent = 'Copy debug info';
+ btn.addEventListener('click', () => { try { onCopy && onCopy(); } catch (e) { /* nothing to do */ } });
+ noticeEl.appendChild(text);
+ noticeEl.appendChild(btn);
+ noticeEl._title = b;
+ noticeEl._detail = small;
+ }
+ noticeEl._title.textContent = title;
+ noticeEl._detail.textContent = detail || '';
+ if (!noticeEl.parentElement) host.appendChild(noticeEl);
+ noticeEl.hidden = false;
+ return noticeEl;
+ }
+
+ function hideFreezeNotice() {
+ if (noticeEl) noticeEl.hidden = true;
+ }
+
+ /* ── the debug report ───────────────────────────────────────────────── */
+
+ /**
+ * The report the "Copy debug info" button copies. It names the stream, the time, the browser,
+ * the codec and the decoder, the last telemetry sample and the recovery steps taken — and
+ * nothing else: no token, no cookie, no account, no personal data.
+ */
+ function buildDebugReport({ streamId, codec, decoder, telemetry, steps, browser, at }) {
+ const t = telemetry || {};
+ const lines = [
+ 'OpenVibe.Live viewer report',
+ `time=${new Date(at || Date.now()).toISOString()}`,
+ `stream_id=${streamId || 'unknown'}`,
+ `browser=${browser || (typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown')}`,
+ `codec=${codec || t.codec || 'unknown'}`,
+ `decoder=${decoder || t.decoder || t.decoderImplementation || 'unknown'}`,
+ 'last_sample:',
+ ];
+ const fields = ['framesDecoded', 'framesDropped', 'framesPerSecond', 'packetsReceived', 'packetsLost',
+ 'jitter', 'jitterBufferDelay', 'nackCount', 'pliCount', 'freezeCount', 'freezeMs'];
+ for (const f of fields) {
+ if (t[f] !== undefined && t[f] !== null) lines.push(` ${f}=${t[f]}`);
+ }
+ lines.push('recovery:');
+ const list = steps && steps.length ? steps : ['(nothing yet)'];
+ for (const s of list) lines.push(` - ${s}`);
+ return lines.join('\n');
+ }
+
+ /* ── the monitor ────────────────────────────────────────────────────── */
+
+ /**
+ * Watches one playback session: reads the decoder's numbers, keeps the ladder, sends the
+ * telemetry and shows the notice.
+ *
+ * getReceiver() → the RTCRtpReceiver (or null)
+ * send(message) → send one signaling message
+ * codec → the negotiated codec, e.g. "video/H264"
+ * restartTransport() → a fresh receive transport (ladder step 2)
+ * rebuild() → a whole new viewer session (ladder step 3)
+ */
+ function createMonitor(opts = {}) {
+ const now = opts.now || (() => Date.now());
+ const ladder = createLadder({ now, thresholds: opts.thresholds, startStage: opts.startStage });
+ const steps = [];
+ let timer = null;
+ let lastStats = null;
+ let lastFramesDecoded = null;
+ let lastPacketsReceived = null;
+ let lastSampleAt = 0;
+ let stopped = false;
+ let polling = false;
+
+ const note = (step) => {
+ steps.push(`${new Date(now()).toISOString()} — ${step}`);
+ if (steps.length > 20) steps.splice(0, steps.length - 20);
+ };
+
+ /** A new decoded frame: the picture moved. The first one also means the media flows. */
+ const noteFrame = () => {
+ if (!ladder.connected) ladder.setConnected(true);
+ ladder.markFrame();
+ hideFreezeNotice();
+ if (opts.onFrame) { try { opts.onFrame(); } catch { /* the caller's bookkeeping never breaks playback */ } }
+ };
+
+ const readReceiverStats = async () => {
+ const receiver = opts.getReceiver && opts.getReceiver();
+ if (!receiver || typeof receiver.getStats !== 'function') return null;
+ try {
+ const stats = await receiver.getStats();
+ let inbound = null;
+ if (stats && typeof stats.forEach === 'function') {
+ stats.forEach((r) => {
+ if (r && r.type === 'inbound-rtp' && (r.kind === 'video' || r.mediaType === 'video')) inbound = r;
+ });
+ }
+ return inbound || null;
+ } catch { return null; }
+ };
+
+ const poll = async () => {
+ // getStats() can take longer than the 1 s tick under load; a second poll must not
+ // tick the same ladder twice and ask for two keyframes.
+ if (stopped || polling) return;
+ polling = true;
+ try {
+ const sample = await readReceiverStats();
+ if (stopped) return;
+ // No video receiver (an audio-only stream) or no stats yet: nothing to judge, so the ladder waits.
+ if (!sample) return;
+ lastStats = sample;
+ const pr = num(sample.packetsReceived);
+ if (pr !== null && lastPacketsReceived !== null && pr > lastPacketsReceived) ladder.markPackets();
+ if (pr !== null) lastPacketsReceived = pr;
+ const fd = num(sample.framesDecoded);
+ if (fd !== null && fd !== lastFramesDecoded) {
+ lastFramesDecoded = fd;
+ noteFrame();
+ }
+ const step = ladder.tick();
+ if (step.action === 'keyframe') {
+ note('asked for a keyframe');
+ if (opts.onStage) opts.onStage(1, step);
+ showNotice();
+ if (opts.send) opts.send({ type: 'sfu-viewer-keyframe' });
+ } else if (step.action === 'restart-transport') {
+ note('restarted the receive transport');
+ if (opts.onStage) opts.onStage(2, step);
+ showNotice();
+ if (opts.restartTransport) opts.restartTransport();
+ } else if (step.action === 'rebuild') {
+ note('rebuilt the viewer session');
+ if (opts.onStage) opts.onStage(3, step);
+ showNotice();
+ if (opts.rebuild) opts.rebuild();
+ }
+ // The telemetry goes out every 5 s, whatever the ladder is doing.
+ if (now() - lastSampleAt >= T.telemetryMs) {
+ lastSampleAt = now();
+ sendTelemetry();
+ }
+ } finally {
+ polling = false;
+ }
+ };
+
+ const sendTelemetry = () => {
+ if (!opts.send) return;
+ const t = buildTelemetry({
+ streamId: opts.streamId,
+ stats: lastStats || {},
+ freezeCount: ladder.freezeCount,
+ freezeMs: ladder.freezeMs,
+ codec: opts.codec,
+ decoder: lastStats ? lastStats.decoderImplementation : '',
+ });
+ try { opts.send(t); } catch { /* the socket is gone; the ladder still runs */ }
+ };
+
+ const showNotice = () => {
+ if (!opts.notice) return;
+ showFreezeNotice({
+ container: opts.notice,
+ title: 'The picture froze. Reconnecting…',
+ detail: `Step: ${ladder.stageLabel}`,
+ onCopy: () => {
+ const text = buildDebugReport({
+ streamId: opts.streamId,
+ codec: opts.codec,
+ decoder: lastStats ? lastStats.decoderImplementation : '',
+ telemetry: {
+ ...(lastStats ? readStats(lastStats) : {}),
+ freezeCount: ladder.freezeCount,
+ freezeMs: ladder.freezeMs,
+ },
+ steps,
+ });
+ copyText(text);
+ if (opts.onCopy) opts.onCopy(text);
+ },
+ });
+ };
+
+ return {
+ ladder,
+ get steps() { return steps.slice(); },
+ get lastStats() { return lastStats; },
+ /** The transport is up: the ladder starts counting from now. */
+ connected() { ladder.setConnected(true); },
+ /** The transport is gone: no ladder steps until it is back. */
+ disconnected() { ladder.setConnected(false); },
+ /** A new frame arrived outside the stats (requestVideoFrameCallback): the picture moved. */
+ markFrame() { noteFrame(); },
+ start() {
+ if (timer || stopped) return;
+ timer = setInterval(() => { poll().catch(() => {}); }, T.pollMs);
+ },
+ stop() {
+ stopped = true;
+ if (timer) { clearInterval(timer); timer = null; }
+ hideFreezeNotice();
+ },
+ /** One poll by hand (the test drives it, and a page that comes back to the front polls at once). */
+ poll,
+ report() {
+ return buildDebugReport({
+ streamId: opts.streamId,
+ codec: opts.codec,
+ decoder: lastStats ? lastStats.decoderImplementation : '',
+ telemetry: {
+ ...(lastStats ? readStats(lastStats) : {}),
+ freezeCount: ladder.freezeCount,
+ freezeMs: ladder.freezeMs,
+ },
+ steps,
+ });
+ },
+ };
+ }
+
+ /** Copy text to the clipboard, with the old execCommand path for browsers without the async API. */
+ function copyText(text) {
+ try {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).catch(() => {});
+ return true;
+ }
+ } catch { /* fall through */ }
+ try {
+ const el = document.createElement('textarea');
+ el.value = text;
+ el.style.position = 'fixed';
+ el.style.opacity = '0';
+ document.body.appendChild(el);
+ el.focus();
+ el.select();
+ document.execCommand('copy');
+ el.remove();
+ return true;
+ } catch { return false; }
+ }
+
+ window.OVViewerHealth = {
+ thresholds: T,
+ createLadder,
+ createMonitor,
+ readStats,
+ buildTelemetry,
+ buildDebugReport,
+ showFreezeNotice,
+ hideFreezeNotice,
+ copyText,
+ };
+})();
@@ -1184,7 +1184,7 @@ async function initWebRTC(stream) {
@@ -1184,7 +1184,7 @@ async function initWebRTC(stream) {
const wsUrl = `${protocol}://${host}${portSuffix}/ws/broadcast?streamId=${streamRef.id}&role=viewer&token=${token}`;
const wsUrl = `${protocol}://${host}${portSuffix}/ws/broadcast?streamId=${streamRef.id}&role=viewer&token=${token}`;
const ws = new WebSocket(wsUrl);
const ws = new WebSocket(wsUrl);
- player = { ws, video, myPeerId: null, watchSent: false, _wsUrl: wsUrl, _serverIceServers: null };
+ player = { ws, video, myPeerId: null, watchSent: false, _wsUrl: wsUrl, _serverIceServers: null, _viewerRestarts: 0 };
const _myGen = _playerGen; // this session's generation; stale once destroyPlayer bumps it
const _myGen = _playerGen; // this session's generation; stale once destroyPlayer bumps it
let _broadcasterDisconnectTimer = null;
let _broadcasterDisconnectTimer = null;
let _viewerReconnectTimer = null;
let _viewerReconnectTimer = null;
@@ -1244,6 +1244,35 @@ async function initWebRTC(stream) {
@@ -1244,6 +1244,35 @@ async function initWebRTC(stream) {
}, delay);
}, delay);
};
};
+ // A player that comes back to the front, or a network that comes back, recovers by itself
+ // (the owner's rule, 2026-10-31): never a reload, never "refresh the page".
+ //
+ // When the signaling socket is gone we cannot do anything here — the socket's own close
+ // handler reconnects it. When it is open but the media transport died (a phone that moved
+ // from Wi-Fi to cellular, a laptop that woke up), we watch again and the server hands us
+ // a fresh transport. When the transport still looks fine, the monitor's poll decides
+ // whether the picture is actually moving; a frozen picture is the ladder's business.
+ const _resumeIfNeeded = (why) => {
+ if (!player || _myGen !== _playerGen || _viewerIntentionalClose) return;
+ if (!player.ws || player.ws.readyState !== WebSocket.OPEN) return;
+ const state = player._sfuRecvTransport?.connectionState;
+ if (state !== 'connected') {
+ console.warn(`[Player] SFU viewer resuming after ${why} (transport=${state || 'none'}) — watching again`);
+ player.watchSent = false;
+ sendPlayerSignal({ type: 'watch' });
+ player.watchSent = true;
+ return;
+ }
+ // The transport looks alive: ask the monitor to look at the decoder's numbers now.
+ if (player._viewerHealth) { try { player._viewerHealth.poll(); } catch { /* */ } }
+ };
+ const _onOnline = () => _resumeIfNeeded('the network came back');
+ const _onVisible = () => { if (!document.hidden) _resumeIfNeeded('the tab came back to the front'); };
+ window.addEventListener('online', _onOnline);
+ document.addEventListener('visibilitychange', _onVisible);
+ player._onOnline = _onOnline;
+ player._onVisibility = _onVisible;
+
ws.onopen = () => {
ws.onopen = () => {
console.log('[Player] Broadcast signaling connected');
console.log('[Player] Broadcast signaling connected');
_updateStatus('Looking for the live feed', {
_updateStatus('Looking for the live feed', {
@@ -1494,6 +1523,9 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -1494,6 +1523,9 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }
if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }
// Clear any post-play frozen-video checker from the previous SFU session
// Clear any post-play frozen-video checker from the previous SFU session
if (player._sfuFrozenInterval) { clearInterval(player._sfuFrozenInterval); player._sfuFrozenInterval = null; }
if (player._sfuFrozenInterval) { clearInterval(player._sfuFrozenInterval); player._sfuFrozenInterval = null; }
+ // The viewer-health monitor (telemetry + the recovery ladder) of the previous session
+ if (player._viewerHealth) { try { player._viewerHealth.stop(); } catch { /* */ } player._viewerHealth = null; }
+ player._viewerHealthFor = null;
// Cancel pending ICE/DTLS connect timeout for any prior SFU transport
// Cancel pending ICE/DTLS connect timeout for any prior SFU transport
if (player._sfuTransportConnectTimeout) {
if (player._sfuTransportConnectTimeout) {
clearTimeout(player._sfuTransportConnectTimeout);
clearTimeout(player._sfuTransportConnectTimeout);
@@ -1600,6 +1632,9 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -1600,6 +1632,9 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
if (!player || player._sfuRecvTransport !== recvTransport) return;
if (!player || player._sfuRecvTransport !== recvTransport) return;
console.log(`[Player] SFU recv transport state: ${state}`);
console.log(`[Player] SFU recv transport state: ${state}`);
if (state === 'connected') {
if (state === 'connected') {
+ // The ladder only escalates while the transport is up; the first frame (or the
+ // first 1.5 s without one) decides what happens next.
+ if (player._viewerHealth) { try { player._viewerHealth.connected(); } catch { /* */ } }
updateStatus('Almost there', {
updateStatus('Almost there', {
phase: 'buffer',
phase: 'buffer',
detail: 'ICE and DTLS are up. Waiting for the next keyframe and live media packets.',
detail: 'ICE and DTLS are up. Waiting for the next keyframe and live media packets.',
@@ -1636,6 +1671,7 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -1636,6 +1671,7 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
}
}
if (state === 'failed' || state === 'closed') {
if (state === 'failed' || state === 'closed') {
// Transport is dead — clear both timers before re-watching
// Transport is dead — clear both timers before re-watching
+ if (player._viewerHealth) { try { player._viewerHealth.disconnected(); } catch { /* */ } }
if (player._sfuTransportConnectTimeout) {
if (player._sfuTransportConnectTimeout) {
clearTimeout(player._sfuTransportConnectTimeout);
clearTimeout(player._sfuTransportConnectTimeout);
player._sfuTransportConnectTimeout = null;
player._sfuTransportConnectTimeout = null;
@@ -1713,6 +1749,7 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -1713,6 +1749,7 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
};
};
let consumedCount = 0;
let consumedCount = 0;
+ let videoCodec = ''; // what the SFU negotiated for this viewer's video (e.g. "video/H264")
for (const prod of producers) {
for (const prod of producers) {
const consumerParams = await _sfuViewerRequest(ws, 'sfu-viewer-consume', 'sfu-viewer-consumed', {
const consumerParams = await _sfuViewerRequest(ws, 'sfu-viewer-consume', 'sfu-viewer-consumed', {
transportId: recvTransport.id,
transportId: recvTransport.id,
@@ -1748,7 +1785,10 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -1748,7 +1785,10 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
// Keep a handle on the VIDEO receiver so the freeze watchdog can read framesDecoded
// Keep a handle on the VIDEO receiver so the freeze watchdog can read framesDecoded
// when requestVideoFrameCallback isn't available.
// when requestVideoFrameCallback isn't available.
- if (consumer.kind === 'video') player._sfuVideoReceiver = consumer.rtpReceiver || null;
+ if (consumer.kind === 'video') {
+ player._sfuVideoReceiver = consumer.rtpReceiver || null;
+ videoCodec = consumerParams?.rtpParameters?.codecs?.[0]?.mimeType || videoCodec;
+ }
mediaStream.addTrack(consumer.track);
mediaStream.addTrack(consumer.track);
console.log(`[Player] SFU consumed ${consumer.kind} track — enabled=${consumer.track.enabled} muted=${consumer.track.muted} readyState=${consumer.track.readyState} paused=${consumer.paused} id=${consumer.id}`);
console.log(`[Player] SFU consumed ${consumer.kind} track — enabled=${consumer.track.enabled} muted=${consumer.track.muted} readyState=${consumer.track.readyState} paused=${consumer.paused} id=${consumer.id}`);
@@ -1777,6 +1817,77 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -1777,6 +1817,77 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
// Set video source
// Set video source
video.srcObject = mediaStream;
video.srcObject = mediaStream;
video.style.display = 'block';
video.style.display = 'block';
+
+ // Start the viewer-health monitor with the media, not on the first painted frame: a
+ // transport that connects but never delivers a decoded frame is exactly the freeze we
+ // must catch (the old watchdog only started on 'playing', so a black picture waited for
+ // the 20 s first-frame timer). The monitor asks for a keyframe after 1.5 s without a
+ // decoded frame, restarts the transport after three asks (or 8 s of asking), then
+ // rebuilds the whole viewer session. Telemetry goes to the server every 5 s.
+ let health = null;
+ const startViewerHealth = () => {
+ if (!window.OVViewerHealth || !player || player._sfuRecvTransport !== recvTransport) return;
+ if (player._viewerHealthFor === recvTransport) return;
+ player._viewerHealthFor = recvTransport;
+ health = OVViewerHealth.createMonitor({
+ streamId: streamRef?.id,
+ codec: videoCodec,
+ // A transport that was restarted by the previous ladder starts at stage 2: the
+ // keyframe asks were already spent, so a picture that stays frozen rebuilds the
+ // session after the grace period instead of asking the same encoder again.
+ startStage: player._viewerRestarts ? 2 : 0,
+ getReceiver: () => player?._sfuVideoReceiver || null,
+ send: (m) => sendPlayerSignal(m),
+ notice: () => document.getElementById('ov-mini-player') || document.getElementById('video-container') || document.body,
+ onFrame: () => { if (player) player._viewerRestarts = 0; },
+ onStage: (stage) => {
+ if (stage === 1) console.warn('[Player] SFU video frozen — asking for a keyframe');
+ if (stage === 2) console.warn('[Player] SFU video still frozen — restarting the receive transport');
+ if (stage === 3) console.warn('[Player] SFU video still frozen — rebuilding the viewer session');
+ },
+ // Step 2: a fresh receive transport. The server drops this viewer's old one and
+ // sends a new sfu-viewer-ready, so the media session is rebuilt without a reload.
+ restartTransport: () => {
+ if (!player || player._sfuRecvTransport !== recvTransport) return;
+ player._viewerRestarts = (Number(player._viewerRestarts) || 0) + 1;
+ player._sfuRecvTransport = null;
+ try { recvTransport.close(); } catch { /* */ }
+ sendPlayerSignal({ type: 'sfu-viewer-restart' });
+ },
+ // Step 3: the whole viewer session — a new signaling socket and a new SFU session.
+ rebuild: () => {
+ const stream = streamRef;
+ try { if (player?.ws) player.ws.close(); } catch { /* */ }
+ try { if (player?._sfuRecvTransport) player._sfuRecvTransport.close(); } catch { /* */ }
+ player = null;
+ _playerGen++; // the old session's callbacks must not act any more
+ _sfuViewerSetupInProgress = false;
+ if (stream) initWebRTC(stream);
+ },
+ });
+ player._viewerHealth = health;
+ health.start();
+ // The monitor's 1.5 s rule replaces the 20 s first-frame timer.
+ if (player._stallTimer) { clearTimeout(player._stallTimer); player._stallTimer = null; }
+ if (recvTransport.connectionState === 'connected') health.connected();
+
+ // Hidden tabs never PAINT frames, so requestVideoFrameCallback goes silent even though
+ // the decoder keeps running. The monitor's primary signal is the decoded-frame count,
+ // which keeps rising in a background tab; rVFC is only an extra, faster "a frame was
+ // presented" signal.
+ if (typeof video.requestVideoFrameCallback === 'function') {
+ const _onFrame = () => {
+ if (!player || player._viewerHealth !== health) return;
+ health.markFrame();
+ if (player._sfuRecvTransport === recvTransport && video.srcObject) {
+ try { video.requestVideoFrameCallback(_onFrame); } catch { /* */ }
+ }
+ };
+ try { video.requestVideoFrameCallback(_onFrame); } catch { /* not supported */ }
+ }
+ };
+ startViewerHealth();
+
startClipRecordingIfNeeded(mediaStream, streamRef?.id);
startClipRecordingIfNeeded(mediaStream, streamRef?.id);
const _onVideoPlaybackProgress = () => {
const _onVideoPlaybackProgress = () => {
@@ -1802,112 +1913,16 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -1802,112 +1913,16 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
_hidePlayerPlaceholder();
_hidePlayerPlaceholder();
if (!video.muted) document.getElementById('unmute-overlay')?.remove();
if (!video.muted) document.getElementById('unmute-overlay')?.remove();
- // Post-play VIDEO-freeze watchdog.
- //
- // The old detector watched video.currentTime — but for a WebRTC MediaStream that
- // clock keeps advancing as long as the AUDIO track renders. So when only the VIDEO
- // decoder stalls (a lost keyframe after packet loss, a decode hiccup), the screen goes
- // black yet currentTime never stops → the stall was invisible → viewers had to refresh.
- //
- // Instead we watch actual VIDEO frame PRESENTATION via requestVideoFrameCallback
- // (falling back to getStats framesDecoded). On a video-only stall we recover in two
- // stages: (1) a cheap keyframe request via re-watch — the server responds with
- // consumer.requestKeyFrame(), which unsticks a missing-keyframe black frame without a
- // rebuild; (2) if that doesn't help within a few seconds, rebuild the receive transport
- // (full re-consume). No manual refresh needed.
- const _nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
- const STALL_CHECK_MS = 1500;
- const STALL_TRIGGER_MS = 3500; // no new video frame for this long (while playing) = frozen
- const STALL_ESCALATE_MS = 6000; // still frozen this long after a keyframe nudge = rebuild
- let _lastFrameAt = _nowMs();
- let _lastFramesDecoded = -1;
- let _recoverStage = 0; // 0 = healthy, 1 = keyframe requested
- let _recoverAt = 0;
- let _usingRVFC = false;
- const _startedAt = _nowMs();
- // Hidden tabs never PAINT frames, so rVFC goes silent even though the decoder keeps
- // running. Treating that silence as "frozen" produced an endless nudge → rebuild →
- // reconnect loop (every ~12s, all night, per background tab) that also hammered the
- // broadcaster with keyframe requests. When the tab becomes visible again, restart
- // the clock instead of judging the time it spent hidden.
- const _onVisibility = () => { if (!document.hidden) _lastFrameAt = _nowMs(); };
- document.addEventListener('visibilitychange', _onVisibility);
-
- // Primary signal: rVFC fires only when a NEW frame is actually presented.
- if (typeof video.requestVideoFrameCallback === 'function') {
- _usingRVFC = true;
- const _onFrame = () => {
- _lastFrameAt = _nowMs();
- if (player?._sfuRecvTransport === recvTransport && video.srcObject) {
- try { video.requestVideoFrameCallback(_onFrame); } catch { /* */ }
- }
- };
- try { video.requestVideoFrameCallback(_onFrame); } catch { _usingRVFC = false; }
- }
-
- const _frozenInterval = setInterval(async () => {
- if (!player || player._sfuRecvTransport !== recvTransport) { clearInterval(_frozenInterval); return; }
- if (video.paused || !(video.srcObject?.active)) { clearInterval(_frozenInterval); document.removeEventListener('visibilitychange', _onVisibility); return; }
-
- // Decoded-frame count from getStats is the PRIMARY progress signal: it keeps
- // rising in background tabs, where rVFC (a paint signal) never fires. rVFC is
- // kept as an extra, faster signal when it is available.
- if (player._sfuVideoReceiver?.getStats) {
- try {
- const stats = await player._sfuVideoReceiver.getStats();
- let fd = null;
- stats.forEach((r) => { if (r.type === 'inbound-rtp' && (r.kind === 'video' || r.mediaType === 'video') && typeof r.framesDecoded === 'number') fd = r.framesDecoded; });
- if (fd != null && fd !== _lastFramesDecoded) { _lastFrameAt = _nowMs(); _lastFramesDecoded = fd; }
- } catch { /* */ }
- }
-
- const stalledFor = _nowMs() - _lastFrameAt;
-
- if (stalledFor <= STALL_TRIGGER_MS) {
- // Video is progressing — healthy (or recovered after a nudge).
- if (_recoverStage !== 0) { console.log('[Player] SFU video recovered'); _recoverStage = 0; }
- // Frames have flowed for a while on this transport → forget rebuild backoff.
- if (player._sfuRebuilds && _nowMs() - _startedAt > 15000) player._sfuRebuilds = 0;
- return;
- }
-
- // Never escalate while the page is hidden: nothing is painted, nobody is watching,
- // and a rebuild just churns the SFU. The visibility handler restarts the clock.
- if (document.hidden) return;
-
- if (_recoverStage === 0) {
- // Stage 1: cheap keyframe request (server calls consumer.requestKeyFrame() on re-watch).
- console.warn(`[Player] SFU video frozen ~${(stalledFor / 1000).toFixed(1)}s (audio may still be playing) — requesting keyframe via re-watch`);
- _recoverStage = 1; _recoverAt = _nowMs();
- player.watchSent = false;
- sendPlayerSignal({ type: 'watch' });
- player.watchSent = true;
- // Intentionally NOT calling _startWatchOfferTimeout() — SFU mode.
- } else if (_recoverStage === 1 && (_nowMs() - _recoverAt) > STALL_ESCALATE_MS) {
- // Stage 2: keyframe didn't unstick it — rebuild the receive transport.
- console.warn('[Player] SFU video still frozen after keyframe nudge — rebuilding receive transport');
- clearInterval(_frozenInterval);
- if (player._sfuFrozenInterval === _frozenInterval) player._sfuFrozenInterval = null;
- _hasVideoFrames = false;
- if (player._sfuRecvTransport === recvTransport) player._sfuRecvTransport = null;
- try { recvTransport.close(); } catch { /* */ }
- document.removeEventListener('visibilitychange', _onVisibility);
- // Exponential backoff between rebuilds (1s, 2s, 4s … 60s) so a transport
- // that genuinely never delivers frames can't loop at full speed.
- const rebuilds = player._sfuRebuilds = (player._sfuRebuilds || 0) + 1;
- const delay = Math.min(60000, 1000 * Math.pow(2, rebuilds - 1));
- console.warn(`[Player] SFU rebuild #${rebuilds} in ${delay}ms`);
- scheduleRewatch(delay);
- }
- }, STALL_CHECK_MS);
- player._sfuFrozenInterval = _frozenInterval;
+ // The viewer-health monitor is already running (it starts with the media above);
+ // this event only records that the first frame reached the screen.
+ player?._viewerHealth?.markFrame();
};
};
video.addEventListener('playing', onPlaying);
video.addEventListener('playing', onPlaying);
- // NOTE: the first-frame stall timer is now armed in the 'connected'
- // connectionstatechange handler above, not here. Arming it here (before transport
- // connects) was the root cause of the 8-second re-watch loop: the timer fired before
- // ICE + DTLS completed AND before the first H.264 keyframe could arrive.
+ // NOTE: the first-frame stall timer is armed in the 'connected'
+ // connectionstatechange handler above. The health monitor takes over as soon as the
+ // media is attached, so a connected transport with no frame is recovered in 1.5 s
+ // rather than waiting out that 20 s grace window.
// Start playback after all tracks consumed
// Start playback after all tracks consumed
if (consumedCount >= 2) {
if (consumedCount >= 2) {
@@ -3586,6 +3601,8 @@ function getSavedPlayerAudioState() {
@@ -3586,6 +3601,8 @@ function getSavedPlayerAudioState() {
/* ── Cleanup ──────────────────────────────────────────────────── */
/* ── Cleanup ──────────────────────────────────────────────────── */
function destroyPlayer() {
function destroyPlayer() {
_playerGen++; // invalidate any in-flight WebRTC reconnect/rewatch closures from the old session
_playerGen++; // invalidate any in-flight WebRTC reconnect/rewatch closures from the old session
+ // The mini player holds this session's video element: give it back before the player dies.
+ try { window.OVMiniPlayer?.release(); } catch { /* */ }
// The overlay owns a websocket and a mediasoup transport of its own, so it has to be
// The overlay owns a websocket and a mediasoup transport of its own, so it has to be
// torn down explicitly — leaving it running would keep consuming a second stream.
// torn down explicitly — leaving it running would keep consuming a second stream.
try { window.streamPip?.detach(); } catch { /* */ }
try { window.streamPip?.detach(); } catch { /* */ }
@@ -3610,6 +3627,9 @@ function destroyPlayer() {
@@ -3610,6 +3627,9 @@ function destroyPlayer() {
window._jsmpegDrawInterval = null;
window._jsmpegDrawInterval = null;
}
}
if (player) {
if (player) {
+ if (player._onOnline) { window.removeEventListener('online', player._onOnline); player._onOnline = null; }
+ if (player._onVisibility) { document.removeEventListener('visibilitychange', player._onVisibility); player._onVisibility = null; }
+ if (player._viewerHealth) { try { player._viewerHealth.stop(); } catch { /* */ } player._viewerHealth = null; }
if (playerType === 'jsmpeg' && player.destroy) player.destroy();
if (playerType === 'jsmpeg' && player.destroy) player.destroy();
if (playerType === 'webrtc') {
if (playerType === 'webrtc') {
if (player.ws) player.ws.close();
if (player.ws) player.ws.close();
@@ -22,9 +22,13 @@ const MAX_VIEWER_SOCKETS_PER_IP = 16;
@@ -22,9 +22,13 @@ const MAX_VIEWER_SOCKETS_PER_IP = 16;
const config = require('../config');
const config = require('../config');
const { turnEntries } = require('../net/turn');
const { turnEntries } = require('../net/turn');
const whipHandler = require('./whip-handler');
const whipHandler = require('./whip-handler');
+const viewerHealth = require('./viewer-health');
const WS_HEARTBEAT_MS = 30000;
const WS_HEARTBEAT_MS = 30000;
const MAX_SEND_BACKPRESSURE = 512 * 1024;
const MAX_SEND_BACKPRESSURE = 512 * 1024;
+// A keyframe ask from a viewer costs every viewer of that stream a large I-frame: one per
+// consumer every 2 s (the viewer's own recovery ladder asks at that pace too).
+const VIEWER_KEYFRAME_ASK_MS = 2000;
class BroadcastServer extends EventEmitter {
class BroadcastServer extends EventEmitter {
constructor() {
constructor() {
@@ -496,6 +500,17 @@ class BroadcastServer extends EventEmitter {
@@ -496,6 +500,17 @@ class BroadcastServer extends EventEmitter {
}
}
break;
break;
+ // ── Viewer health (plan L23): telemetry, keyframe asks, transport restarts ──
+ case 'sfu-viewer-telemetry':
+ if (client.role === 'viewer') this._handleSfuViewerTelemetry(ws, client, msg);
+ break;
+ case 'sfu-viewer-keyframe':
+ if (client.role === 'viewer') this._handleSfuViewerKeyframe(ws, client);
+ break;
+ case 'sfu-viewer-restart':
+ if (client.role === 'viewer') this._handleSfuViewerRestart(ws, client);
+ break;
+
default:
default:
break;
break;
}
}
@@ -926,6 +941,87 @@ class BroadcastServer extends EventEmitter {
@@ -926,6 +941,87 @@ class BroadcastServer extends EventEmitter {
console.log(`[Broadcast] SFU viewer ${client.peerId} consuming ${result.kind} for stream ${client.streamId}`);
console.log(`[Broadcast] SFU viewer ${client.peerId} consuming ${result.kind} for stream ${client.streamId}`);
}
}
+ /**
+ * A viewer's telemetry (plan L23): the decoder's own numbers, every 5 s.
+ *
+ * The freeze test lives in server/streaming/viewer-health.js: no new decoded frame for 2 s
+ * while packets keep arriving, or the player's own freezeCount went up. A freeze is logged
+ * once — the stream, the peer, the codec — and counted for the metric.
+ */
+ _handleSfuViewerTelemetry(ws, client, msg, now = Date.now()) {
+ const payload = msg && typeof msg.data === 'object' && msg.data ? msg.data : {};
+ // A viewer may send these faster than the 5 s the player uses; one every 2 s is plenty.
+ if (client._lastViewerTelemetryAt && now - client._lastViewerTelemetryAt < 2000) return;
+ client._lastViewerTelemetryAt = now;
+
+ if (!client._viewerTracker) client._viewerTracker = new viewerHealth.ViewerTracker();
+ const seen = client._viewerTracker.observe(payload, now);
+ // One freeze, one line, one count — the same freeze seen again in the next sample is
+ // not a second freeze.
+ if (!seen.freeze || !seen.first) return;
+
+ const codec = seen.codec || 'unknown';
+ const total = viewerHealth.noteFreeze({
+ streamId: client.streamId,
+ peerId: client.peerId,
+ codec,
+ decoder: seen.decoder,
+ reason: seen.reason,
+ detail: seen.detail,
+ });
+ console.log(`[Broadcast] Viewer ${client.peerId} froze on stream ${client.streamId} — ${seen.reason} | codec=${codec} decoder=${seen.decoder || 'unknown'} | ${seen.detail} | ${total} freeze(s) on this stream`);
+ }
+
+ /**
+ * A viewer asks for a keyframe (the first step of its recovery ladder).
+ *
+ * The ask goes to the broadcaster's encoder as a PLI. Every PLI forces a large I-frame, so it
+ * is expensive for every viewer of that stream: one ask per consumer every 2 s, and only for
+ * this viewer's own video consumer.
+ */
+ _handleSfuViewerKeyframe(ws, client) {
+ const roomId = client._sfuViewerRoomId || `stream-${client.streamId}`;
+ const room = webrtcSFU.rooms?.get(roomId);
+ if (!room) { this.safeSend(ws, { type: 'sfu-keyframe-requested', asked: 0, throttled: 0 }); return; }
+ const now = Date.now();
+ if (!client._lastKeyframeAskAt) client._lastKeyframeAskAt = new Map();
+ let asked = 0;
+ let throttled = 0;
+ for (const cid of (client._sfuViewerConsumerIds || [])) {
+ const entry = room.consumers.get(cid);
+ if (!entry?.consumer || entry.consumer.closed || entry.consumer.kind !== 'video') continue;
+ const last = client._lastKeyframeAskAt.get(cid) || 0;
+ if (now - last < VIEWER_KEYFRAME_ASK_MS) { throttled++; continue; }
+ client._lastKeyframeAskAt.set(cid, now);
+ asked++;
+ entry.consumer.requestKeyFrame().catch(err => {
+ console.warn(`[Broadcast] Keyframe ask failed for consumer ${cid} (${client.peerId}): ${err.message}`);
+ });
+ }
+ console.log(`[Broadcast] Viewer ${client.peerId} asked for a keyframe on stream ${client.streamId} — asked=${asked} throttled=${throttled}`);
+ this.safeSend(ws, { type: 'sfu-keyframe-requested', asked, throttled });
+ }
+
+ /**
+ * A viewer asks for a fresh receive transport (the second step of its recovery ladder): the
+ * picture froze, the keyframes did not unstick it. The old transport is dropped and a new
+ * sfu-viewer-ready goes out, so the media session is rebuilt without the page reloading.
+ */
+ _handleSfuViewerRestart(ws, client) {
+ console.log(`[Broadcast] Viewer ${client.peerId} asked for a fresh transport on stream ${client.streamId}`);
+ // The new transport starts a new decoder: its framesDecoded counts from zero. A tracker
+ // carried over would read that reset as "no new frames" and invent a freeze.
+ client._viewerTracker = null;
+ client._lastViewerTelemetryAt = 0;
+ this._cleanupSfuViewerTransport(client);
+ this._tryCreateSfuViewer(ws, client).then((handled) => {
+ if (!handled) this.safeSend(ws, { type: 'sfu-source-unavailable', reason: 'ingest_stale' });
+ }).catch(err => {
+ console.error(`[Broadcast] SFU viewer restart failed for ${client.peerId}: ${err.message}`);
+ this.safeSend(ws, { type: 'sfu-viewer-error', error: err.message });
+ });
+ }
+
_cleanupSfuViewerTransport(client) {
_cleanupSfuViewerTransport(client) {
if (!client._sfuViewerTransportId) return;
if (!client._sfuViewerTransportId) return;
const roomId = client._sfuViewerRoomId || `stream-${client.streamId}`;
const roomId = client._sfuViewerRoomId || `stream-${client.streamId}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
+'use strict';
+/**
+ * Viewer freezes (plan L23, 2026-10-31).
+ *
+ * A viewer's picture can stop moving while everything else looks fine: the signaling socket is
+ * open, the SFU transport says "connected", the audio keeps playing. The owner saw it on stream
+ * 2833 (2026-10-31 04:05 UTC): the picture played for a few seconds and then froze, and a reload
+ * played fine. Without a number, "it froze" is a story; with one, it is a bug we can find.
+ *
+ * The player's own decoder reports every 5 s (public/js/ov-viewer-health.js): how many frames it
+ * decoded, how many it dropped, how many packets were lost, the jitter, the jitter buffer delay,
+ * the codec and the decoder. This module reads those samples and decides what a freeze is:
+ *
+ * • no new decoded frame for FREEZE_MS (2 s) while packets keep arriving — the picture is stuck
+ * but the media is flowing, so the fault is in the decode or the display, not in the network;
+ * • the player's own freezeCount went up — the player saw it too.
+ *
+ * A freeze is logged once (one line: the stream, the peer, the codec) and counted, per stream and
+ * per codec, so /metrics can show it as live_viewer_freezes_total{codec}.
+ */
+const FREEZE_MS = 2000;
+
+/** The per-stream rolling count and the last freezes (a ring, so a long night cannot grow). */
+const streams = new Map();
+const RING = 20;
+
+let counter = null; // the Prometheus counter, once the registry exists
+
+/** Attach the metric to the metrics registry (server/web/observability.js calls this). */
+function attachCounter(registry) {
+ if (counter) return counter;
+ if (!registry || typeof registry.counter !== 'function') return null;
+ counter = registry.counter({
+ name: 'live_viewer_freezes_total',
+ help: 'Viewer freezes on live streams (a picture that stopped moving while packets kept arriving)',
+ labelNames: ['codec'],
+ });
+ return counter;
+}
+
+/** The counter itself (null before the registry is attached). */
+function freezeCounter() { return counter; }
+
+const num = (v) => (typeof v === 'number' && isFinite(v) ? v : null);
+const str = (v, max = 64) => (typeof v === 'string' ? v.slice(0, max) : '');
+
+/** A telemetry sample, cleaned up: the browser is not a trusted source of numbers. */
+function normalize(sample) {
+ const s = sample && typeof sample === 'object' ? sample : {};
+ return {
+ framesDecoded: num(s.framesDecoded),
+ framesDropped: num(s.framesDropped),
+ packetsReceived: num(s.packetsReceived),
+ packetsLost: num(s.packetsLost),
+ jitter: num(s.jitter),
+ jitterBufferDelay: num(s.jitterBufferDelay),
+ freezeCount: num(s.freezeCount),
+ freezeMs: num(s.freezeMs),
+ codec: str(s.codec, 32),
+ decoder: str(s.decoder, 48),
+ };
+}
+
+/**
+ * Reads one viewer's samples and says when the picture froze.
+ *
+ * tracker.observe(sample) → { freeze, first, reason, detail, codec }
+ */
+class ViewerTracker {
+ constructor() {
+ this.last = null; // the previous sample
+ this.lastFrameAt = 0; // when a new decoded frame was last seen
+ this.inFreeze = false; // the freeze is already logged
+ this.clientFreezeCount = 0; // the player's own count, to spot a rise
+ }
+
+ /**
+ * One sample. Returns what the caller should log (nothing when the picture is moving).
+ * @param {object} sample the telemetry payload (already normalized or not)
+ * @param {number} now
+ */
+ observe(sample, now = Date.now()) {
+ const s = normalize(sample);
+ const out = { freeze: false, first: false, reason: '', detail: '', codec: s.codec, decoder: s.decoder };
+ const prev = this.last;
+ this.last = { ...s, at: now };
+
+ // The player saw a freeze: that is the strongest signal, and it counts each freeze once.
+ const cf = s.freezeCount;
+ if (cf !== null && cf > this.clientFreezeCount) {
+ this.clientFreezeCount = cf;
+ out.freeze = true;
+ out.first = true;
+ out.reason = 'the player saw the picture freeze';
+ out.detail = `freezeCount=${cf} freezeMs=${s.freezeMs ?? '?'} framesDecoded=${s.framesDecoded ?? '?'} framesDropped=${s.framesDropped ?? '?'} packetsLost=${s.packetsLost ?? '?'} jitter=${s.jitter ?? '?'} jitterBufferDelay=${s.jitterBufferDelay ?? '?'}`;
+ this.inFreeze = true;
+ return out;
+ }
+ if (cf !== null) this.clientFreezeCount = cf;
+
+ if (!prev) { this.lastFrameAt = now; return out; }
+
+ const fd = s.framesDecoded, pfd = prev.framesDecoded;
+ const pk = s.packetsReceived, ppk = prev.packetsReceived;
+ if (fd !== null && pfd !== null && fd > pfd) {
+ this.lastFrameAt = now;
+ this.inFreeze = false;
+ return out;
+ }
+ // No new frame — but are packets still arriving? If they are, the media flows and the
+ // picture is stuck in the decoder (a lost keyframe, a decode hiccup). If they are not,
+ // the network is the problem and the player recovers on its own.
+ if (pk !== null && ppk !== null && pk > ppk) {
+ const silent = now - (this.lastFrameAt || prev.at);
+ if (silent >= FREEZE_MS) {
+ out.freeze = true;
+ out.reason = `no new frame for ${Math.round(silent / 1000)}s while packets keep arriving`;
+ out.detail = `framesDecoded=${fd ?? '?'} packetsReceived=${pk} packetsLost=${s.packetsLost ?? '?'} jitter=${s.jitter ?? '?'} jitterBufferDelay=${s.jitterBufferDelay ?? '?'}`;
+ if (!this.inFreeze) { out.first = true; this.inFreeze = true; }
+ }
+ }
+ return out;
+ }
+}
+
+/** Count a freeze for a stream (and for the metric). */
+function noteFreeze({ streamId, peerId, codec, reason, detail, decoder }) {
+ const key = String(streamId || 'unknown');
+ const c = String(codec || 'unknown').slice(0, 32) || 'unknown';
+ let s = streams.get(key);
+ if (!s) { s = { total: 0, byCodec: {}, recent: [] }; streams.set(key, s); }
+ s.total++;
+ s.byCodec[c] = (s.byCodec[c] || 0) + 1;
+ s.recent.push({ at: Date.now(), peerId: String(peerId || ''), codec: c, decoder: String(decoder || ''), reason: String(reason || ''), detail: String(detail || '') });
+ if (s.recent.length > RING) s.recent.splice(0, s.recent.length - RING);
+ if (counter) counter.inc({ codec: c });
+ return s.total;
+}
+
+/** The freezes of one stream (the rolling count). */
+function streamFreezes(streamId) {
+ const s = streams.get(String(streamId));
+ return s ? s.total : 0;
+}
+
+/** Every stream's count, for a status endpoint or a test. */
+function allStreamFreezes() {
+ const out = {};
+ for (const [k, v] of streams) out[k] = v.total;
+ return out;
+}
+
+/** Forget everything (a test, and a fresh process). */
+function reset() {
+ streams.clear();
+}
+
+module.exports = {
+ FREEZE_MS,
+ ViewerTracker,
+ attachCounter,
+ freezeCounter,
+ normalize,
+ noteFreeze,
+ streamFreezes,
+ allStreamFreezes,
+ reset,
+};
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
* GET /metrics Prometheus text, direct loopback callers only (404 through nginx or any proxy):
* GET /metrics Prometheus text, direct loopback callers only (404 through nginx or any proxy):
* http_requests_total{method,route,status_class}, http_request_duration_seconds,
* http_requests_total{method,route,status_class}, http_request_duration_seconds,
* http_requests_in_flight, process/event-loop metrics, release_info{service,release},
* http_requests_in_flight, process/event-loop metrics, release_info{service,release},
- * live_streams_live, live_ws_connections{server}, live_events_outbox{status}
+ * live_streams_live, live_ws_connections{server}, live_events_outbox{status},
+ * live_viewer_freezes_total{codec} (a viewer's picture that stopped moving)
* GET /api/ready 200 while Live can serve (boot finished, the database answers); 503 otherwise.
* GET /api/ready 200 while Live can serve (boot finished, the database answers); 503 otherwise.
* Media, the WebRTC SFU and the Network signing key are optional: when one is
* Media, the WebRTC SFU and the Network signing key are optional: when one is
* missing the answer stays 200 with status "degraded" and names it, because Live
* missing the answer stays 200 with status "degraded" and names it, because Live
@@ -17,6 +18,7 @@
@@ -17,6 +18,7 @@
*/
*/
const metrics = require('openvibe-shared/metrics');
const metrics = require('openvibe-shared/metrics');
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
+const viewerHealth = require('../streaming/viewer-health');
// Long-lived responses whose "duration" is a viewing session, not a request.
// Long-lived responses whose "duration" is a viewing session, not a request.
const STREAMING_PATHS = [/^\/api\/live-events$/, /^\/api\/streams\/rtmp-proxy\//];
const STREAMING_PATHS = [/^\/api\/live-events$/, /^\/api\/streams\/rtmp-proxy\//];
@@ -51,6 +53,8 @@ function mountMetrics(app, { release }) {
@@ -51,6 +53,8 @@ function mountMetrics(app, { release }) {
*/
*/
function registerDomainGauges(registry, { liveStreams, wsServers = {}, outboxStatus }) {
function registerDomainGauges(registry, { liveStreams, wsServers = {}, outboxStatus }) {
registry.gauge({ name: 'live_streams_live', help: 'Streams currently marked live (the list /api/streams serves)', collect: () => liveStreams() });
registry.gauge({ name: 'live_streams_live', help: 'Streams currently marked live (the list /api/streams serves)', collect: () => liveStreams() });
+ // A viewer's picture that stopped moving while its packets kept arriving (plan L23).
+ viewerHealth.attachCounter(registry);
registry.gauge({
registry.gauge({
name: 'live_ws_connections', help: 'Open WebSocket connections per WebSocket server', labelNames: ['server'],
name: 'live_ws_connections', help: 'Open WebSocket connections per WebSocket server', labelNames: ['server'],
collect: () => Object.entries(wsServers)
collect: () => Object.entries(wsServers)
@@ -206,33 +206,24 @@ assert.ok(
@@ -206,33 +206,24 @@ assert.ok(
playerSrc.includes('_sfuFrozenInterval'),
playerSrc.includes('_sfuFrozenInterval'),
'stream-player.js must have a frozen-video detector (_sfuFrozenInterval)'
'stream-player.js must have a frozen-video detector (_sfuFrozenInterval)'
);
);
-// The detector used to tick to 30s and log. It now escalates in two stages, because a
-// frozen picture with audio still playing is usually one missing keyframe: ask for one
-// first, and only rebuild the transport if that does not unstick it. Assert the two
-// stages rather than the old tick counter and its wording.
-assert.ok(
- /STALL_TRIGGER_MS\s*=\s*\d+/.test(playerSrc) && /STALL_ESCALATE_MS\s*=\s*\d+/.test(playerSrc),
- 'frozen detector must have a trigger threshold and a later escalation threshold'
-);
-assert.ok(
- /requesting keyframe via re-watch/.test(playerSrc),
- 'stage 1 must ask for a keyframe before anything more disruptive'
-);
-assert.ok(
- /still frozen after keyframe nudge — rebuilding receive transport/.test(playerSrc),
- 'stage 2 must rebuild the receive transport when the keyframe did not help'
-);
+// The freeze ladder (public/js/ov-viewer-health.js, plan L23 R3, 2026-10-31): a picture that stops while video packets
+// still arrive asks for a keyframe, then restarts the receive transport, then rebuilds the viewer session; never a
+// page reload. test/viewer-health.test.js drives the ladder itself; here, the player wires each step.
+assert.ok(/OVViewerHealth\.createMonitor\(/.test(playerSrc), 'the player runs the viewer-health monitor');
+assert.ok(/sendPlayerSignal\(\{ type: 'sfu-viewer-restart' \}\)/.test(playerSrc), 'step 2 restarts the receive transport');
+assert.ok(/rebuild: \(\) => \{[\s\S]{0,900}initWebRTC\(stream\)/.test(playerSrc), 'step 3 rebuilds the viewer session');
+assert.ok(!/location\.reload\(/.test(playerSrc.slice(playerSrc.indexOf('createMonitor('), playerSrc.indexOf('createMonitor(') + 4000)), 'never a page reload');
assert.ok(
assert.ok(
playerSrc.includes('player._sfuFrozenInterval = null') &&
playerSrc.includes('player._sfuFrozenInterval = null') &&
playerSrc.includes('clearInterval(player._sfuFrozenInterval)'),
playerSrc.includes('clearInterval(player._sfuFrozenInterval)'),
'stream-player.js must clear the frozen interval when transport changes'
'stream-player.js must clear the frozen interval when transport changes'
);
);
-// Frozen re-watch must NOT start P2P offer timeout
-assert.ok(
- playerSrc.includes('Intentionally NOT calling _startWatchOfferTimeout()'),
- 'stream-player.js frozen-video re-watch must NOT call _startWatchOfferTimeout()'
-);
-console.log('OK I: frozen-video detector nudges with a keyframe, then rebuilds the transport');
+// The freeze ladder never starts the P2P offer timeout: the SFU never answers with an 'offer'.
+{
+ const at = playerSrc.indexOf('OVViewerHealth.createMonitor(');
+ assert.ok(!/_startWatchOfferTimeout\(\)/.test(playerSrc.slice(at, at + 3000).replace(/\/\/.*$/gm, '')), 'the freeze ladder must NOT call _startWatchOfferTimeout()');
+}
+console.log('OK I: the freeze ladder asks for a keyframe, restarts the transport, then rebuilds the session');
// ── J: broadcaster auto-publishes into SFU (SFU is the only media path) ──
// ── J: broadcaster auto-publishes into SFU (SFU is the only media path) ──
assert.ok(
assert.ok(
new file mode 100644
new file mode 100644
@@ -0,0 +1,435 @@
@@ -0,0 +1,435 @@
+'use strict';
+/**
+ * Viewer health (plan L23, 2026-10-31).
+ *
+ * The owner watched stream 2833 and the picture froze a few seconds in; a reload played fine.
+ * Without a number, "it froze" is a story. These are the numbers and the ladder:
+ *
+ * A. The recovery ladder, driven by a fake clock: ask for a keyframe, then restart the
+ * transport, then rebuild the session — in that order, and never a page reload.
+ * B. A frame that arrives puts the ladder back to zero and closes the freeze.
+ * C. The telemetry message: the decoder's own numbers, cleaned up.
+ * D. The debug report the viewer copies: the stream, the time, the browser, the codec, the
+ * decoder, the last sample and the steps — and nothing that identifies the viewer.
+ * E. The server: a freeze is one log line and one count, and the count reaches the metric.
+ * F. The server's rate limit on keyframe asks (one per consumer every 2 s).
+ * G. The server's telemetry handler: one line per freeze, not one per sample.
+ *
+ * node test/viewer-health.test.js
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+// ── the player's own module, in a sandbox ────────────────────────────────────────────────────────
+const playerSrc = fs.readFileSync(path.join(__dirname, '../public/js/ov-viewer-health.js'), 'utf8');
+const sandbox = {
+ window: {},
+ console,
+ setTimeout, clearTimeout, setInterval, clearInterval,
+ Date, Math, JSON, Object, Array, String, Number, isFinite, parseInt,
+};
+sandbox.window.window = sandbox.window;
+vm.createContext(sandbox);
+vm.runInContext(playerSrc, sandbox, { filename: 'ov-viewer-health.js' });
+const VH = sandbox.window.OVViewerHealth;
+assert.ok(VH, 'ov-viewer-health.js must expose window.OVViewerHealth');
+
+let failures = 0;
+function check(name, fn) {
+ try { fn(); console.log(' ✓', name); }
+ catch (e) { failures++; console.log(' ✗', name, '\n ', e.message); }
+}
+
+// ── A: the ladder, with a fake clock ────────────────────────────────────────────────────────────
+check('A1 the ladder asks for a keyframe after 1.5 s of a still picture', () => {
+ let t = 10000;
+ const ladder = VH.createLadder({ now: () => t });
+ ladder.setConnected(true);
+ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall
+ assert.equal(ladder.tick().action, 'none', 'a moving picture asks for nothing');
+ t += 1600;
+ const step = ladder.tick();
+ assert.equal(step.action, 'keyframe', 'a still picture asks for a keyframe');
+ assert.equal(ladder.stage, 1);
+});
+
+check('A2 the ladder asks three times, then restarts the transport, then rebuilds', () => {
+ let t = 10000;
+ const ladder = VH.createLadder({ now: () => t });
+ ladder.setConnected(true);
+ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall
+ t += 1600;
+ assert.equal(ladder.tick().action, 'keyframe');
+ t += 2000;
+ assert.equal(ladder.tick().action, 'keyframe');
+ assert.equal(ladder.keyframeAsks, 2);
+ t += 2000;
+ assert.equal(ladder.tick().action, 'keyframe');
+ assert.equal(ladder.keyframeAsks, 3);
+ t += 2000;
+ const restart = ladder.tick();
+ assert.equal(restart.action, 'restart-transport', 'three keyframe asks and the transport is restarted');
+ assert.equal(ladder.stage, 2);
+ t += 8000;
+ const rebuild = ladder.tick();
+ assert.equal(rebuild.action, 'rebuild', 'a restarted transport that stays frozen rebuilds the session');
+ assert.equal(ladder.stage, 3);
+});
+
+check('A3 the ladder restarts the transport after 8 s of asking, even with fewer asks', () => {
+ let t = 10000;
+ const ladder = VH.createLadder({ now: () => t });
+ ladder.setConnected(true);
+ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall
+ t += 1600;
+ assert.equal(ladder.tick().action, 'keyframe');
+ // The player asks again only every 2 s, so the asks are few: the 8 s rule must still fire.
+ t += 8000;
+ const step = ladder.tick();
+ assert.equal(step.action, 'restart-transport');
+});
+
+check('A4 the ladder asks again no more often than every 2 s', () => {
+ let t = 10000;
+ const ladder = VH.createLadder({ now: () => t });
+ ladder.setConnected(true);
+ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall
+ t += 1600;
+ assert.equal(ladder.tick().action, 'keyframe');
+ t += 500; // too soon for another ask
+ assert.equal(ladder.tick().action, 'wait');
+ assert.equal(ladder.keyframeAsks, 1);
+ t += 1500;
+ assert.equal(ladder.tick().action, 'keyframe');
+ assert.equal(ladder.keyframeAsks, 2);
+});
+
+check('A5 a transport that is not connected never escalates', () => {
+ let t = 10000;
+ const ladder = VH.createLadder({ now: () => t });
+ // Not connected: the ladder has nothing to say.
+ t += 60000;
+ assert.equal(ladder.tick().action, 'none');
+ ladder.setConnected(true);
+ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall
+ t += 1600;
+ assert.equal(ladder.tick().action, 'keyframe');
+});
+
+check('A6 a transport restarted by the ladder starts at the rebuild step', () => {
+ let t = 10000;
+ const ladder = VH.createLadder({ now: () => t, startStage: 2 });
+ ladder.setConnected(true);
+ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall
+ t += 1600;
+ assert.equal(ladder.tick().action, 'wait', 'the keyframe asks were already spent on the old transport');
+ t += 7000;
+ assert.equal(ladder.tick().action, 'rebuild', 'a restarted transport that stays frozen rebuilds the session');
+ assert.equal(ladder.stage, 3);
+ // A frame that arrives after all that puts the ladder back to zero.
+ ladder.markFrame();
+ assert.equal(ladder.stage, 0);
+});
+
+// ── B: a frame that arrives ──────────────────────────────────────────────────────────────────────
+
+check('A7 a quiet sender is not a freeze: no packets and no frames for a while asks for nothing', () => {
+ let t = 10000;
+ const ladder = VH.createLadder({ now: () => t });
+ ladder.setConnected(true);
+ t += 5000;
+ assert.equal(ladder.tick().action, 'none', 'a static screen share sends nothing until something changes');
+ t += 10000;
+ assert.equal(ladder.tick().action, 'none', 'still nothing at 15 s');
+ assert.equal(ladder.stage, 0);
+});
+
+check('A8 twenty seconds with no video packets at all climbs the ladder (the transport may be dead)', () => {
+ let t = 10000;
+ const ladder = VH.createLadder({ now: () => t });
+ ladder.setConnected(true);
+ t += 20500;
+ assert.equal(ladder.tick().action, 'keyframe');
+});
+
+check('A9 packets after a frame, then no frame: the stall is caught from the packets', () => {
+ let t = 10000;
+ const ladder = VH.createLadder({ now: () => t });
+ ladder.setConnected(true);
+ t += 500; ladder.markFrame();
+ t += 300; ladder.markPackets();
+ t += 1600;
+ assert.equal(ladder.tick().action, 'keyframe', 'packets arrived and nothing decoded');
+});
+
+check('B1 a new frame puts the ladder back to zero and closes the freeze', () => {
+ let t = 10000;
+ const ladder = VH.createLadder({ now: () => t });
+ ladder.setConnected(true);
+ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall
+ t += 1600;
+ ladder.tick();
+ assert.equal(ladder.stage, 1);
+ assert.equal(ladder.freezeCount, 1, 'one freeze so far');
+ t += 1000;
+ ladder.markFrame();
+ assert.equal(ladder.stage, 0, 'the picture moved: the ladder is back to zero');
+ assert.ok(ladder.freezeMs >= 1000, 'the freeze duration is counted');
+ ladder.markPackets();
+ t += 1600;
+ ladder.tick();
+ assert.equal(ladder.freezeCount, 2, 'a second freeze is a second freeze');
+});
+
+check('B2 the duration counts the freeze that is still running', () => {
+ let t = 10000;
+ const ladder = VH.createLadder({ now: () => t });
+ ladder.setConnected(true);
+ ladder.markPackets(); // video packets keep arriving: a still picture is a decoder stall
+ t += 1600;
+ ladder.tick();
+ assert.ok(ladder.freezeMs >= 1600, 'the running freeze is already part of the total');
+ t += 2000;
+ assert.ok(ladder.freezeMs >= 3600, 'and it keeps growing until a frame arrives');
+ ladder.markFrame();
+ t += 1000;
+ assert.ok(ladder.freezeMs >= 3600, 'a frame closes it without losing the time');
+});
+
+// ── C: the telemetry message ────────────────────────────────────────────────────────────────────
+check('C1 the telemetry carries the decoder numbers and nothing else', () => {
+ const stats = VH.readStats({
+ framesDecoded: 120, framesDropped: 3, packetsReceived: 900, packetsLost: 2,
+ jitter: 0.01, jitterBufferDelay: 0.05, decoderImplementation: 'FFmpegVideoDecoder',
+ codecId: 'H264', somethingElse: 'ignored', framesDecodedX: 'nope',
+ });
+ assert.equal(stats.framesDecoded, 120);
+ assert.equal(stats.decoderImplementation, 'FFmpegVideoDecoder');
+ assert.equal(stats.somethingElse, undefined, 'an unknown field is not carried over');
+ const msg = VH.buildTelemetry({ streamId: 2833, stats, freezeCount: 1, freezeMs: 2500, codec: 'video/H264' });
+ assert.equal(msg.type, 'sfu-viewer-telemetry');
+ assert.equal(msg.streamId, '2833');
+ assert.equal(msg.data.codec, 'video/H264');
+ assert.equal(msg.data.decoder, 'FFmpegVideoDecoder');
+ assert.equal(msg.data.freezeCount, 1);
+ assert.equal(msg.data.freezeMs, 2500);
+ assert.ok(msg.data.at > 0);
+});
+
+check('C2 a browser that reports nothing still sends a usable message', () => {
+ const msg = VH.buildTelemetry({ streamId: '7', stats: {} });
+ assert.equal(msg.type, 'sfu-viewer-telemetry');
+ assert.equal(msg.data.framesDecoded, null);
+ assert.equal(msg.data.freezeCount, 0);
+ assert.equal(msg.data.codec, '');
+});
+
+// ── D: the report the viewer copies ─────────────────────────────────────────────────────────────
+check('D1 the report names the stream, the codec, the decoder, the sample and the steps', () => {
+ const text = VH.buildDebugReport({
+ streamId: 2833,
+ codec: 'video/H264',
+ decoder: 'VideoToolboxVideoDecoder',
+ telemetry: { framesDecoded: 10, packetsLost: 1, freezeCount: 1, freezeMs: 900 },
+ steps: ['asked for a keyframe', 'restarted the receive transport'],
+ browser: 'TestBrowser/1.0',
+ at: Date.UTC(2026, 9, 31, 4, 6, 22),
+ });
+ assert.ok(text.includes('stream_id=2833'));
+ assert.ok(text.includes('browser=TestBrowser/1.0'));
+ assert.ok(text.includes('codec=video/H264'));
+ assert.ok(text.includes('decoder=VideoToolboxVideoDecoder'));
+ assert.ok(text.includes('framesDecoded=10'));
+ assert.ok(text.includes('freezeCount=1'));
+ assert.ok(text.includes('asked for a keyframe'));
+ assert.ok(text.includes('restarted the receive transport'));
+ assert.ok(text.includes('2026-10-31T04:06:22'), 'the time of the freeze is in the report');
+});
+
+check('D2 the report carries no token, no cookie and no personal data', () => {
+ const text = VH.buildDebugReport({
+ streamId: 2833, codec: 'video/H264', decoder: 'x', telemetry: {}, steps: [], browser: 'UA',
+ });
+ for (const forbidden of ['token', 'cookie', 'Bearer', 'authorization', 'password']) {
+ assert.ok(!text.toLowerCase().includes(forbidden.toLowerCase()), `the report must not mention ${forbidden}`);
+ }
+});
+
+// ── E: the server's freeze detection and its metric ─────────────────────────────────────────────
+const viewerHealth = require('../server/streaming/viewer-health');
+
+check('E1 a moving picture is not a freeze', () => {
+ const tracker = new viewerHealth.ViewerTracker();
+ let seen = tracker.observe({ framesDecoded: 10, packetsReceived: 100, codec: 'video/H264' }, 1000);
+ assert.equal(seen.freeze, false);
+ seen = tracker.observe({ framesDecoded: 40, packetsReceived: 400, codec: 'video/H264' }, 6000);
+ assert.equal(seen.freeze, false);
+});
+
+check('E2 no new frame for 2 s while packets arrive is a freeze, logged once', () => {
+ const tracker = new viewerHealth.ViewerTracker();
+ tracker.observe({ framesDecoded: 40, packetsReceived: 400, codec: 'video/H264' }, 1000);
+ // The frames stop but the packets keep coming: the media flows, the picture is stuck.
+ let seen = tracker.observe({ framesDecoded: 40, packetsReceived: 500, codec: 'video/H264' }, 2000);
+ assert.equal(seen.freeze, false, 'one second of silence is not yet a freeze');
+ seen = tracker.observe({ framesDecoded: 40, packetsReceived: 600, codec: 'video/H264' }, 3500);
+ assert.equal(seen.freeze, true, '2.5 s without a frame while packets arrive is a freeze');
+ assert.equal(seen.first, true, 'the freeze is logged the first time it is seen');
+ assert.ok(seen.reason.includes('no new frame'));
+ // The next sample is the same freeze: it is not logged again.
+ seen = tracker.observe({ framesDecoded: 40, packetsReceived: 700, codec: 'video/H264' }, 5000);
+ assert.equal(seen.freeze, true);
+ assert.equal(seen.first, false, 'the same freeze is not logged twice');
+});
+
+check('E3 a picture that moves again ends the freeze', () => {
+ const tracker = new viewerHealth.ViewerTracker();
+ tracker.observe({ framesDecoded: 40, packetsReceived: 400 }, 1000);
+ tracker.observe({ framesDecoded: 40, packetsReceived: 500 }, 3500);
+ tracker.observe({ framesDecoded: 90, packetsReceived: 900 }, 4000);
+ const seen = tracker.observe({ framesDecoded: 130, packetsReceived: 950 }, 9000);
+ assert.equal(seen.freeze, false, 'the picture kept moving: no freeze');
+});
+
+check('E4 the player saying it saw a freeze is a freeze too', () => {
+ const tracker = new viewerHealth.ViewerTracker();
+ tracker.observe({ framesDecoded: 40, packetsReceived: 400, freezeCount: 0 }, 1000);
+ const seen = tracker.observe({ framesDecoded: 40, packetsReceived: 500, freezeCount: 1 }, 6000);
+ assert.equal(seen.freeze, true);
+ assert.ok(seen.reason.includes('player'));
+});
+
+check('E5 a freeze is counted per stream and reaches the metric', () => {
+ viewerHealth.reset();
+ const series = [];
+ const registry = {
+ counter(opts) {
+ const s = {};
+ const c = {
+ name: opts.name,
+ inc(labels) {
+ const key = JSON.stringify(labels || {});
+ s[key] = (s[key] || 0) + 1;
+ series.push({ key, count: s[key] });
+ },
+ };
+ return c;
+ },
+ };
+ viewerHealth.attachCounter(registry);
+ const total = viewerHealth.noteFreeze({ streamId: 2833, peerId: 'peer-4', codec: 'video/H264', reason: 'no new frame', detail: 'framesDecoded=40' });
+ assert.equal(total, 1);
+ assert.equal(viewerHealth.streamFreezes(2833), 1);
+ assert.equal(viewerHealth.streamFreezes(9999), 0);
+ assert.equal(series.length, 1);
+ assert.equal(series[0].key, JSON.stringify({ codec: 'video/H264' }));
+ assert.equal(series[0].count, 1);
+ viewerHealth.noteFreeze({ streamId: 2833, peerId: 'peer-5', codec: 'video/H264' });
+ assert.equal(viewerHealth.streamFreezes(2833), 2);
+ viewerHealth.reset();
+ assert.equal(viewerHealth.streamFreezes(2833), 0);
+});
+
+check('E6 a telemetry payload from the browser is cleaned up before it is counted', () => {
+ const s = viewerHealth.normalize({ framesDecoded: 'many', packetsLost: 3, codec: 'x'.repeat(200), freezeCount: null });
+ assert.equal(s.framesDecoded, null, 'a number that is not a number is dropped');
+ assert.equal(s.packetsLost, 3);
+ assert.equal(s.codec.length, 32, 'a long codec name is cut');
+ assert.equal(s.freezeCount, null);
+});
+
+// ── F: the server's rate limit on keyframe asks ─────────────────────────────────────────────────
+const broadcastServer = require('../server/streaming/broadcast-server');
+const webrtcSFU = require('../server/streaming/webrtc-sfu');
+const { WebSocket } = require('ws');
+
+check('F1 one keyframe ask per consumer every 2 s', () => {
+ const asked = [];
+ const room = {
+ consumers: new Map(),
+ transports: new Map(),
+ producers: new Map(),
+ };
+ room.consumers.set('c-video', {
+ consumer: { kind: 'video', closed: false, requestKeyFrame: async () => { asked.push('c-video'); } },
+ });
+ room.consumers.set('c-audio', {
+ consumer: { kind: 'audio', closed: false, requestKeyFrame: async () => { asked.push('c-audio'); } },
+ });
+ webrtcSFU.rooms.set('stream-4242', room);
+ const sent = [];
+ const ws = { readyState: WebSocket.OPEN, bufferedAmount: 0, send: (raw) => sent.push(JSON.parse(raw)) };
+ const client = {
+ role: 'viewer', peerId: 'peer-test', streamId: 4242,
+ _sfuViewerRoomId: 'stream-4242', _sfuViewerConsumerIds: ['c-video', 'c-audio'],
+ };
+
+ broadcastServer._handleSfuViewerKeyframe(ws, client);
+ assert.deepEqual(asked, ['c-video'], 'only the video consumer is asked, and only once');
+
+ // Straight away: the 2 s rate limit holds.
+ broadcastServer._handleSfuViewerKeyframe(ws, client);
+ assert.deepEqual(asked, ['c-video'], 'a second ask inside the window is throttled');
+ assert.ok(sent.length === 2);
+ assert.equal(sent[1].throttled, 1, 'the viewer is told the ask was throttled');
+
+ // After the window: the ask goes through again.
+ const last = client._lastKeyframeAskAt.get('c-video');
+ client._lastKeyframeAskAt.set('c-video', last - 2000);
+ broadcastServer._handleSfuViewerKeyframe(ws, client);
+ assert.deepEqual(asked, ['c-video', 'c-video'], 'after 2 s the ask goes through');
+ webrtcSFU.rooms.delete('stream-4242');
+});
+
+// ── G: the server's telemetry handler ───────────────────────────────────────────────────────────
+check('G1 the server logs one line per freeze and counts it', () => {
+ viewerHealth.reset();
+ const logged = [];
+ const realLog = console.log;
+ console.log = (...args) => { logged.push(args.join(' ')); };
+ try {
+ const sent = [];
+ const ws = { readyState: WebSocket.OPEN, bufferedAmount: 0, send: (raw) => sent.push(JSON.parse(raw)) };
+ const client = { role: 'viewer', peerId: 'peer-4', streamId: 2833 };
+ broadcastServer._handleSfuViewerTelemetry(ws, client, { data: { framesDecoded: 40, packetsReceived: 400, codec: 'video/H264' } }, 1000);
+ // Three seconds later the frames stop but the packets keep coming.
+ broadcastServer._handleSfuViewerTelemetry(ws, client, { data: { framesDecoded: 40, packetsReceived: 500, codec: 'video/H264' } }, 4000);
+ broadcastServer._handleSfuViewerTelemetry(ws, client, { data: { framesDecoded: 40, packetsReceived: 600, codec: 'video/H264' } }, 7000);
+
+ const freezeLines = logged.filter((l) => l.includes('froze on stream 2833'));
+ assert.equal(freezeLines.length, 1, 'the freeze is logged once, not once per sample');
+ assert.ok(freezeLines[0].includes('peer-4'));
+ assert.ok(freezeLines[0].includes('video/H264'));
+ assert.equal(viewerHealth.streamFreezes(2833), 1);
+ } finally {
+ console.log = realLog;
+ viewerHealth.reset();
+ }
+});
+
+check('G2 a telemetry message that arrives too fast is ignored', () => {
+ viewerHealth.reset();
+ const logged = [];
+ const realLog = console.log;
+ console.log = (...args) => { logged.push(args.join(' ')); };
+ try {
+ const ws = { readyState: WebSocket.OPEN, bufferedAmount: 0, send: () => {} };
+ const client = { role: 'viewer', peerId: 'peer-5', streamId: 2834 };
+ broadcastServer._handleSfuViewerTelemetry(ws, client, { data: { framesDecoded: 1, packetsReceived: 1, codec: 'video/VP8' } }, 1000);
+ broadcastServer._handleSfuViewerTelemetry(ws, client, { data: { framesDecoded: 1, packetsReceived: 2, codec: 'video/VP8' } }, 1500);
+ const freezeLines = logged.filter((l) => l.includes('froze on stream 2834'));
+ assert.equal(freezeLines.length, 0, 'a flood of telemetry does not invent freezes');
+ } finally {
+ console.log = realLog;
+ viewerHealth.reset();
+ }
+});
+
+if (failures) {
+ console.log(`\n${failures} check(s) failed`);
+ process.exit(1);
+}
+console.log('\nviewer health: all checks passed');