From 16007677ef18232cc325fa213f534ebf1a106b5e Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Tue, 2 Jun 2026 10:55:38 -0400 Subject: [PATCH] =?UTF-8?q?zebra-report:=20track=20mute=20=E2=86=92=20debo?= =?UTF-8?q?unced=20tile=20removal=20=E2=80=94=20mirror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- zebra-report/zebra-spaces.html | 43 ++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html index 19e3bf3..c8735e0 100644 --- a/zebra-report/zebra-spaces.html +++ b/zebra-report/zebra-spaces.html @@ -993,6 +993,41 @@ function flushSfuStreams(){ } } +/* When a publisher unshares, the SFU stops its transceiver on every + * 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 >1.5s = the publisher is gone and + * we should remove the tile. If 'unmute' fires within the window + * (transient network blip) we cancel the removal. */ +function watchVideoTrackForRemoval(track, removeFn){ + if (!track) return; + let timer = null, removed = false; + const remove = () => { + if (removed) return; + removed = true; + if (timer) { clearTimeout(timer); timer = null; } + try { removeFn(); } catch(_){} + }; + const onMute = () => { + if (removed || timer) return; + timer = setTimeout(() => { + timer = null; + if (!removed && track.muted) remove(); + }, 1500); + }; + const onUnmute = () => { + if (timer){ clearTimeout(timer); timer = null; } + }; + 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 * cameras column (cameras above, screens-thumbs below). When a tile is * spotlit, a SECOND larger tile renders in the middle slot pointing at @@ -1452,13 +1487,13 @@ async function sfuSubscribe(){ if (kind === 'screen'){ screenStreams.set(pubHex, ev.streams[0]); renderScreenTile(pubHex, ev.streams[0]); - ev.track.addEventListener('ended', () => removeScreenTile(pubHex)); + watchVideoTrackForRemoval(ev.track, () => removeScreenTile(pubHex)); return; } if (kind === 'camera'){ cameraStreams.set(pubHex, ev.streams[0]); renderCameraTile(pubHex, ev.streams[0]); - ev.track.addEventListener('ended', () => removeCameraTile(pubHex)); + watchVideoTrackForRemoval(ev.track, () => removeCameraTile(pubHex)); return; } if (kind !== 'mic'){ @@ -2714,8 +2749,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');