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
Changes
@@@ -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>@@@ -2121,6 +2121,14 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatclet _recoverStage = 0; // 0 = healthy, 1 = keyframe requestedlet _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, scheduleRewatcconst _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, scheduleRewatcif (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;@@@ -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') {
@@ -4971,7 +4971,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/app.js?v=125" 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/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
@@ -2121,6 +2121,14 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -2121,6 +2121,14 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
let _recoverStage = 0; // 0 = healthy, 1 = keyframe requested
let _recoverStage = 0; // 0 = healthy, 1 = keyframe requested
let _recoverAt = 0;
let _recoverAt = 0;
let _usingRVFC = false;
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.
// Primary signal: rVFC fires only when a NEW frame is actually presented.
if (typeof video.requestVideoFrameCallback === 'function') {
if (typeof video.requestVideoFrameCallback === 'function') {
@@ -2136,11 +2144,12 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -2136,11 +2144,12 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
const _frozenInterval = setInterval(async () => {
const _frozenInterval = setInterval(async () => {
if (!player || player._sfuRecvTransport !== recvTransport) { clearInterval(_frozenInterval); return; }
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 {
try {
const stats = await player._sfuVideoReceiver.getStats();
const stats = await player._sfuVideoReceiver.getStats();
let fd = null;
let fd = null;
@@ -2154,9 +2163,15 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -2154,9 +2163,15 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
if (stalledFor <= STALL_TRIGGER_MS) {
if (stalledFor <= STALL_TRIGGER_MS) {
// Video is progressing — healthy (or recovered after a nudge).
// Video is progressing — healthy (or recovered after a nudge).
if (_recoverStage !== 0) { console.log('[Player] SFU video recovered'); _recoverStage = 0; }
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;
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) {
if (_recoverStage === 0) {
// Stage 1: cheap keyframe request (server calls consumer.requestKeyFrame() on re-watch).
// 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`);
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
@@ -2173,7 +2188,13 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
_hasVideoFrames = false;
_hasVideoFrames = false;
if (player._sfuRecvTransport === recvTransport) player._sfuRecvTransport = null;
if (player._sfuRecvTransport === recvTransport) player._sfuRecvTransport = null;
try { recvTransport.close(); } catch { /* */ }
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);
}, STALL_CHECK_MS);
player._sfuFrozenInterval = _frozenInterval;
player._sfuFrozenInterval = _frozenInterval;
@@ -785,7 +785,15 @@ class BroadcastServer extends EventEmitter {
@@ -785,7 +785,15 @@ class BroadcastServer extends EventEmitter {
if (activeConsumerIds.length > 0) {
if (activeConsumerIds.length > 0) {
// Consumers are alive — nudge with keyframe and ack the client so
// Consumers are alive — nudge with keyframe and ack the client so
// startWatchOfferTimeout doesn't fire and loop.
// 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) {
for (const cid of activeConsumerIds) {
const entry = existingRoom.consumers.get(cid);
const entry = existingRoom.consumers.get(cid);
if (entry?.consumer && entry.consumer.kind === 'video') {
if (entry?.consumer && entry.consumer.kind === 'video') {