diff --git a/test/video-track-removal.test.js b/test/video-track-removal.test.js index adc6855..fbd90b0 100644 --- a/test/video-track-removal.test.js +++ b/test/video-track-removal.test.js @@ -40,13 +40,17 @@ function extract(re){ const watchSrc = extract(/function watchVideoTrackForRemoval\(/); -/* The shipped constant. Read it from source so the test always validates - * what's actually live — if the window changes the assertion below - * catches an accidental shorten. */ +/* Shipped constants. Read from source so the tests always validate + * what's actually live — if either window changes the assertions below + * catch an accidental shorten. */ const winM = src.match(/const\s+VIDEO_REMOVE_MUTE_WINDOW_MS\s*=\s*(\d+)\s*;/); if (!winM) throw new Error('VIDEO_REMOVE_MUTE_WINDOW_MS not found in source'); const SHIPPED_WINDOW_MS = parseInt(winM[1], 10); +const winScreenM = src.match(/const\s+VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS\s*=\s*(\d+)\s*;/); +if (!winScreenM) throw new Error('VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS not found in source'); +const SHIPPED_WINDOW_SCREEN_MS = parseInt(winScreenM[1], 10); + /* ============================ fake clock ============================ */ let fakeNow = 0; let pending = new Map(); // id -> { fireAt, fn } @@ -82,11 +86,13 @@ function resetLogs(){ logs = []; } /* harness — Function-constructor scope so the function's free * references resolve to our fakes, not the host's real globals. */ const harness = new Function( - 'setTimeout', 'clearTimeout', 'logLine', 'VIDEO_REMOVE_MUTE_WINDOW_MS', + 'setTimeout', 'clearTimeout', 'logLine', + 'VIDEO_REMOVE_MUTE_WINDOW_MS', 'VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS', watchSrc + '\nreturn watchVideoTrackForRemoval;' ); const watchVideoTrackForRemoval = harness( - fakeSetTimeout, fakeClearTimeout, logLine, SHIPPED_WINDOW_MS, + fakeSetTimeout, fakeClearTimeout, logLine, + SHIPPED_WINDOW_MS, SHIPPED_WINDOW_SCREEN_MS, ); /* ============================ fake track ============================ */ @@ -127,6 +133,41 @@ test('shipped mute-window is at least 10s — anything less is too aggressive', truthy(SHIPPED_WINDOW_MS >= 10000, 'window ' + SHIPPED_WINDOW_MS + ' < 10000'); }); +test('shipped screen-window is at least 60s — static screens stay quiet', () => { + truthy(SHIPPED_WINDOW_SCREEN_MS >= 60000, + 'screen window ' + SHIPPED_WINDOW_SCREEN_MS + ' < 60000'); + truthy(SHIPPED_WINDOW_SCREEN_MS > SHIPPED_WINDOW_MS, + 'screen window must be longer than camera window'); +}); + +test('callers can override the window per-kind (screen window honored)', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++, SHIPPED_WINDOW_SCREEN_MS); + t.setMuted(false); t.fire('unmute'); + t.setMuted(true); t.fire('mute'); + /* the camera window has passed — but we passed the screen window, so + * the tile must survive */ + advance(SHIPPED_WINDOW_MS + 1000); + eq(removed, 0, 'camera-window elapsed but screen-window in effect — must not remove'); + /* still inside the screen window */ + advance(SHIPPED_WINDOW_SCREEN_MS - SHIPPED_WINDOW_MS - 2000); + eq(removed, 0, 'still inside screen window'); + /* now we cross the screen window */ + advance(2000); + eq(removed, 1, 'screen window elapsed — removed'); +}); + +test('invalid window argument falls back to camera default', () => { + const t = makeTrack(); + let removed = 0; + watchVideoTrackForRemoval(t, () => removed++, 'not a number'); + t.setMuted(false); t.fire('unmute'); + t.setMuted(true); t.fire('mute'); + advance(SHIPPED_WINDOW_MS + 100); + eq(removed, 1, 'bad windowMs should fall back to default, not disable removal'); +}); + test('initial mute (never flowed) does NOT schedule a removal', () => { const t = makeTrack(); let removed = 0; diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 937069e..88fc1f1 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -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');