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');