diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index b488673..e5ef772 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -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 @@
+
+
room
@@ -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');