zebra-spaces: self-listener mode — speakers can switch to the HTTP DJ stream
Speakers/cohosts/hosts get a per-row stream toggle ONLY on their own row that flips their playback for the entire room from the live WebRTC mesh to the buffered HTTP Ogg/Opus broadcast tap. Auto-mutes the user's mic when ON (they'd be 2-4s behind the conversation, can't talk into the delay). Unmuting flips it OFF, restoring the live mesh. This gives a non-WebRTC audio path that survives cellular ICE/DTLS churn — when the mesh dies the HTTP <audio> jitter buffer keeps serving until the user opts back into live conversation. Side change: dropped the host-controls-other-rows variant of the toggle (fox: each role only switches themselves). selfListenerMode populates streamMode with every audible peer, mutes their WebRTC remoteAudio, and pulls each via /stream?pub=PUBHEX. Also adds breadcrumb logging to startStream (loadstart/canplay/stalled/ error code) so the next failed click leaves a trail — earlier sessions clicked the toggle and zero /stream GETs reached the SFU; we can now tell on which leg the fetch breaks.
This commit is contained in:
parent
f388d64f3b
commit
1e2fbfa102
1 changed files with 124 additions and 26 deletions
|
|
@ -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 -> <audio> 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 <audio>
|
||||
* 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
|
||||
* <audio> 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');
|
|||
|
||||
<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-06-04</span><br>
|
||||
md5 <span class="stamp-md5">669ef155bc5fd5e8a1452e593f3b257d</span><br>
|
||||
sha256 <span class="stamp-sha">1242cf6e1835f9e2e1bb40c51b3b83cc7ed2c9bcd69b6593a5bea70835054ac5</span><br>
|
||||
md5 <span class="stamp-md5">398d9aaa80adc3b9c5b1b7320f095834</span><br>
|
||||
sha256 <span class="stamp-sha">151bbeb161be4bffaf10bf71249fe5ba6fd1d4bc22b2c948dbbdb540574b6bf7</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