From 958ce6331b9c4551f8145aa681e1eeec2fde72ea Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 31 May 2026 14:08:54 -0400 Subject: [PATCH] zebra-report: zebra-spaces queues SFU ontrack until the matching member joins --- zebra-report/zebra-spaces.html | 53 ++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html index dd576d3..7190d51 100644 --- a/zebra-report/zebra-spaces.html +++ b/zebra-report/zebra-spaces.html @@ -447,6 +447,32 @@ const SFU_BASE = (new URLSearchParams(location.search).get('sfu') let sfuPubPC = null, sfuPubPeerID = null; let sfuSubPC = null, sfuSubPeerID = null, sfuSubEvents = null; +/* SFU forwarded tracks whose publisher pubkey we couldn't match to a room + * member yet — ontrack fires from RTP; the signal's peer-joined may arrive + * a beat later. Without queueing, the host's rejoined audio would be lost. */ +const sfuPendingTracks = new Map(); // pubHex -> MediaStream + +function attachSfuTrack(uuid, stream){ + let a = remoteAudio.get(uuid); + if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); } + a.srcObject = stream; + stopMeter(uuid); startMeter(uuid, stream); + logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || shortHex(uuid))); +} +function flushSfuPendingTracks(){ + if (!sfuPendingTracks.size) return; + for (const [pubHex, stream] of [...sfuPendingTracks]){ + for (const [uuid, mm] of members){ + try { + if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ + attachSfuTrack(uuid, stream); + sfuPendingTracks.delete(pubHex); + break; + } + } catch(_){} + } + } +} async function sfuPublish(){ if (sfuPubPC){ logLine('','sfu publish: already publishing'); return; } @@ -487,17 +513,15 @@ async function sfuSubscribe(){ pc.ontrack = (ev) => { const pubHex = ev.streams[0] ? ev.streams[0].id : ''; if (!pubHex) return; - /* map track.streamID (= publisher's pubkey hex) -> room uuid */ + /* try to match streamID (= publisher's pubkey hex) -> room uuid; if the + * member's not in our roster yet, queue the stream — flushSfuPendingTracks + * will pick it up on the next peer-joined / host-promoted / state event */ let matchUuid = null; - for (const [uuid, m] of members){ - try { if (m.pubkey && hex(unb64(m.pubkey)) === pubHex){ matchUuid = uuid; break; } } catch(_){} + for (const [uuid, mm] of members){ + try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ matchUuid = uuid; break; } } catch(_){} } - if (!matchUuid) return; - let a = remoteAudio.get(matchUuid); - if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(matchUuid, a); } - a.srcObject = ev.streams[0]; - stopMeter(matchUuid); startMeter(matchUuid, ev.streams[0]); - logLine('', 'sfu: receiving '+((members.get(matchUuid)||{}).handle || shortHex(matchUuid))); + if (matchUuid){ attachSfuTrack(matchUuid, ev.streams[0]); } + else { sfuPendingTracks.set(pubHex, ev.streams[0]); } }; /* server-initiated offer: POST /subscribe (empty body) — SFU answers with * an SDP offer containing one m-line per current publisher. We answer it @@ -541,6 +565,7 @@ async function sfuSubscribe(){ async function sfuUnsubscribe(){ if (sfuSubEvents){ try { sfuSubEvents.close(); } catch(_){} sfuSubEvents = null; } if (sfuSubPC){ try { sfuSubPC.close(); } catch(_){} sfuSubPC = null; sfuSubPeerID = null; } + sfuPendingTracks.clear(); } /* ================================================================== @@ -748,7 +773,7 @@ async function handleSignal(raw){ renderRoom(); break; case 'state': - roomEpoch = m.epoch; applyState(m.state); renderRoom(); break; + roomEpoch = m.epoch; applyState(m.state); flushSfuPendingTracks(); renderRoom(); break; case 'peer-joined': members.set(m.uuid, { uuid:m.uuid, pubkey:m.pubkey, handle:m.handle, role:m.role, joined_at: Date.now()/1000 }); logLine('', m.handle+' joined as '+m.role); @@ -758,6 +783,9 @@ async function handleSignal(raw){ hostUUID = m.uuid; setStatus('connected as '+myRole, 'ok'); } + /* a new member may resolve a queued SFU track (e.g. host's rejoin + * race where ontrack fired before peer-joined) */ + flushSfuPendingTracks(); /* establish mesh PC if both us and them are speakers (or mods). * present-member-offers: existing speaker offers when a new speaker * arrives. deterministic by uuid string compare. */ @@ -870,6 +898,7 @@ async function handleSignal(raw){ if (m.new_host_uuid === myUUID){ myRole = 'host'; setStatus('connected as host','ok'); onRoleChanged('cohost','host'); } else { setStatus('connected as '+myRole, 'ok'); } /* clears the space-closing warning */ logLine('', (mm.handle||shortHex(m.new_host_uuid))+' is now host'); } + flushSfuPendingTracks(); renderRoom(); } break; @@ -1222,8 +1251,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');