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