From d5e9e4eb023b4c302b107944b2a010e43d7c450d Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Wed, 3 Jun 2026 11:01:03 -0400 Subject: [PATCH] zebra-spaces: wrap each remote video track in a fresh MediaStream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real cause of 'host can't see laptop's camera on rejoin' (and the phone- camera analogue): SFU's TrackLocalStaticRTP uses the same streamID (shortPub-kind) for every publish of a given (pubkey, kind). When a publisher supplants themselves the new track's MSID matches the old one. Per WebRTC spec the browser merges it into the SAME MediaStream object — ev.streams[0] is the same instance as before, containing BOTH the dead old track AND the new live one. Setting video.srcObject = ev.streams[0] doesn't switch the source; the video element keeps showing the old track's last frame, reports muted, and the 60s mute-window then reaps a tile that was never going to come back on its own. Fix: construct a fresh MediaStream from just ev.track. The video element binds to the new RTP cleanly and the receive-side state machine sees a real unmute as soon as packets arrive. --- web/zebra-spaces.html | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index eeff56f..b6aa8f4 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -2441,20 +2441,29 @@ async function sfuSubscribe(){ logLine('', 'sfu ontrack: kind=' + kind + ' pub=' + pubHex + ' track=' + ev.track.kind + ' mute=' + ev.track.muted + ' state=' + ev.track.readyState); } - /* Stream-identity guard on removeFn: when a publisher supplants - * themselves (typical: hard refresh — new SFU peer_id same pubkey + - * kind), the OLD track's mute → ended → removeFn would tear down the - * tile that the NEW track just installed. Only remove if the stream - * we registered against is still the one in the store for this pub. */ + /* MSID-supplant safety: the SFU re-uses the same streamID + * (`shortPub-kind`) when a publisher supplants themselves. WebRTC + * merges the new track into the EXISTING MediaStream — ev.streams[0] + * is literally the same instance as before, containing both the + * dead old track AND the new live one. Setting srcObject to that + * stream doesn't switch the playing track; the video element keeps + * showing the (now-ended) old track's last frame and reports muted. + * Construct a fresh MediaStream containing only the new track so + * the video element binds to the new RTP flow cleanly. + * + * Stream-identity guard on removeFn: when the OLD track's mute → + * ended → removeFn would tear down the tile that the NEW track + * just installed. Only remove if the stream we registered against + * is still the one in the store for this pub. */ if (kind === 'screen'){ - const s = ev.streams[0]; + const s = new MediaStream([ev.track]); screenStreams.set(pubHex, s); renderScreenTile(pubHex, s); 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]; + const s = new MediaStream([ev.track]); cameraStreams.set(pubHex, s); renderCameraTile(pubHex, s); watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_MS); @@ -2464,7 +2473,7 @@ async function sfuSubscribe(){ /* a publisher is sharing their gameplay (Region-Capture cropped * iframe). Route to its own TILE_KIND so it coexists with a * normal screen-share from the same person. */ - const s = ev.streams[0]; + const s = new MediaStream([ev.track]); gameStreams.set(pubHex, s); renderVideoTile('gameshare', pubHex, s); watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS); @@ -3861,8 +3870,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');