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');