diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html index a3feb84..baa49b4 100644 --- a/zebra-report/zebra-spaces.html +++ b/zebra-report/zebra-spaces.html @@ -2415,103 +2415,112 @@ async function sfuUnpublish(){ } } +/* handleRemoteSfuTrack — receive-side mesh state machine. Called once + * per ontrack on the SFU sub PC. Extracted into a named function so + * test/multi-peer-mesh.test.js can drive it directly with synthetic + * events and verify the mesh invariants without needing a real + * RTCPeerConnection or SFU. The signature mirrors a real RTCTrackEvent: + * .streams[0] (MediaStream), .track (MediaStreamTrack), .receiver + * (RTCRtpReceiver — optional, only used for playoutDelayHint). */ +function handleRemoteSfuTrack(ev){ + const sid = ev.streams[0] ? ev.streams[0].id : ''; + if (!sid) return; + /* streamID format (RFC 7941 compliant — Firefox enforces 1*64 token- + * chars and rejects ':'): SHORT16HEX (mic) | SHORT16HEX-screen | + * SHORT16HEX-camera. Resolve the 16-char prefix back to a member's + * full pubkey via lookup so the rest of the code keeps using full + * pubhex as identity. */ + const dash = sid.indexOf('-'); + let pubHex16, kind; + if (dash > 0){ + pubHex16 = sid.slice(0, dash); + kind = sid.slice(dash + 1); + } else { + pubHex16 = sid; + kind = 'mic'; + } + /* skip echo of our own publish — match by prefix */ + if (myKeys && myKeys.pubHex.startsWith(pubHex16)) return; + /* resolve short prefix → full pubhex via member roster */ + let pubHex = pubHex16; + for (const [, mm] of members){ + try { + if (mm.pubkey){ + const fh = hex(unb64(mm.pubkey)); + if (fh.startsWith(pubHex16)){ pubHex = fh; break; } + } + } catch(_){} + } + if (kind === 'screen' || kind === 'camera' || kind === 'game'){ + logLine('', 'sfu ontrack: kind=' + kind + ' pub=' + pubHex + + ' track=' + ev.track.kind + ' mute=' + ev.track.muted + ' state=' + ev.track.readyState); + } + /* 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 = 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 = 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); + return; + } + if (kind === 'game'){ + /* 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 = 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); + return; + } + if (kind !== 'mic'){ + logLine('', 'sfu: unknown kind '+kind+' from '+pubHex); + return; + } + /* mic audio — 400ms jitter-buffer target absorbs Wi-Fi peak jitter */ + try { if (ev.receiver) ev.receiver.playoutDelayHint = 0.4; } catch(_){} + /* cache by full pubkey (already resolved above) so it survives the + * member's session uuid changing across leave/rejoin */ + sfuStreamsByPubHex.set(pubHex, ev.streams[0]); + for (const [uuid, mm] of members){ + try { + if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ + /* speakers get their peers' audio via mesh (lower latency) — + * skip the duplicate SFU mic. screens + cameras still came + * through above. */ + if (canSpeak(myRole) && peers.has(uuid)) return; + attachSfuTrack(uuid, ev.streams[0]); + return; + } + } catch(_){} + } + /* no matching member yet — flushSfuStreams will attach on peer-joined */ +} + async function sfuSubscribe(){ if (sfuSubPC || !roomID) return; const pc = new RTCPeerConnection(rtcConfig); - pc.ontrack = (ev) => { - const sid = ev.streams[0] ? ev.streams[0].id : ''; - if (!sid) return; - /* streamID format (RFC 7941 compliant — Firefox enforces 1*64 token- - * chars and rejects ':'): SHORT16HEX (mic) | SHORT16HEX-screen | - * SHORT16HEX-camera. Resolve the 16-char prefix back to a member's - * full pubkey via lookup so the rest of the code keeps using full - * pubhex as identity. */ - const dash = sid.indexOf('-'); - let pubHex16, kind; - if (dash > 0){ - pubHex16 = sid.slice(0, dash); - kind = sid.slice(dash + 1); - } else { - pubHex16 = sid; - kind = 'mic'; - } - /* skip echo of our own publish — match by prefix */ - if (myKeys && myKeys.pubHex.startsWith(pubHex16)) return; - /* resolve short prefix → full pubhex via member roster */ - let pubHex = pubHex16; - for (const [, mm] of members){ - try { - if (mm.pubkey){ - const fh = hex(unb64(mm.pubkey)); - if (fh.startsWith(pubHex16)){ pubHex = fh; break; } - } - } catch(_){} - } - if (kind === 'screen' || kind === 'camera' || kind === 'game'){ - logLine('', 'sfu ontrack: kind=' + kind + ' pub=' + pubHex + - ' track=' + ev.track.kind + ' mute=' + ev.track.muted + ' state=' + ev.track.readyState); - } - /* 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 = 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 = 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); - return; - } - if (kind === 'game'){ - /* 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 = 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); - return; - } - if (kind !== 'mic'){ - logLine('', 'sfu: unknown kind '+kind+' from '+pubHex); - return; - } - /* mic audio — 400ms jitter-buffer target absorbs Wi-Fi peak jitter */ - try { ev.receiver.playoutDelayHint = 0.4; } catch(_){} - /* cache by full pubkey (already resolved above) so it survives the - * member's session uuid changing across leave/rejoin */ - sfuStreamsByPubHex.set(pubHex, ev.streams[0]); - for (const [uuid, mm] of members){ - try { - if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ - /* speakers get their peers' audio via mesh (lower latency) — - * skip the duplicate SFU mic. screens + cameras still came - * through above. */ - if (canSpeak(myRole) && peers.has(uuid)) return; - attachSfuTrack(uuid, ev.streams[0]); - return; - } - } catch(_){} - } - /* no matching member yet — flushSfuStreams will attach on peer-joined */ - }; + pc.ontrack = handleRemoteSfuTrack; /* server-initiated offer: POST /subscribe (empty body) — SFU answers with * an SDP offer containing one m-line per current publisher. We answer it * and POST the answer back, which completes the initial handshake. */ @@ -3889,8 +3898,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');