From 9869e7861d3ee5dcd14c53d00d2db7a93f4e0013 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 2 Jun 2026 11:39:05 -0400 Subject: [PATCH] =?UTF-8?q?zebra-spaces:=20only=20prune=20mute=20AFTER=20d?= =?UTF-8?q?ata=20flowed=20=E2=80=94=20fresh=20tiles=20stop=20disappearing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: listener saw camera tiles appear black, then vanish ~1.5s later. Cause: remote MediaStreamTracks ALWAYS start in muted state until the first RTP packet arrives. My watchVideoTrackForRemoval had a 'defensive' branch that scheduled a prune if the track was already muted at attach time — so the moment we attached, the 1.5s timer started, and if the publisher's encoder hadn't pushed a keyframe yet (common for cameras), the tile was pruned before any frame rendered. Fix: track a hasFlowed flag. Set true on the FIRST 'unmute' (RTP actually arrived). Only schedule a prune on 'mute' events that fire AFTER hasFlowed — those are the real 'publisher stopped sending' case. Initial muted state is now ignored. Also bumped the debounce from 1.5s to 3s for extra safety on slow networks. 'ended' still removes immediately (terminal state, no debounce). 83 FSM tests green. --- web/zebra-spaces.html | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 688f280..110a54d 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1459,30 +1459,33 @@ function flushSfuStreams(){ * (transient network blip) we cancel the removal. */ function watchVideoTrackForRemoval(track, removeFn){ if (!track) return; - let timer = null, removed = false; + let timer = null, removed = false, hasFlowed = false; const remove = () => { if (removed) return; removed = true; if (timer) { clearTimeout(timer); timer = null; } try { removeFn(); } catch(_){} }; + const onUnmute = () => { + /* RTP arrived; future mute events are meaningful (a flow that + * existed then stopped — publisher unshared or net dropped) */ + hasFlowed = true; + if (timer){ clearTimeout(timer); timer = null; } + }; const onMute = () => { if (removed || timer) return; + /* fresh remote tracks ALWAYS start muted until the first RTP packet + * arrives. If we've never seen data flow on this track, the mute is + * its initial state, not a publisher unshare — don't prune. */ + if (!hasFlowed) return; timer = setTimeout(() => { timer = null; if (!removed && track.muted) remove(); - }, 1500); - }; - const onUnmute = () => { - if (timer){ clearTimeout(timer); timer = null; } + }, 3000); }; track.addEventListener('ended', remove); track.addEventListener('mute', onMute); track.addEventListener('unmute', onUnmute); - /* defensive: if the track is already muted at attach time (the - * 'mute' event may have fired before we got here), schedule the - * pruning right away */ - if (track.muted) onMute(); } /* Spotlight model — every tile keeps its thumbnail permanently in the @@ -3360,8 +3363,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');