diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 70df133..bbfa87b 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1631,7 +1631,12 @@ function reorderTiles(){ for (const el of els) c.appendChild(el); } function broadcastSpotlight(){ - /* fire-and-forget to the signal server; no-op if not connected yet */ + /* fire-and-forget to the signal server; no-op if not connected yet. + * Suppressed during role transitions — otherwise removeScreenTile + + * removeCameraTile triggered by sfuUnpublishScreen/Camera during a + * demotion fire pickNextSpotlight → broadcasts an empty spotlight, + * which every other peer logs as 'X looked away'. False signal. */ + if (typeof inRoleTransition !== 'undefined' && inRoleTransition) return; try { send({ type: 'spotlight', key: spotlightKey() }); } catch(_){} } function logSpotlightChange(uuid, key){ @@ -2797,12 +2802,13 @@ async function handleSignal(raw){ onRoleChanged(prev, m.role); } else { logLine('', mm.handle+' is now '+m.role); - /* mesh adjustments */ - if (canSpeak(myRole)){ - if (canSpeak(m.role) && !peers.has(m.uuid)) connectToPeer(m.uuid, myUUID < m.uuid); - if (!canSpeak(m.role) && peers.has(m.uuid)) tearPeer(m.uuid); - } else if (peers.has(m.uuid)){ - tearPeer(m.uuid); + /* mesh adjustments — fox: 'never drop people out of the mesh + * automatically'. Promote: connect if we can speak + they + * can speak + we don't already have them. Demote: do NOT + * tear existing mesh peer connections; let them ride as a + * back-channel until one side actually leaves the room. */ + if (canSpeak(myRole) && canSpeak(m.role) && !peers.has(m.uuid)){ + connectToPeer(m.uuid, myUUID < m.uuid); } } renderRoom(); @@ -2894,23 +2900,39 @@ async function onRoleEntered(){ sfuPublish().catch(e => logLine('err','sfu publish: '+e.message)); } } +let inRoleTransition = false; async function onRoleChanged(prev, next){ - if (!canSpeak(prev) && canSpeak(next)){ - await ensureMicAndUI(); - for (const [uuid, mm] of members){ - if (uuid === myUUID) continue; - if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid); + /* suppress spotlight broadcasts triggered by tile cleanup during the + * role transition — otherwise removeScreenTile / removeCameraTile + * fires pickNextSpotlight which broadcasts an empty spotlight key, + * which every other peer logs as 'X looked away'. */ + inRoleTransition = true; + try { + if (!canSpeak(prev) && canSpeak(next)){ + await ensureMicAndUI(); + for (const [uuid, mm] of members){ + if (uuid === myUUID) continue; + if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid); + } + /* 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)){ + /* demote to listener: drop everything we PUBLISH (mic + screen + + * camera + game) but DON'T tear mesh peers. fox: 'never drop + * people out of the mesh automatically'. The mesh connections + * stay up as a bonus low-latency audio path; they get GC'd + * naturally when the other end leaves or also demotes. */ + dropMic(); muted = false; + await sfuUnpublish(); + await sfuUnpublishScreen(); + await sfuUnpublishCamera(); + await sfuUnpublishGame(); } - /* 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(); - /* still subscribed — listener role needs the same incoming streams */ + updateRoleUI(); + } finally { + inRoleTransition = false; } - updateRoleUI(); } async function ensureMicAndUI(){ /* mic input + music-mode rows are always visible; here we just grant the @@ -3525,8 +3547,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');