diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 8dd8665..813657e 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -3481,6 +3481,20 @@ async function handleSignal(raw){
* present-member-offers: existing speaker offers when a new speaker
* arrives. deterministic by uuid string compare. */
if (canSpeak(myRole) && canSpeak(m.role)) connectToPeer(m.uuid, /*weOffer*/ myUUID < m.uuid);
+ /* if we're in self-listener mode and a new speaker just arrived,
+ * pull them onto the buffered HTTP stream too so we hear them on
+ * the same path as everyone else */
+ if (selfListenerMode && canSpeak(m.role) && m.uuid !== myUUID && m.pubkey){
+ try {
+ const ph = hex(unb64(m.pubkey));
+ if (ph && !streamMode.has(ph)){
+ streamMode.add(ph);
+ startStream(m.uuid, ph);
+ const w = remoteAudio.get(m.uuid);
+ if (w) try { w.muted = true; } catch(_){}
+ }
+ } catch(_){}
+ }
renderRoom();
break;
case 'peer-left':
@@ -3819,6 +3833,10 @@ async function onRoleChanged(prev, next){
* 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. */
+ /* self-listener flag is meaningless once we're a listener (the
+ * row's stream toggle disappears); flip it OFF so its streamMode
+ * entries get torn down cleanly with the rest of our state. */
+ if (selfListenerMode) disableSelfListenerMode();
dropMic(); muted = false;
await sfuUnpublish();
await sfuUnpublishScreen();
@@ -4076,6 +4094,12 @@ const streamAudio = new Map(); // uuid -> pulling stream
* Muted starts TRUE so the visible button starts as 'play' — fox: the
* canonical 'I want sound' moment is when they tap that button. */
let listenerOutputMuted = true;
+/* Self-listener mode: a speaker / cohost / host has flipped their own
+ * row's stream toggle to consume the room via the buffered HTTP Ogg/
+ * Opus path instead of the live WebRTC mesh. Auto-mutes their mic so
+ * they can't talk into a delayed stream (they'd be 2-4s behind the
+ * conversation); unmuting toggles them back to WebRTC seamlessly. */
+let selfListenerMode = false;
/* DJ HTTP stream mode is intentionally NOT auto-enrolled for listener
* phones — Firefox Android refuses autoplay on every fresh
* with src URL and a hard refresh starts the same loop. WebRTC stays
@@ -4125,6 +4149,7 @@ async function startStream(uuid, pubHex){
* in-flight load — Firefox surfaces this as 'fetching aborted at
* user request' which we'd mistake for autoplay block. */
if (a && a.src === wantUrl && !a.error) {
+ logLine('', 'stream: skip dup for '+pubHex.slice(0,12)+' (already loading/playing)');
return;
}
if (!a){
@@ -4142,6 +4167,15 @@ async function startStream(uuid, pubHex){
* DJ-mode on remote speakers) was playing silently. Unmute on every
* call so a reused element from a prior stop also re-monitors. */
try { a.muted = false; } catch(_){}
+ /* Diagnostic breadcrumbs — earlier sessions clicked the toggle and
+ * the SFU log saw zero /stream GETs. Track loadstart (fetch began),
+ * canplay (data arriving), stalled (TCP stuck) so we can tell on
+ * which leg the path breaks next time. once:false so we see every
+ * recovery cycle. */
+ a.addEventListener('loadstart', () => logLine('', 'stream loadstart '+pubHex.slice(0,12)), { once: true });
+ a.addEventListener('canplay', () => logLine('', 'stream canplay '+pubHex.slice(0,12)), { once: true });
+ a.addEventListener('stalled', () => logLine('err','stream stalled '+pubHex.slice(0,12)));
+ logLine('', 'stream open: '+pubHex.slice(0,12)+' → '+wantUrl);
a.src = wantUrl;
const onPlaying = () => {
logLine('', 'stream on for '+pubHex.slice(0,12)+' — DJ mode (~2s delay, glitch-free)');
@@ -4151,7 +4185,12 @@ async function startStream(uuid, pubHex){
logLine('err', 'stream for '+pubHex.slice(0,12)+' '+why+' — staying on live WebRTC');
};
a.addEventListener('playing', onPlaying, { once: true });
- a.addEventListener('error', () => onFail('error'), { once: true });
+ a.addEventListener('error', (ev) => {
+ const err = a.error;
+ const code = err ? err.code : '?';
+ const msg = err ? (err.message || '') : '';
+ onFail('error code='+code+' msg='+msg);
+ }, { once: true });
try {
const p = a.play();
if (p && p.catch) p.catch(e => onFail('autoplay blocked: '+e.message));
@@ -4181,6 +4220,68 @@ function toggleStreamFor(uuid, pubHex){
renderRoom();
}
+/* selfListenerMode — a speaker/cohost/host who wants to consume the
+ * room via the buffered HTTP Ogg/Opus path instead of the live WebRTC
+ * mesh. Useful when WebRTC ICE / DTLS dies under cellular churn or
+ * the user just wants the higher-fidelity broadcast pipeline.
+ *
+ * Coupled to mute: turning it ON auto-mutes (you'd be 2-4s behind the
+ * conversation; talking into that delay is hopeless). Unmuting flips
+ * it OFF so the user is seamlessly back on the live mesh.
+ *
+ * Mechanics: populate streamMode with every audible peer's pubHex,
+ * fire startStream() for each so we open the HTTP Ogg/Opus pull, and
+ * mute the corresponding remoteAudio (WebRTC) elements so we don't
+ * hear both paths at once. disable* tears everything down + restores
+ * the WebRTC playback. */
+async function enableSelfListenerMode(){
+ if (selfListenerMode) return;
+ selfListenerMode = true;
+ /* auto-mute mic before we start playing the delayed stream */
+ if (micStream && !muted){
+ muted = true;
+ try { sessionStorage.setItem(MUTE_STATE_KEY, '1'); } catch(_){}
+ applyMuteState();
+ try { sendMicState(); } catch(_){}
+ }
+ let added = 0;
+ for (const [uuid, mm] of members){
+ if (uuid === myUUID || !canSpeak(mm.role) || !mm.pubkey) continue;
+ let pubHex;
+ try { pubHex = hex(unb64(mm.pubkey)); } catch(_){ continue; }
+ if (streamMode.has(pubHex)) continue;
+ streamMode.add(pubHex);
+ startStream(uuid, pubHex);
+ const w = remoteAudio.get(uuid);
+ if (w) try { w.muted = true; } catch(_){}
+ added++;
+ }
+ renderRoom();
+ logLine('', 'self-listener ON — '+added+' peers on buffered HTTP path, mic muted');
+}
+function disableSelfListenerMode(){
+ if (!selfListenerMode) return;
+ selfListenerMode = false;
+ for (const pubHex of [...streamMode]){
+ let foundUuid = null;
+ for (const [u, mm] of members){
+ try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ foundUuid = u; break; } } catch(_){}
+ }
+ streamMode.delete(pubHex);
+ if (foundUuid) stopStream(foundUuid);
+ }
+ /* restore WebRTC playback for every remote — stopStream already
+ * unmutes the matched uuid; this catches any others we couldn't
+ * resolve (e.g. member dropped while we were in DJ mode). */
+ for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} }
+ renderRoom();
+ logLine('', 'self-listener OFF — back to live WebRTC mesh');
+}
+function toggleSelfListenerMode(){
+ if (selfListenerMode) disableSelfListenerMode();
+ else enableSelfListenerMode();
+}
+
/* Default-on DJ mode for listeners: skip the WebRTC mic playback path
* for every audible peer and pull HTTP Ogg/Opus instead. Browser's
* element keeps a deep media buffer (~30s in Chrome) that
@@ -4293,29 +4394,20 @@ function renderRoom(){
* non-mods (just-self-monitor self-rows), which fox flagged as
* causing vertical scroll. Single-char glyph keeps the column 1.4rem. */
let streamEl = null;
- if (canSpeak(m.role) && m.pubkey){
- let mPubHex = '';
- try { mPubHex = hex(unb64(m.pubkey)); } catch(_){}
- if (mPubHex){
- const isSelf = (m.uuid === myUUID);
- const canStreamThisRow = isSelf || myRole === 'host';
- if (canStreamThisRow){
- const on = streamMode.has(mPubHex);
- streamEl = document.createElement('button');
- streamEl.className = 'stream-toggle' + (on ? ' on' : '');
- streamEl.textContent = on ? '◉' : '○';
- if (isSelf){
- streamEl.title = on
- ? 'monitoring your DJ stream — click to stop (feedback risk on open speakers!)'
- : 'preview what listeners hear of YOUR mic (~2s delay) — headphones recommended';
- } else {
- streamEl.title = on
- ? 'streaming — click to switch back to live WebRTC'
- : 'switch this speaker to HTTP Ogg/Opus stream (~2s delay, glitch-free)';
- }
- streamEl.onclick = () => toggleStreamFor(m.uuid, mPubHex);
- }
- }
+ /* Per-row stream toggle. The ONLY row that gets one is SELF — and
+ * only when we can speak (host/cohost/speaker). The button flips
+ * THIS viewer's whole audio path from the live WebRTC mesh to the
+ * buffered HTTP Ogg/Opus broadcast tap (the listener-quality
+ * stream). selfListenerMode is global, so the ◉/○ glyph reflects
+ * that flag rather than streamMode of any single pubHex. */
+ if (canSpeak(m.role) && m.uuid === myUUID && canSpeak(myRole)){
+ streamEl = document.createElement('button');
+ streamEl.className = 'stream-toggle' + (selfListenerMode ? ' on' : '');
+ streamEl.textContent = selfListenerMode ? '◉' : '○';
+ streamEl.title = selfListenerMode
+ ? 'listening on the buffered HTTP stream — click to rejoin the live WebRTC mesh (also unmutes is via the mic button)'
+ : 'switch yourself to the listener stream (buffered, ~2s behind) — auto-mutes your mic';
+ streamEl.onclick = () => toggleSelfListenerMode();
}
if (isMod(myRole) && m.uuid !== myUUID){
if (m.role === 'listener'){
@@ -4651,10 +4743,15 @@ function applyMuteState(){
}
$('btn-mute').addEventListener('click', () => {
if (!micStream) return;
+ const wasMuted = muted;
muted = !muted;
try { sessionStorage.setItem(MUTE_STATE_KEY, muted ? '1' : '0'); } catch(_){}
applyMuteState();
sendMicState();
+ /* Unmuting while self-listener-mode is on means "I want to talk
+ * again" — tear down the buffered HTTP streams and restore the live
+ * WebRTC mesh so the user is back in the now of the conversation. */
+ if (wasMuted && !muted && selfListenerMode) disableSelfListenerMode();
});
$('mic-select').addEventListener('change', async (e) => {
micDeviceId = e.target.value;
@@ -4895,6 +4992,7 @@ $('btn-leave').addEventListener('click', async () => {
}
streamAudio.clear();
streamMode.clear();
+ selfListenerMode = false;
for (const u of [...peers.keys()]) tearPeer(u);
/* send 'bye' BEFORE closing the WS — server distinguishes a strong
* leave (user clicked leave / closed tab) from a hiccup disconnect
@@ -4944,8 +5042,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
page integrity · built 2026-06-04
- md5 669ef155bc5fd5e8a1452e593f3b257d
- sha256 1242cf6e1835f9e2e1bb40c51b3b83cc7ed2c9bcd69b6593a5bea70835054ac5
+ md5 398d9aaa80adc3b9c5b1b7320f095834
+ sha256 151bbeb161be4bffaf10bf71249fe5ba6fd1d4bc22b2c948dbbdb540574b6bf7
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or host your own community