openvibers/openvibe.live Commits

Player: stop the background-tab re-watch loop (framesDecoded as primary stall signal, no escalation while hidden, rebuild backoff); throttle keyframe nudges server-side

OP OpenVibers committed 26 Aug 2026 · 5380736c34 · 3 files, +36 −7


Changes

ddiff --git a/public/index.html b/public/index.html
iindex 7cceaaa6..d41bbf22 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -4971,7 +4971,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/app.js?v=125" defer></script>
--<script src="/js/stream-player.js?v=20" defer></script>
++<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex 822e57ba..c9db7f9f 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -2121,6 +2121,14 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
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') {
@@@ -2136,11 +2144,12 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
const _frozenInterval = setInterval(async () => {
if (!player || player._sfuRecvTransport !== recvTransport) { clearInterval(_frozenInterval); return; }
-- if (video.paused || !(video.srcObject?.active)) { clearInterval(_frozenInterval); return; }
++ if (video.paused || !(video.srcObject?.active)) { clearInterval(_frozenInterval); document.removeEventListener('visibilitychange', _onVisibility); return; }
-- // Fallback progress signal when rVFC is unavailable (e.g. older Firefox): decoded
-- // video frame count from getStats. A rising count == video is still rendering.
-- if (!_usingRVFC && player._sfuVideoReceiver?.getStats) {
++ // 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;
@@@ -2154,9 +2163,15 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
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`);
@@@ -2173,7 +2188,13 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
_hasVideoFrames = false;
if (player._sfuRecvTransport === recvTransport) player._sfuRecvTransport = null;
try { recvTransport.close(); } catch { /* */ }
-- scheduleRewatch(1000);
++ 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;
ddiff --git a/server/streaming/broadcast-server.js b/server/streaming/broadcast-server.js
iindex 7b498e29..a4497791 100644
---- a/server/streaming/broadcast-server.js
++++ b/server/streaming/broadcast-server.js
@@@ -785,7 +785,15 @@ class BroadcastServer extends EventEmitter {
if (activeConsumerIds.length > 0) {
// Consumers are alive — nudge with keyframe and ack the client so
// startWatchOfferTimeout doesn't fire and loop.
-- console.log(`[Broadcast] Viewer ${client.peerId} re-watch with connected SFU transport ${client._sfuViewerTransportId} (${activeConsumerIds.length} consumers) — requesting keyframe`);
++ // Throttle: a looping client (or many viewers re-watching at once) must
++ // not turn into a keyframe storm on the broadcaster's encoder — every
++ // PLI forces a large I-frame and tanks quality for EVERY viewer.
++ const nowMs = Date.now();
++ const throttled = client._lastKeyframeNudgeAt && (nowMs - client._lastKeyframeNudgeAt) < 2000;
++ client._rewatchNudges = (client._rewatchNudges || 0) + 1;
++ console.log(`[Broadcast] Viewer ${client.peerId} re-watch with connected SFU transport ${client._sfuViewerTransportId} (${activeConsumerIds.length} consumers) — ${throttled ? 'keyframe nudge throttled' : 'requesting keyframe'}${client._rewatchNudges > 10 ? ` (${client._rewatchNudges} re-watches this session — client likely looping)` : ''}`);
++ if (throttled) { this.safeSend(ws, { type: 'sfu-keyframe-requested' }); return true; }
++ client._lastKeyframeNudgeAt = nowMs;
for (const cid of activeConsumerIds) {
const entry = existingRoom.consumers.get(cid);
if (entry?.consumer && entry.consumer.kind === 'video') {