diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 19e3bf3..c8735e0 100644
--- a/web/zebra-spaces.html
+++ b/web/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');