diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index d002c0a..68cd8a1 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -529,32 +529,28 @@
.raised { color: #b00; font-weight: bold; }
.mic { width: 18px; height: 18px; }
.mic svg { width: 18px; height: 18px; display: block; }
+ /* mod-actions is a vertical stack of row-groups. Each row-group is its
+ * own horizontal auto-fit grid that wraps inside itself when the panel
+ * is too narrow. Separating row-groups gives kick/ban their own line
+ * beneath the primary mod actions (stream + role transitions) without
+ * collapsing the parent auto-fit (the previous grid-column:1/-1 trick
+ * forced the entire parent grid to one column → every button stacked
+ * vertically). Grid only — no flex per project rule. */
.mod-actions {
- /* repeat(auto-fit, max-content) lets buttons wrap onto a second row
- * when the panel is too narrow to hold them all on one line — was
- * grid-auto-flow: column which forced a single non-wrapping line and
- * the right edge of long button strips (stream + promote + demote +
- * kick + ban) overflowed the side panel, triggering horizontal
- * scroll. Grid still — no flex per the project rule. */
- display: grid; grid-template-columns: repeat(auto-fit, max-content);
- gap: 0.3rem;
- justify-content: start;
+ display: grid;
+ grid-auto-rows: max-content;
+ row-gap: 0.2rem;
margin-top: 0.2rem;
}
- .mod-actions:empty { display: none; } /* no row gap when nothing to do */
- /* kick + ban always land on their own row beneath the primary mod
- * actions (stream toggle, role transitions). They're destructive,
- * not routine — visually separating them avoids the muscle-memory
- * misclick where 'ban' sits next to '→ speaker' and a quick stab
- * removes someone from the room. grid-column:1/-1 spans the parent
- * auto-fit row so the next auto-placed item wraps. */
- .mod-actions .acts-removal {
- grid-column: 1 / -1;
+ .mod-actions:empty { display: none; }
+ .acts-primary, .acts-removal {
display: grid;
grid-template-columns: repeat(auto-fit, max-content);
- gap: 0.3rem;
- margin-top: 0.15rem;
+ column-gap: 0.3rem;
+ row-gap: 0.2rem;
+ justify-content: start;
}
+ .acts-primary:empty, .acts-removal:empty { display: none; }
/* log lives in its own full-width section beneath the .page grid.
* 240px default, vertical resize handle so the user can drag it
* taller when the chatter outpaces the viewport. */
@@ -1690,6 +1686,23 @@ function flushSfuStreams(){
* - 60s camera / 120s screen+game (current). */
const VIDEO_REMOVE_MUTE_WINDOW_MS = 60000;
const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 120000;
+
+/* Receiver-side jitter buffer target in seconds. Bigger = more
+ * absorption of network jitter / burst loss = cleaner playback under
+ * stress, at the cost of conversational latency. The history of this
+ * number:
+ * - 0.4s: original — too tight for mixed Wi-Fi + cellular,
+ * produced robot-voice artifacts on hand-offs.
+ * - 0.7s: covered most cross-AP cases, still chopped on
+ * music-mode / loaded uplinks.
+ * - 2.0s (current): fox 2026-06-03 asked for a long buffer to
+ * prevent chop "ever". Conversational latency goes up, but
+ * this is a music/DJ-focused stack and the stream/HTTP-pull
+ * DJ mode already commits to ~2s, so RTC matching that is
+ * consistent. If you need lower latency for actual
+ * conversation, drop this back to 0.7 and accept the
+ * occasional under-run. */
+const RECV_PLAYOUT_DELAY_SEC = 2.0;
function watchVideoTrackForRemoval(track, removeFn, windowMs){
if (!track) return;
if (typeof windowMs !== 'number' || !isFinite(windowMs) || windowMs <= 0){
@@ -2630,12 +2643,10 @@ function handleRemoteSfuTrack(ev){
logLine('', 'sfu: unknown kind '+kind+' from '+pubHex);
return;
}
- /* mic audio — 700ms jitter-buffer target absorbs cross-Wi-Fi peak
- * jitter and burst packet loss. 400ms was too tight for users on
- * different APs / cellular hand-offs — under-runs were producing
- * the 'robot voice' artifact. 700ms costs an inaudible bit of
- * end-to-end latency in exchange for clean playback under loss. */
- try { if (ev.receiver) ev.receiver.playoutDelayHint = 0.7; } catch(_){}
+ /* mic audio — see RECV_PLAYOUT_DELAY_SEC for the buffer-vs-latency
+ * tradeoff rationale. Bumped 0.7 → 2.0 after fox 2026-06-03 chasing
+ * persistent chop that survived every SDP-side dial-back. */
+ try { if (ev.receiver) ev.receiver.playoutDelayHint = RECV_PLAYOUT_DELAY_SEC; } catch(_){}
/* cache by full pubkey (already resolved above) so it survives the
* member's session uuid changing across leave/rejoin */
sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
@@ -3816,10 +3827,8 @@ async function connectToPeer(uuid, weOffer){
applySinkTo(a);
}
a.srcObject = ev.streams[0] || new MediaStream([ev.track]);
- /* 700ms jitter buffer — matches the SFU path. Cross-Wi-Fi and
- * cellular hand-off jitter ate into the old 400ms cushion and
- * produced robot-voice artifacts on tighter networks. */
- try { ev.receiver.playoutDelayHint = 0.7; } catch(_){}
+ /* mesh path matches the SFU path — same RECV_PLAYOUT_DELAY_SEC. */
+ try { ev.receiver.playoutDelayHint = RECV_PLAYOUT_DELAY_SEC; } catch(_){}
stopMeter(uuid); startMeter(uuid, a.srcObject);
};
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
@@ -4027,35 +4036,47 @@ function renderRoom(){
const fill = document.createElement('div'); fill.className = 'meter-fill';
meter.appendChild(fill);
m._meterEl = fill;
- /* mod controls — only render when we can act on this row */
+ /* mod controls — only render when we can act on this row. Two
+ * sibling row-groups inside .mod-actions:
+ * .acts-primary — stream toggle, role transitions (non-destructive)
+ * .acts-removal — kick + ban (destructive, own line) */
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. Also rendered on your
- * own row as a DJ self-monitor: confirms what listeners are hearing
- * (~2s delayed). Safe with closed headphones; loud feedback with
- * open speakers + music-mode mic — title attr warns. */
+ const actsPrimary = document.createElement('span'); actsPrimary.className = 'acts-primary';
+ const actsRemoval = document.createElement('span'); actsRemoval.className = 'acts-removal';
+
+ /* per-speaker "stream" toggle: flips THIS viewer's audio path for
+ * the targeted speaker from WebRTC subscribe → HTTP Ogg/Opus tap.
+ * Visibility rule:
+ * - SELF row: always (self-monitor — preview what listeners hear)
+ * - OTHER rows: host only (host controls the room's audio path
+ * for every speaker; a non-host speaker should not be flipping
+ * anyone else's stream because the toggle is rendered as a
+ * ROOM-LEVEL switch in the host's mental model)
+ * Cohosts intentionally get only self-monitor — if fox wants them
+ * room-wide, lift the gate to isMod(myRole). */
if (canSpeak(m.role) && m.pubkey){
let mPubHex = '';
try { mPubHex = hex(unb64(m.pubkey)); } catch(_){}
if (mPubHex){
const isSelf = (m.uuid === myUUID);
- const sb = document.createElement('button'); sb.className = 'small';
- const on = streamMode.has(mPubHex);
- sb.textContent = on ? '◉ stream' : '○ stream';
- if (isSelf){
- sb.title = on
- ? 'monitoring your own DJ stream — click to stop (feedback risk on open speakers!)'
- : 'preview what listeners hear of YOUR mic (~2s delay) — headphones recommended';
- } else {
- 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)';
+ const canStreamThisRow = isSelf || myRole === 'host';
+ if (canStreamThisRow){
+ const sb = document.createElement('button'); sb.className = 'small';
+ const on = streamMode.has(mPubHex);
+ sb.textContent = on ? '◉ stream' : '○ stream';
+ if (isSelf){
+ sb.title = on
+ ? 'monitoring your own DJ stream — click to stop (feedback risk on open speakers!)'
+ : 'preview what listeners hear of YOUR mic (~2s delay) — headphones recommended';
+ } else {
+ 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);
+ actsPrimary.appendChild(sb);
}
- if (on) sb.classList.add('invert');
- sb.onclick = () => toggleStreamFor(m.uuid, mPubHex);
- acts.appendChild(sb);
}
}
if (isMod(myRole) && m.uuid !== myUUID){
@@ -4066,44 +4087,39 @@ function renderRoom(){
b.onclick = raised
? () => modGrant(m.uuid).catch(e=>logLine('err','grant: '+e.message))
: () => modInvite(m.uuid).catch(e=>logLine('err','invite: '+e.message));
- acts.appendChild(b);
+ actsPrimary.appendChild(b);
}
if (m.role === 'speaker' && myRole === 'host'){
const b = document.createElement('button'); b.className='small';
b.textContent = '→ cohost'; b.onclick = () => modPromote(m.uuid).catch(e=>logLine('err','promote: '+e.message));
- acts.appendChild(b);
+ actsPrimary.appendChild(b);
}
if (m.role === 'speaker'){
const b = document.createElement('button'); b.className='small';
b.textContent = '→ listener'; b.onclick = () => modDemote(m.uuid, 'listener').catch(e=>logLine('err','demote: '+e.message));
- acts.appendChild(b);
+ actsPrimary.appendChild(b);
}
if (m.role === 'cohost' && myRole === 'host'){
const b = document.createElement('button'); b.className='small';
b.textContent = '→ speaker'; b.onclick = () => modDemote(m.uuid, 'speaker').catch(e=>logLine('err','demote: '+e.message));
- acts.appendChild(b);
+ actsPrimary.appendChild(b);
}
- /* kick + ban allowed against anyone except host; cohosts also
- * can't kick/ban cohosts (host only). Two buttons because the
- * actions have different blast radius: kick = drop this session
- * (rejoinable), ban = block-by-pubkey for the room's hold window.
- * Nested in their own .acts-removal row so they always sit on a
- * line beneath the primary mod actions — destructive controls
- * shouldn't share a row with role-transition buttons. */
+ /* kick + ban — destructive removal actions, kept on their own
+ * row so the muscle-memory misclick where 'ban' sat next to
+ * '→ speaker' can't fire by accident. */
if (m.role !== 'host' && !(m.role === 'cohost' && myRole !== 'host')){
- const removalRow = document.createElement('span');
- removalRow.className = 'acts-removal';
const kb = document.createElement('button'); kb.className='small';
kb.textContent = 'kick';
kb.onclick = () => modKick(m.uuid).catch(e => logLine('err','kick: '+e.message));
- removalRow.appendChild(kb);
+ actsRemoval.appendChild(kb);
const bb = document.createElement('button'); bb.className='small';
bb.textContent = 'ban';
bb.onclick = () => modBan(m.uuid).catch(e => logLine('err','ban: '+e.message));
- removalRow.appendChild(bb);
- acts.appendChild(removalRow);
+ actsRemoval.appendChild(bb);
}
}
+ acts.appendChild(actsPrimary);
+ acts.appendChild(actsRemoval);
row.appendChild(badge); row.appendChild(handle); row.appendChild(pub);
row.appendChild(micEl); row.appendChild(meter); row.appendChild(acts);
wrap.appendChild(row);
@@ -4427,26 +4443,33 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
refreshMicList();
refreshSpeakerList();
-/* tab-close strong-leave: when the page is about to unload, send 'bye'
- * BEFORE the WS gets torn down by the browser. Without this, closing a
- * tab (or backgrounding the phone browser, or swiping the app away)
- * leaves the WS to die silently — the signal server treats that as a
- * hiccup and waits 8s before broadcasting peer-left + evicting SFU PCs.
- * During those 8s the rest of the room still hears the closed tab's
- * mic. Fox 2026-06-03 ("will closed tab on phone — audio kept playing
- * until i kicked him"). pagehide is the cross-browser reliable signal
- * for this; beforeunload doesn't fire on mobile Safari and is unreliable
- * on PWAs. We use both: pagehide is primary, beforeunload covers older
- * desktop Firefox / Chromium that fire it before pagehide. */
-function sendByeAndClose(){
+/* tab-close strong-leave: when the page is about to unload FOR REAL,
+ * send 'bye' BEFORE the WS gets torn down by the browser. Without
+ * this, a closed tab dies silently → hiccup grace → 8s of trailing
+ * audio. Fox 2026-06-03.
+ *
+ * Critical guard: pagehide ALSO fires when the page goes into the
+ * back-forward cache (mobile app-switch / lock screen / minimise),
+ * with event.persisted=true. We must NOT send bye in that case —
+ * the page is still alive, just paused; on pageshow it resumes with
+ * the same WS / SFU / mesh state. Sending bye here would force the
+ * server to evict the SFU PCs, and when the phone comes back the
+ * resumed audio path stays muted (Fox 2026-06-03: "now the phone
+ * leaving and coming back cannot hear the music").
+ *
+ * The JS heartbeat handles the bfcache case independently — while
+ * the page is bfcached, setInterval is paused, so the server's
+ * aliveTTL fires after 45s if the user doesn't come back. */
+function sendByeIfRealClose(ev){
+ if (ev && ev.persisted) return; // bfcache — page is napping, not dying
try {
if (ws && ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({ type: 'bye' }));
}
} catch(_){}
}
-window.addEventListener('pagehide', sendByeAndClose);
-window.addEventListener('beforeunload', sendByeAndClose);
+window.addEventListener('pagehide', sendByeIfRealClose);
+window.addEventListener('beforeunload', sendByeIfRealClose);
/* laptop-lid-close / sleep / suspend recovery: when the tab comes back
* to visible, check whether our SFU sub PC is still in a healthy state.
@@ -4574,8 +4597,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');