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