zebra-spaces: SFU bridge + role-change notice banner
Listeners now hear all speakers via a Pion-based audio-only SFU on proxy.uncloseai.com. Speakers publish to it; listeners subscribe and get one PC carrying every speaker's track. Mesh remains the low-latency speaker<->speaker path; SFU is the broadcast fan-out. Wiring: - sfuPublish/Unpublish for speaker role transitions - sfuSubscribe/Unsubscribe for listener role transitions - onRoleEntered + onRoleChanged + leave hooks - ontrack on the subscribe PC maps streams[0].id (= publisher pubkey hex, set as the SFU TrackLocal StreamID) -> room member uuid; audio element + meter attach to the matched member row - SSE renegotiation: SFU pushes offer when speakers come/go; browser answers via POST /answer - applyMicMode now hot-swaps the SFU publish sender's track too, so voice/music mode toggles apply over the SFU just like the mesh Notice banner — fox flagged that booted/demoted users had no visible signal. Added a #sec-notice section with .notice-banner (warn for boot/demote-to-listener, info for promote). Boot also sets wantConnected=false to prevent the WS auto-reconnect loop from rejoining into a boot loop.
This commit is contained in:
parent
7e54e1b7ef
commit
9ed1cefcd5
1 changed files with 173 additions and 12 deletions
|
|
@ -122,6 +122,14 @@
|
|||
border: 2px solid #000; padding: 0.8rem; margin-bottom: 1rem; background: #ffd;
|
||||
display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
|
||||
}
|
||||
.notice-banner {
|
||||
padding: 0.7rem 0.8rem; margin-bottom: 1rem;
|
||||
display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.notice-banner.warn { border: 2px solid #b00; background: #fee; color: #b00; }
|
||||
.notice-banner.info { border: 2px solid #060; background: #efe; color: #060; }
|
||||
.notice-banner button { margin-left: auto; }
|
||||
.vault-panel { border: 1px dashed #000; padding: 0.7rem; margin-top: 0.5rem; }
|
||||
.hidden { display: none !important; }
|
||||
@media (max-width: 500px) {
|
||||
|
|
@ -213,6 +221,13 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section id="sec-notice" class="hidden">
|
||||
<div id="notice-banner" class="notice-banner warn">
|
||||
<span id="notice-text"></span>
|
||||
<button id="btn-notice-close" class="small">dismiss</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="sec-room" class="hidden">
|
||||
<h2>room</h2>
|
||||
<div id="members"></div>
|
||||
|
|
@ -421,6 +436,108 @@ async function aesDecrypt(key, bytes){
|
|||
return new TextDecoder().decode(await crypto.subtle.decrypt({name:'AES-GCM',iv}, key, ct));
|
||||
}
|
||||
|
||||
/* ==================================================================
|
||||
* SFU bridge — listeners subscribe to receive every speaker's audio;
|
||||
* speakers publish their mic. Mesh handles speaker↔speaker low-latency;
|
||||
* SFU handles broadcast fan-out to listeners. Speakers never subscribe
|
||||
* (they'd hear their mesh peers a second time, delayed).
|
||||
* ================================================================== */
|
||||
const SFU_BASE = (new URLSearchParams(location.search).get('sfu')
|
||||
|| 'https://cors-proxy.uncloseai.com/zebra-spaces-sfu').replace(/\/$/, '');
|
||||
|
||||
let sfuPubPC = null, sfuPubPeerID = null;
|
||||
let sfuSubPC = null, sfuSubPeerID = null, sfuSubEvents = null;
|
||||
|
||||
async function sfuPublish(){
|
||||
if (sfuPubPC || !micStream || !myKeys || !roomID) return;
|
||||
const pc = new RTCPeerConnection(rtcConfig);
|
||||
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
|
||||
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
await waitForIceGathering(pc);
|
||||
const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sdp: pc.localDescription.sdp })
|
||||
});
|
||||
if (!res.ok){ pc.close(); throw new Error('sfu publish http '+res.status); }
|
||||
const ans = await res.json();
|
||||
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
||||
sfuPubPC = pc; sfuPubPeerID = ans.peer_id;
|
||||
logLine('', 'sfu: publishing as '+shortHex(sfuPubPeerID));
|
||||
}
|
||||
|
||||
async function sfuUnpublish(){
|
||||
if (!sfuPubPC) return;
|
||||
const pid = sfuPubPeerID;
|
||||
try { sfuPubPC.close(); } catch(_){}
|
||||
sfuPubPC = null; sfuPubPeerID = null;
|
||||
if (pid && roomID){
|
||||
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
|
||||
}
|
||||
}
|
||||
|
||||
async function sfuSubscribe(){
|
||||
if (sfuSubPC || !roomID) return;
|
||||
const pc = new RTCPeerConnection(rtcConfig);
|
||||
pc.ontrack = (ev) => {
|
||||
const pubHex = ev.streams[0] ? ev.streams[0].id : '';
|
||||
if (!pubHex) return;
|
||||
/* map track.streamID (= publisher's pubkey hex) -> room uuid */
|
||||
let matchUuid = null;
|
||||
for (const [uuid, m] of members){
|
||||
try { if (m.pubkey && hex(unb64(m.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)));
|
||||
};
|
||||
/* server-initiated offer: POST /subscribe (empty body) — SFU answers with
|
||||
* an SDP offer containing one m-line per current publisher. We answer it
|
||||
* and POST the answer back, which completes the initial handshake. */
|
||||
const offerRes = await fetch(SFU_BASE + '/subscribe?room=' + encodeURIComponent(roomID), {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'
|
||||
});
|
||||
if (!offerRes.ok){ pc.close(); throw new Error('sfu subscribe http '+offerRes.status); }
|
||||
const offer = await offerRes.json();
|
||||
await pc.setRemoteDescription({ type:'offer', sdp: offer.sdp });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGathering(pc);
|
||||
const ackRes = await fetch(SFU_BASE + '/subscribe-answer?room=' + encodeURIComponent(roomID) + '&peer=' + offer.peer_id, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sdp: pc.localDescription.sdp })
|
||||
});
|
||||
if (!ackRes.ok){ pc.close(); throw new Error('sfu subscribe-answer http '+ackRes.status); }
|
||||
sfuSubPC = pc; sfuSubPeerID = offer.peer_id;
|
||||
/* SSE: server pushes renegotiation offers when publisher set changes.
|
||||
* We answer each via POST /answer. ping events are keepalive only. */
|
||||
sfuSubEvents = new EventSource(SFU_BASE + '/events?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID);
|
||||
sfuSubEvents.onmessage = async (ev) => {
|
||||
let m; try { m = JSON.parse(ev.data); } catch(_){ return; }
|
||||
if (m.type !== 'offer' || !sfuSubPC) return;
|
||||
try {
|
||||
await sfuSubPC.setRemoteDescription({ type:'offer', sdp: m.sdp });
|
||||
const ans = await sfuSubPC.createAnswer();
|
||||
await sfuSubPC.setLocalDescription(ans);
|
||||
await waitForIceGathering(sfuSubPC);
|
||||
await fetch(SFU_BASE + '/answer?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sdp: sfuSubPC.localDescription.sdp })
|
||||
});
|
||||
} catch(e){ logLine('err','sfu renegotiate: '+e.message); }
|
||||
};
|
||||
sfuSubEvents.onerror = () => { /* EventSource auto-reconnects */ };
|
||||
logLine('', 'sfu: subscribed as '+shortHex(sfuSubPeerID));
|
||||
}
|
||||
|
||||
async function sfuUnsubscribe(){
|
||||
if (sfuSubEvents){ try { sfuSubEvents.close(); } catch(_){} sfuSubEvents = null; }
|
||||
if (sfuSubPC){ try { sfuSubPC.close(); } catch(_){} sfuSubPC = null; sfuSubPeerID = null; }
|
||||
}
|
||||
|
||||
/* ==================================================================
|
||||
* ephemeral TURN credentials (reused from zebra-audio model)
|
||||
* ================================================================== */
|
||||
|
|
@ -489,14 +606,17 @@ async function setSenderBitrate(sender){
|
|||
} catch(_){}
|
||||
}
|
||||
async function applyMicMode(){
|
||||
/* re-acquire mic with new constraints, hot-swap onto every live sender */
|
||||
/* re-acquire mic with new constraints, hot-swap onto every live sender
|
||||
* (mesh peers + the SFU publish PC) */
|
||||
const ns = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
|
||||
const nt = ns.getAudioTracks()[0];
|
||||
tagTrack(nt); nt.enabled = !muted;
|
||||
for (const [uuid, pc] of peers){
|
||||
async function swap(pc){
|
||||
const sender = pc.getSenders().find(s=>s.track && s.track.kind==='audio') || pc.getSenders()[0];
|
||||
if (sender){ try { await sender.replaceTrack(nt); } catch(_){} setSenderBitrate(sender); }
|
||||
}
|
||||
for (const [_, pc] of peers) await swap(pc);
|
||||
if (sfuPubPC) await swap(sfuPubPC);
|
||||
if (micStream) micStream.getTracks().forEach(t=>t.stop());
|
||||
micStream = ns;
|
||||
/* old analyser is now dead — rewire local meter against the fresh stream */
|
||||
|
|
@ -688,6 +808,14 @@ async function handleSignal(raw){
|
|||
myRole = m.role;
|
||||
logLine('', 'you are now '+m.role);
|
||||
setStatus('connected as '+myRole, 'ok');
|
||||
const byMod = members.get(m.by);
|
||||
const byTxt = byMod ? ' by '+byMod.handle : '';
|
||||
/* visible self-notification per role transition */
|
||||
if (m.role === 'listener' && prev !== 'listener') showNotice('You were moved to listener'+byTxt+'. Your mic is off.', 'warn');
|
||||
else if (m.role === 'speaker' && prev === 'listener') showNotice('You are now a speaker.', 'info');
|
||||
else if (m.role === 'speaker' && prev === 'cohost') showNotice('You were stepped down to speaker'+byTxt+'.', 'warn');
|
||||
else if (m.role === 'cohost') showNotice('You were promoted to co-host'+byTxt+'.', 'info');
|
||||
else if (m.role === 'host') showNotice('You are now the host.', 'info');
|
||||
onRoleChanged(prev, m.role);
|
||||
} else {
|
||||
logLine('', mm.handle+' is now '+m.role);
|
||||
|
|
@ -706,12 +834,16 @@ async function handleSignal(raw){
|
|||
case 'peer-booted':
|
||||
{
|
||||
const mm = members.get(m.uuid);
|
||||
if (mm) logLine('', mm.handle+' was removed by '+(members.get(m.by)?members.get(m.by).handle:'a mod'));
|
||||
members.delete(m.uuid); tearPeer(m.uuid); handraise.delete(m.uuid);
|
||||
const by = members.get(m.by);
|
||||
if (mm) logLine('', mm.handle+' was removed by '+(by?by.handle:'a mod'));
|
||||
if (m.uuid === myUUID){
|
||||
/* we got booted — connection will be closed by server */
|
||||
/* server will close our socket; surface a clear notice and prevent
|
||||
* the WS reconnect loop from auto-rejoining into a boot loop */
|
||||
wantConnected = false;
|
||||
showNotice('You were removed from this space'+(by?' by '+by.handle:'')+'.', 'warn');
|
||||
logLine('err','you were removed from this space');
|
||||
}
|
||||
members.delete(m.uuid); tearPeer(m.uuid); handraise.delete(m.uuid);
|
||||
renderRoom();
|
||||
}
|
||||
break;
|
||||
|
|
@ -748,16 +880,22 @@ function applyState(state){
|
|||
}
|
||||
|
||||
/* called once on welcome (whatever role we entered as) and on every
|
||||
* role change. host/cohost/speaker need a mic; listener drops it. */
|
||||
* role change. host/cohost/speaker need a mic; listener drops it.
|
||||
*
|
||||
* SFU bridge: speakers publish to the SFU (so listeners hear them);
|
||||
* listeners subscribe to the SFU (so they hear the speakers). Speakers
|
||||
* never subscribe — mesh gives them lower-latency audio already. */
|
||||
async function onRoleEntered(){
|
||||
if (canSpeak(myRole)) await ensureMicAndUI();
|
||||
else updateRoleUI();
|
||||
/* fan-out PCs to all current speakers we should mesh with */
|
||||
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){
|
||||
|
|
@ -767,10 +905,13 @@ async function onRoleChanged(prev, next){
|
|||
if (uuid === myUUID) continue;
|
||||
if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid);
|
||||
}
|
||||
await sfuUnsubscribe();
|
||||
sfuPublish().catch(e => logLine('err','sfu publish: '+e.message));
|
||||
} else if (canSpeak(prev) && !canSpeak(next)){
|
||||
/* demoted to listener — tear all PCs, drop the mic, switch UI */
|
||||
for (const u of [...peers.keys()]) tearPeer(u);
|
||||
dropMic(); muted = false;
|
||||
await sfuUnpublish();
|
||||
sfuSubscribe().catch(e => logLine('err','sfu subscribe: '+e.message));
|
||||
}
|
||||
updateRoleUI();
|
||||
}
|
||||
|
|
@ -813,7 +954,7 @@ async function connectToPeer(uuid, weOffer){
|
|||
let a = remoteAudio.get(uuid);
|
||||
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
|
||||
a.srcObject = ev.streams[0] || new MediaStream([ev.track]);
|
||||
startMeter(uuid, a.srcObject);
|
||||
stopMeter(uuid); startMeter(uuid, a.srcObject);
|
||||
};
|
||||
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
|
||||
pc.onconnectionstatechange = () => {
|
||||
|
|
@ -967,6 +1108,24 @@ function renderRoom(){
|
|||
* ================================================================== */
|
||||
$('btn-raise').addEventListener('click', () => { send({ type:'raise-hand' }); });
|
||||
$('btn-lower').addEventListener('click', () => { send({ type:'lower-hand' }); });
|
||||
/* notice banner — visible callouts for events that affect you directly
|
||||
* (boot, role change). Auto-clears after 8s for info; stays for warn. */
|
||||
let noticeTimer = null;
|
||||
function showNotice(text, kind){
|
||||
if (noticeTimer){ clearTimeout(noticeTimer); noticeTimer = null; }
|
||||
$('notice-text').textContent = text;
|
||||
$('notice-banner').className = 'notice-banner ' + (kind || 'warn');
|
||||
$('sec-notice').classList.remove('hidden');
|
||||
if (kind === 'info'){
|
||||
noticeTimer = setTimeout(()=>{ $('sec-notice').classList.add('hidden'); noticeTimer = null; }, 8000);
|
||||
}
|
||||
}
|
||||
function hideNotice(){
|
||||
if (noticeTimer){ clearTimeout(noticeTimer); noticeTimer = null; }
|
||||
$('sec-notice').classList.add('hidden');
|
||||
}
|
||||
$('btn-notice-close').addEventListener('click', hideNotice);
|
||||
|
||||
$('btn-accept-mic').addEventListener('click', () => {
|
||||
if (!outstandingInvite) return;
|
||||
send({ type:'accept-mic', epoch: outstandingInvite.epoch });
|
||||
|
|
@ -1011,16 +1170,18 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
|
|||
* ================================================================== */
|
||||
$('btn-enter').addEventListener('click', joinSpace);
|
||||
$('rdv-code').addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); joinSpace(); } });
|
||||
$('btn-leave').addEventListener('click', () => {
|
||||
$('btn-leave').addEventListener('click', async () => {
|
||||
wantConnected = false;
|
||||
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
|
||||
for (const u of [...peers.keys()]) tearPeer(u);
|
||||
if (ws){ try { ws.close(); } catch(_){} ws = null; }
|
||||
await sfuUnpublish(); await sfuUnsubscribe();
|
||||
dropMic();
|
||||
members.clear(); handraise.clear(); myUUID=''; myRole=''; hostUUID=''; outstandingInvite=null;
|
||||
$('sec-room').classList.add('hidden');
|
||||
$('sec-invite').classList.add('hidden');
|
||||
$('sec-listener-actions').classList.add('hidden');
|
||||
hideNotice();
|
||||
$('dot-call').className='dot warn';
|
||||
setStatus('left', null);
|
||||
$('btn-enter').disabled = false;
|
||||
|
|
@ -1036,8 +1197,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
|
|||
|
||||
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-05-31</span><br>
|
||||
md5 <span class="stamp-md5">cd35adde9997d99b27d4ee03f54863cd</span><br>
|
||||
sha256 <span class="stamp-sha">818aefd367f7031750589fa3c8e870440f6dcd0df2f91793e118060169364767</span><br>
|
||||
md5 <span class="stamp-md5">b285faeda4dec4f749f7cb5a7ebe8d4e</span><br>
|
||||
sha256 <span class="stamp-sha">0f0d84caf0280fdbe9e4fb75b42fd63a7b198526c6b6ffa411bc58937f357e0d</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
||||
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||
</footer>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue