From 622db439d7930b2ef8be43405ddbb7553b072309 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 2 Jun 2026 10:15:05 -0400 Subject: [PATCH] zebra-spaces: speakers also subscribe to SFU so they see each other's screens + cameras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: 'speakers publish, listeners subscribe' meant speakers never got the SFU subscribe leg — which carries every screen + camera publish. So a host sharing a screen never saw the other speaker's screen, a late-joining speaker missed any screen already being shared, and toggling camera made each side see only their own preview. Fix: - onRoleEntered: everyone (speaker AND listener) calls sfuSubscribe. The subscribe PC carries all incoming kinds: mic + screen + camera. - onRoleChanged: keep the subscribe alive across role flips instead of tearing it down when becoming speaker. - ontrack mic-handler: if we're a speaker AND we already have a mesh peer for the publisher's pubkey, skip the SFU mic track so audio only comes through mesh (lower-latency path) instead of doubling. Screens + cameras always render regardless of role. Late-join screens already worked from the SFU side (serveSubscribe AddTracks every existing publisher into the initial offer); the missing piece was speakers actually completing the subscribe. --- web/zebra-spaces.html | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index b952f7e..84ef5c1 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1260,6 +1260,11 @@ async function sfuSubscribe(){ for (const [uuid, mm] of members){ try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ + /* if we're a speaker and already have a mesh peer with this + * member, mesh carries their audio with lower latency — skip + * the duplicate SFU mic. Screens + cameras still come through + * because they're handled above. */ + if (canSpeak(myRole) && peers.has(uuid)) return; attachSfuTrack(uuid, ev.streams[0]); return; } @@ -1796,14 +1801,17 @@ function applyState(state){ async function onRoleEntered(){ if (canSpeak(myRole)) await ensureMicAndUI(); else updateRoleUI(); + /* Everyone subscribes to the SFU. Listeners use it for mic+screen+camera. + * Speakers use it for screen+camera (and as a backup audio path); the + * SFU mic track is suppressed by the ontrack handler when we already + * have a mesh peer for that pubkey, so we don't get double audio. */ + sfuSubscribe().catch(e => logLine('err','sfu subscribe: '+e.message)); if (canSpeak(myRole)){ for (const [uuid, mm] of members){ if (uuid === myUUID) continue; if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid); } sfuPublish().catch(e => logLine('err','sfu publish: '+e.message)); - } else { - sfuSubscribe().catch(e => logLine('err','sfu subscribe: '+e.message)); } } async function onRoleChanged(prev, next){ @@ -1813,13 +1821,14 @@ async function onRoleChanged(prev, next){ if (uuid === myUUID) continue; if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid); } - await sfuUnsubscribe(); + /* keep sfuSub alive — screen + camera tracks still ride it. The + * ontrack handler suppresses SFU mic when mesh is also up. */ sfuPublish().catch(e => logLine('err','sfu publish: '+e.message)); } else if (canSpeak(prev) && !canSpeak(next)){ for (const u of [...peers.keys()]) tearPeer(u); dropMic(); muted = false; await sfuUnpublish(); - sfuSubscribe().catch(e => logLine('err','sfu subscribe: '+e.message)); + /* still subscribed — listener role needs the same incoming streams */ } updateRoleUI(); } @@ -2406,8 +2415,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');