zebra-spaces: 120s mute window for screen + game (static content), 15s stays for camera

Screen shares and game shares can sit static for long stretches — a still
desktop, a paused video, a code editor with no caret movement. The
encoder genuinely stops emitting RTP, the subscriber's track goes muted,
and the 15s camera window would falsely reap the live tile.

watchVideoTrackForRemoval now takes a per-call windowMs; the sub-PC
ontrack handler passes VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS (120s) for
screen + game and VIDEO_REMOVE_MUTE_WINDOW_MS (15s) for camera. A
genuine unshare still resolves through the 'ended' path within a
frame, so the longer window only affects the slow-failure case.

Tests bumped to 18: new screen-window assertions + invalid-windowMs
fallback to the default rather than disabling removal entirely.
This commit is contained in:
Russell Ballestrini 2026-06-03 10:33:28 -04:00
parent 9419a415bf
commit 0878996e96
No known key found for this signature in database
2 changed files with 78 additions and 21 deletions

View file

@ -1601,21 +1601,37 @@ function flushSfuStreams(){
* subscriber's pc and renegotiates. Browsers don't reliably fire
* 'ended' on remote tracks under this path (Chromium half-fires,
* Firefox stays silent). They DO fire 'mute' when RTP stops arriving.
* Watch both: a sustained mute for >MUTE_WINDOW ms = the publisher is
* Watch both: a sustained mute for >windowMs = the publisher is
* (probably) gone and we remove the tile. If 'unmute' fires within
* the window — transient network blip, NACK retransmission gap, brief
* CPU pressure on publisher, mobile network handoff — we cancel the
* removal.
* CPU pressure on publisher, mobile network handoff, OR (for screen
* shares) a long stretch of static content where the encoder simply
* isn't producing RTP — we cancel the removal.
*
* Window depends on the kind of content (caller passes via windowMs):
* - 15s for cameras: face/motion content keeps RTP flowing
* continuously, so a 15s gap is meaningful. Generous enough to
* weather Wi-Fi stalls and mobile network handoffs.
* - 120s for screen / gameshare: STATIC content (a still desktop, a
* paused video, a code editor with no caret motion) doesn't push
* new RTP for long stretches. The encoder genuinely stops emitting
* packets, the receiver's track goes muted, and the 15s window
* would falsely reap a live tile. 120s lets a static screen
* survive comfortably; a real unshare still resolves through the
* 'ended' path within a frame.
*
* Window history:
* - 3s: too aggressive — transient blips killed live tiles, the
* 'host shares screen, other speaker loses it' regression.
* - 15s: enough to weather Wi-Fi stalls and mobile network handoffs
* without leaving frozen tiles around for ages. A genuine unshare
* still removes the tile within 15s. */
* - 3s (all kinds): killed live tiles on any transient mute.
* - 15s (all kinds): cameras ok, but static screens vanished after
* 15s of no motion.
* - 15s camera / 120s screen+game (current). */
const VIDEO_REMOVE_MUTE_WINDOW_MS = 15000;
function watchVideoTrackForRemoval(track, removeFn){
const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 120000;
function watchVideoTrackForRemoval(track, removeFn, windowMs){
if (!track) return;
if (typeof windowMs !== 'number' || !isFinite(windowMs) || windowMs <= 0){
windowMs = VIDEO_REMOVE_MUTE_WINDOW_MS;
}
let timer = null, removed = false, hasFlowed = false;
const remove = () => {
if (removed) return;
@ -1641,10 +1657,10 @@ function watchVideoTrackForRemoval(track, removeFn){
timer = setTimeout(() => {
timer = null;
if (!removed && track.muted){
logLine('', 'video track muted >'+(VIDEO_REMOVE_MUTE_WINDOW_MS/1000)+'s — removing tile');
logLine('', 'video track muted >'+(windowMs/1000)+'s — removing tile');
remove();
}
}, VIDEO_REMOVE_MUTE_WINDOW_MS);
}, windowMs);
};
track.addEventListener('ended', remove);
track.addEventListener('mute', onMute);
@ -2427,14 +2443,14 @@ async function sfuSubscribe(){
const s = ev.streams[0];
screenStreams.set(pubHex, s);
renderScreenTile(pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); });
watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
return;
}
if (kind === 'camera'){
const s = ev.streams[0];
cameraStreams.set(pubHex, s);
renderCameraTile(pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); });
watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_MS);
return;
}
if (kind === 'game'){
@ -2444,7 +2460,7 @@ async function sfuSubscribe(){
const s = ev.streams[0];
gameStreams.set(pubHex, s);
renderVideoTile('gameshare', pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); });
watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
return;
}
if (kind !== 'mic'){
@ -3829,8 +3845,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-03</span><br>
md5 <span class="stamp-md5">202400eb71552609f46039301779f8ed</span><br>
sha256 <span class="stamp-sha">684fb8eb60e8a4a30f7a9781f51a3dba088401b874262a84f735f332269f5758</span><br>
md5 <span class="stamp-md5">e7d4557587a42469e48e836866e4cc56</span><br>
sha256 <span class="stamp-sha">027a8b232ffdcb7333dc18a1bf8f14ae68b582e3c7e9d9824288821ab80886dc</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>