diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 937069e..88fc1f1 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/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');