From 94b981da29f30e1398ab159a9a06253044f05b13 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Wed, 3 Jun 2026 19:51:30 -0400 Subject: [PATCH] zebra-report: mute button + peer-force-muted handler Mirror of zebra-report a3c49f1 + proxy.unturf.com 90a5e96. --- zebra-report/zebra-spaces.html | 52 ++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html index 4953e72..f9cad31 100644 --- a/zebra-report/zebra-spaces.html +++ b/zebra-report/zebra-spaces.html @@ -3612,6 +3612,36 @@ async function handleSignal(raw){ } } break; + case 'peer-force-muted': + { + const mm = members.get(m.uuid); + const by = members.get(m.by); + const victPub = pubHexFromMsg(m, 'pubkey') + || (mm && mm.pubkey ? pubHexFromMsg({pubkey:mm.pubkey},'pubkey') : ''); + const byPub = pubHexFromMsg(m, 'by_pubkey'); + logLine('', idTag(m.uuid, victPub) + ' was muted by ' + (by||byPub?idTag(m.by, byPub):'a mod')); + if (m.uuid === myUUID){ + /* I was the target — server already dropped my mic publisher + * at the SFU; mirror that locally so my own UI shows muted + * and the mic track stops sending into mesh PCs too. The + * user can hit unmute to resume on their own. */ + showNotice('You were muted'+(by?' by '+by.handle:'')+'. Click unmute to talk again.', 'warn'); + if (!muted){ + muted = true; + try { sessionStorage.setItem(MUTE_STATE_KEY, '1'); } catch(_){} + if (micStream){ + for (const t of micStream.getAudioTracks()) t.enabled = false; + } + try { sendMicState(); } catch(_){} + } + renderRoom(); + } else { + /* Mark the target visually muted right away — their next + * mic-state broadcast will confirm it on the wire. */ + if (mm){ mm.muted = true; renderRoom(); } + } + } + break; case 'peer-booted': { const mm = members.get(m.uuid); @@ -3969,6 +3999,15 @@ async function modDemote(uuid, to){ const sig = await signBytes(sigAction(roomID, roomEpoch, 'demote', uuid, to)); send({ type:'demote', target: uuid, to, epoch: roomEpoch, sig }); } +/* mute: drops the speaker's mic publisher at the SFU so their voice + * stops immediately. No role change — they keep their seat, keep + * sharing screen / camera / game, keep hearing the room — and they + * may unmute themselves on their own when they want to talk again. */ +async function modMute(uuid){ + if (!confirm('mute this speaker? (they can unmute themselves)')) return; + const sig = await signBytes(sigAction(roomID, roomEpoch, 'mute', uuid)); + send({ type:'mute', target: uuid, epoch: roomEpoch, sig }); +} /* kick: drop the peer from this session — evicts their SFU PCs (audio * actually stops) but does NOT block their pubkey. They can rejoin * freely. Use when someone's audio is leaking from a closed-tab / @@ -4210,6 +4249,15 @@ function renderRoom(){ b.textContent = '→ speaker'; b.onclick = () => modDemote(m.uuid, 'speaker').catch(e=>logLine('err','demote: '+e.message)); actsPrimary.appendChild(b); } + /* mute: drops their mic publisher at the SFU. Speakers always; + * cohosts only when I'm host (cohost can't mute another cohost). */ + if (canSpeak(m.role) && m.role !== 'host' && + !(m.role === 'cohost' && myRole !== 'host')){ + const mb = document.createElement('button'); mb.className='small'; + mb.textContent = 'mute'; + mb.onclick = () => modMute(m.uuid).catch(e => logLine('err','mute: '+e.message)); + actsPrimary.appendChild(mb); + } /* kick + ban — destructive removal actions, kept on their own * row so the muscle-memory misclick where 'ban' sat next to * '→ speaker' can't fire by accident. */ @@ -4709,8 +4757,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');