openvibers/openvibe.live Commits

Viewers recover from a frozen picture, and the player says what it saw (#470)

OP OpenVibers committed 13 days ago · 23544066fe · 12 files, +1569 −130

* Viewers recover from a frozen picture, and the player says what it saw

* Freeze ladder: a still picture is a freeze only while video packets arrive; no video means nothing to judge

Review of #470. The ladder judged by decoded frames alone, so two ordinary streams would have climbed it for ever:
- an audio-only stream (no video receiver): no frames, so keyframe asks, a transport restart and a rebuild, again
  and again;
- a static screen share: the sender sends frames only when something changes.

Now the monitor reads the inbound packet count too. A picture that stops while video packets keep arriving (a decoder
stall, the 2833 case) climbs as before; a quiet sender asks for nothing; twenty seconds with no video packets at all
climbs (the transport may be dead); and with no video receiver the ladder waits. Tests A7-A9 cover the three.

test/sfu-viewer-stability.test.js section I followed the old two-stage detector (STALL_TRIGGER_MS and the re-watch
wording); it now checks the ladder's wiring: the monitor runs, step 2 restarts the receive transport, step 3
rebuilds the session, never a reload, and never the P2P offer timeout.

Changes

ddiff --git a/public/features.json b/public/features.json
iindex 3f92a382..9a6cba4c 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -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"
]
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 08136fc6..c00ec00b 100644
---- a/public/js/app.js
++++ b/public/js/app.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; }
}
ddiff --git a/public/js/home-featured.js b/public/js/home-featured.js
iindex 25b38d73..deafb753 100644
---- a/public/js/home-featured.js
++++ b/public/js/home-featured.js
@@@ -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 sheet
render(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 = ''; }
ddiff --git a/public/js/media-player.js b/public/js/media-player.js
iindex 44c08146..9816d45c 100644
---- a/public/js/media-player.js
++++ b/public/js/media-player.js
@@@ -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`, {
ddiff --git a/public/js/ov-mini-player.js b/public/js/ov-mini-player.js
nnew file mode 100644
iindex 00000000..80f7a088
---- /dev/null
++++ b/public/js/ov-mini-player.js
@@@ -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 };
++})();
ddiff --git a/public/js/ov-viewer-health.js b/public/js/ov-viewer-health.js
nnew file mode 100644
iindex 00000000..758cdcf3
---- /dev/null
++++ b/public/js/ov-viewer-health.js
@@@ -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,
++ };
++})();
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex 307d87c1..be2ac121 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -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 it
let _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, scheduleRewatc
if (player._playRetryTimer) { clearTimeout(player._playRetryTimer); player._playRetryTimer = null; }
// Clear any post-play frozen-video checker from the previous SFU session
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
if (player._sfuTransportConnectTimeout) {
clearTimeout(player._sfuTransportConnectTimeout);
@@@ -1600,6 +1632,9 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
if (!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 source
video.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 consumed
if (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();
ddiff --git a/server/streaming/broadcast-server.js b/server/streaming/broadcast-server.js
iindex b54e7120..0cc8a677 100644
---- a/server/streaming/broadcast-server.js
++++ b/server/streaming/broadcast-server.js
@@@ -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}`;
ddiff --git a/server/streaming/viewer-health.js b/server/streaming/viewer-health.js
nnew file mode 100644
iindex 00000000..21f7e32f
---- /dev/null
++++ b/server/streaming/viewer-health.js
@@@ -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,
++};
ddiff --git a/server/web/observability.js b/server/web/observability.js
iindex f653aee7..7e0c25dc 100644
---- a/server/web/observability.js
++++ b/server/web/observability.js
@@@ -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)
ddiff --git a/test/sfu-viewer-stability.test.js b/test/sfu-viewer-stability.test.js
iindex 0e343adb..669be996 100644
---- a/test/sfu-viewer-stability.test.js
++++ b/test/sfu-viewer-stability.test.js
@@@ -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(
ddiff --git a/test/viewer-health.test.js b/test/viewer-health.test.js
nnew file mode 100644
iindex 00000000..4d6baebc
---- /dev/null
++++ b/test/viewer-health.test.js
@@@ -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');