zebra-spaces: stream-gating + layout + 2s playout + bfcache-safe bye
Four corrections in one pass — all from fox's live-room session
2026-06-03 after the kick/ban + heartbeat ship:
1. Stream button visibility was too generous. A speaker viewing
the host's row got a stream button — they shouldn't. New rule:
SELF row always (self-monitor), OTHER rows only when myRole
=== 'host'. Cohost gets self-only too; lift the gate to
isMod(myRole) if room-wide cohost stream control is wanted.
2. .acts-removal's grid-column:1/-1 collapsed the parent auto-fit
grid down to a single column on a row with both primary +
removal actions, so EVERY button stacked vertically. Refactor:
.mod-actions is now a row-stack of sibling groups
(.acts-primary + .acts-removal), each its own horizontal auto-
fit grid. Primary still wraps inside itself when the panel is
narrow; kick + ban are forced to a fresh line by the parent
grid-auto-rows.
3. playoutDelayHint bumped 0.7 → 2.0 (RECV_PLAYOUT_DELAY_SEC) on
both mesh + SFU receive paths. Conversational latency goes up
but the stream/HTTP-pull DJ mode already commits to ~2s, so
matching the WebRTC path makes the room consistent. Persistent
chop survived every SDP-side dial-back fox tried; this is the
last knob left at the receiver.
4. pagehide-bye now skips bfcache (event.persisted=true). Without
this guard, a phone going to lock screen / app-switch /
minimise fired bye → server evicted SFU PCs → page resumed but
audio stayed silent. Aliveness of a bfcached page is already
handled by the server-side aliveTTL (heartbeat stops during
bfcache → server arms hiccup grace at 45s).
This commit is contained in:
parent
148116ae89
commit
de5b530427
1 changed files with 107 additions and 84 deletions
|
|
@ -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');
|
|||
|
||||
<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-03</span><br>
|
||||
md5 <span class="stamp-md5">cb5265cea7abb421e8c6b19ee0861317</span><br>
|
||||
sha256 <span class="stamp-sha">3ca7bec762f04cf2c4e75ee7b5f22b1e83d9924e78828a4a69641526ffdf6d7c</span><br>
|
||||
md5 <span class="stamp-md5">e5f7ac83724775581e7f3b543f0c7877</span><br>
|
||||
sha256 <span class="stamp-sha">0aa8ed4ff27ace6d5f641cc99377f4f67b89f475605c60ab38300888b11e626f</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