zebra-report: per-speaker "stream" toggle (DJ mode, Ogg/Opus over HTTP)

Each speaker row carries a stream toggle that swaps their audio from
WebRTC subscribe (~700ms latency, glitchy under loss) to a plain
HTTP <audio> pulling Ogg/Opus from the SFU's new /stream endpoint
(~2s latency, browser-managed jitter buffer, bulletproof for DJ
playback). Requires matching proxy.unturf.com SFU build.
This commit is contained in:
russell@unturf.com 2026-06-03 18:35:52 -04:00
parent 147dbc0f9e
commit 9b55fd96ce
No known key found for this signature in database

View file

@ -3853,6 +3853,10 @@ function tearPeer(uuid){
if (pc){ try { pc.close(); } catch(_){} peers.delete(uuid); }
const a = remoteAudio.get(uuid);
if (a){ try { a.srcObject = null; a.remove(); } catch(_){} remoteAudio.delete(uuid); }
/* stream-mode <audio> for this uuid (DJ mode) — close the HTTP pull
* so the SFU stops fanning Ogg pages to a dead client */
const s = streamAudio.get(uuid);
if (s){ try { s.pause(); s.removeAttribute('src'); s.load(); s.remove(); } catch(_){} streamAudio.delete(uuid); }
}
function waitForIceGathering(p, timeoutMs=6000){
return new Promise(res=>{
@ -3930,6 +3934,56 @@ const MIC_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="#b00" stroke-width
function rankOf(role){ return {host:4, cohost:3, speaker:2, listener:1}[role] || 0; }
/* ----- HTTP broadcast stream (DJ mode) — parallel to SFU mic subscribe.
* For a given speaker's pubHex, the user can flip into "stream" mode:
* we mute the WebRTC remote audio for that uuid and attach a separate
* <audio> element pulling /zebra-spaces-sfu/stream?room=R&pub=PUBHEX
* as Ogg/Opus. ~1.53s extra latency, but the browser's jitter buffer
* smooths out network glitches the WebRTC path can't. State is keyed
* by pubHex (not uuid) so it survives session-uuid churn on rejoin. */
const streamMode = new Set(); // pubHex strings currently streaming
const streamAudio = new Map(); // uuid -> <audio> pulling stream
function streamUrlFor(pubHex){
return SFU_BASE + '/stream?room=' + encodeURIComponent(roomID) + '&pub=' + pubHex;
}
function startStream(uuid, pubHex){
/* mute the WebRTC mic for this uuid so they don't double-play */
const w = remoteAudio.get(uuid);
if (w) try { w.muted = true; } catch(_){}
let a = streamAudio.get(uuid);
if (!a){
a = document.createElement('audio');
a.autoplay = true; a.controls = false;
document.body.appendChild(a);
streamAudio.set(uuid, a);
applySinkTo(a);
}
a.muted = false;
a.src = streamUrlFor(pubHex);
try { const p = a.play(); if (p && p.catch) p.catch(()=>{}); } catch(_){}
logLine('', 'stream on for '+pubHex.slice(0,12)+' — DJ mode (~2s delay, glitch-free)');
}
function stopStream(uuid){
const a = streamAudio.get(uuid);
if (a){
try { a.pause(); } catch(_){}
try { a.removeAttribute('src'); a.load(); } catch(_){}
}
/* unmute the WebRTC mic so conversation resumes */
const w = remoteAudio.get(uuid);
if (w) try { w.muted = false; } catch(_){}
}
function toggleStreamFor(uuid, pubHex){
if (streamMode.has(pubHex)){
streamMode.delete(pubHex);
stopStream(uuid);
} else {
streamMode.add(pubHex);
startStream(uuid, pubHex);
}
renderRoom();
}
function renderRoom(){
const wrap = $('members'); wrap.innerHTML = '';
/* sort host → cohosts → speakers → listeners, then by joined_at */
@ -3956,6 +4010,25 @@ function renderRoom(){
m._meterEl = fill;
/* mod controls — only render when we can act on this row */
const acts = document.createElement('span'); acts.className = 'mod-actions';
/* per-speaker "stream" toggle: anyone can flip another speaker's
* audio from WebRTC subscribe → HTTP Ogg/Opus tap. Higher latency,
* cleaner playback under loss. Not a mod action — every listener
* gets to pick their own path per speaker. */
if (canSpeak(m.role) && m.uuid !== myUUID && m.pubkey){
let mPubHex = '';
try { mPubHex = hex(unb64(m.pubkey)); } catch(_){}
if (mPubHex){
const sb = document.createElement('button'); sb.className = 'small';
const on = streamMode.has(mPubHex);
sb.textContent = on ? '◉ stream' : '○ stream';
sb.title = on
? 'streaming — click to switch back to live (low-latency WebRTC)'
: 'switch to HTTP Ogg/Opus stream (~2s delay, glitch-free for DJ sets)';
if (on) sb.classList.add('invert');
sb.onclick = () => toggleStreamFor(m.uuid, mPubHex);
acts.appendChild(sb);
}
}
if (isMod(myRole) && m.uuid !== myUUID){
if (m.role === 'listener'){
const b = document.createElement('button'); b.className='small invert';
@ -4466,8 +4539,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> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-03</span><br>
md5 <span class="stamp-md5">438b3d7f452d60edd79baae0a03094af</span><br>
sha256 <span class="stamp-sha">203ace58efa704b3499137bd26d0a881df84d40ef73913e20aa2d7ba6d001fa6</span><br>
md5 <span class="stamp-md5">beebb8a6a5d5675bfcf12f83230fe283</span><br>
sha256 <span class="stamp-sha">898175e9ffac08ae9e56390a594701728bd94ec8b0d36942589df921efcfa716</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>