zebra-spaces: fix mobile horizontal scroll + host rejoin audio + timeline below

Three issues fixed together:

1. Mobile horizontal scroll: dynamically-appended <audio> sinks render
   their default ~300px control strip on Firefox mobile, pushing the
   page wider than the viewport. Hide them globally with
   'audio { display: none }' — they're just sinks for the WebRTC track,
   no UI required.

2. Don't use literal 50/50 split: page grid is now
   'minmax(0, 1fr) minmax(0, 520px)' so the controls cap at 520px and
   the timeline soaks the remainder. min-width:0 on both children so
   they shrink without forcing an outer scrollbar.

3. Mobile order: on phones the controls were stacked ABOVE the
   timeline placeholder; fox wants them first. Use 'order: 1' on
   .controls and 'order: 2' on .timeline inside the 800px breakpoint.

4. Host rejoin audio: when a host leaves and rejoins, Pion frequently
   reuses the same transceiver on the SFU side, so 'ontrack' doesn't
   fire a second time on the listener — but the listener already tore
   down the audio element on peer-left. The audio plays into nothing.

   Replace the pending-tracks queue with sfuStreamsByPubHex (keyed by
   the publisher's pubkey, which is the streamID). The cached
   MediaStream survives the host's session uuid changing across
   leave/rejoin. On peer-joined / host-promoted / state we look up the
   cached stream by pubkey and attach to the fresh audio element.

   Also call a.play() after the srcObject swap so the autoplay policy
   doesn't quietly drop the resumed playback.
This commit is contained in:
Russell Ballestrini 2026-05-31 14:19:43 -04:00
parent c525c7df11
commit a1ce6c0598
No known key found for this signature in database

View file

@ -16,26 +16,38 @@
font-family: monospace; background: #fff; color: #000;
padding: 2rem; max-width: 1440px; margin: 0 auto;
}
/* two-column page: left = timeline (blank for now, ~50%), right = controls.
* stacks below 800px so the controls stay usable on a phone. */
/* two-column page on desktop: left = timeline (1fr, takes remaining
* space — will host the running event feed once v0.3 lands), right =
* controls (capped at 520px so they never get awkwardly wide on a
* full-screen monitor). Both columns get min-width:0 so they shrink
* without forcing a horizontal scrollbar. */
.page {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: minmax(0, 1fr) minmax(0, 520px);
gap: 2rem;
align-items: start;
}
.timeline {
border-right: 1px solid #ddd; padding-right: 2rem; min-height: 60vh;
min-width: 0;
}
.controls { min-width: 0; }
.timeline-placeholder {
font-size: 0.8rem; color: #999; padding: 1rem 0; line-height: 1.6;
}
@media (max-width: 800px) {
body { padding: 1.25rem; }
.page { grid-template-columns: 1fr; gap: 1rem; }
.timeline { border-right: none; border-bottom: 1px solid #ddd;
padding-right: 0; padding-bottom: 1rem; min-height: 0; }
/* stack on mobile, controls first, timeline below */
.page { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
.controls { order: 1; }
.timeline { order: 2;
border-right: none; border-top: 1px solid #ddd;
padding-right: 0; padding-top: 1rem; min-height: 0; }
}
/* never let dynamically-appended <audio> sinks (one per remote
* speaker) render their default ~300px control strip — they're just
* sinks for the WebRTC track, no UI required */
audio { display: none; }
h1 {
font-family: 'chunkfiveregular', serif;
font-size: 3rem; font-weight: normal; letter-spacing: 0.02em;
@ -447,26 +459,43 @@ const SFU_BASE = (new URLSearchParams(location.search).get('sfu')
let sfuPubPC = null, sfuPubPeerID = null;
let sfuSubPC = null, sfuSubPeerID = null, sfuSubEvents = null;
/* SFU forwarded tracks whose publisher pubkey we couldn't match to a room
* member yet — ontrack fires from RTP; the signal's peer-joined may arrive
* a beat later. Without queueing, the host's rejoined audio would be lost. */
const sfuPendingTracks = new Map(); // pubHex -> MediaStream
/* SFU MediaStream cache keyed by PUBLISHER pubkey hex (= streamID set by the
* SFU's TrackLocal). Survives across host leave/rejoin: when a speaker drops
* we tear their audio element, but Pion often REUSES the transceiver on
* their rejoin so ontrack doesn't fire a second time — the same MediaStream
* object keeps receiving new RTP under the hood. By caching by pubkey we
* can re-attach that same stream to a fresh audio element on peer-joined
* even when no fresh ontrack event arrives. */
const sfuStreamsByPubHex = new Map(); // pubHex -> MediaStream
function attachSfuTrack(uuid, stream){
let a = remoteAudio.get(uuid);
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
a.srcObject = stream;
/* programmatic play in case autoplay policy needs the nudge after a track
* swap — caller already had a user gesture (entered the space) */
try { const p = a.play(); if (p && p.catch) p.catch(()=>{}); } catch(_){}
stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || shortHex(uuid)));
}
function flushSfuPendingTracks(){
if (!sfuPendingTracks.size) return;
for (const [pubHex, stream] of [...sfuPendingTracks]){
/* If we already have a cached SFU stream for this member's pubkey (from a
* prior subscribe-side ontrack), attach it. Used on peer-joined / host
* promotions / room state updates so a rejoined speaker's audio reattaches
* without needing the SFU to emit a fresh ontrack. */
function attachCachedSfuStreamFor(uuid){
const mm = members.get(uuid);
if (!mm || !mm.pubkey) return;
let pubHex;
try { pubHex = hex(unb64(mm.pubkey)); } catch(_){ return; }
const stream = sfuStreamsByPubHex.get(pubHex);
if (stream) attachSfuTrack(uuid, stream);
}
function flushSfuStreams(){
for (const [pubHex, stream] of sfuStreamsByPubHex){
for (const [uuid, mm] of members){
try {
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
attachSfuTrack(uuid, stream);
sfuPendingTracks.delete(pubHex);
if (!remoteAudio.has(uuid)) attachSfuTrack(uuid, stream);
break;
}
} catch(_){}
@ -513,15 +542,18 @@ async function sfuSubscribe(){
pc.ontrack = (ev) => {
const pubHex = ev.streams[0] ? ev.streams[0].id : '';
if (!pubHex) return;
/* try to match streamID (= publisher's pubkey hex) -> room uuid; if the
* member's not in our roster yet, queue the stream — flushSfuPendingTracks
* will pick it up on the next peer-joined / host-promoted / state event */
let matchUuid = null;
/* cache stream by publisher pubkey so it survives the member's session
* uuid changing across leave/rejoin — see flushSfuStreams */
sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
for (const [uuid, mm] of members){
try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ matchUuid = uuid; break; } } catch(_){}
try {
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
attachSfuTrack(uuid, ev.streams[0]);
return;
}
} catch(_){}
}
if (matchUuid){ attachSfuTrack(matchUuid, ev.streams[0]); }
else { sfuPendingTracks.set(pubHex, ev.streams[0]); }
/* no matching member yet — flushSfuStreams will attach on peer-joined */
};
/* server-initiated offer: POST /subscribe (empty body) — SFU answers with
* an SDP offer containing one m-line per current publisher. We answer it
@ -565,7 +597,7 @@ async function sfuSubscribe(){
async function sfuUnsubscribe(){
if (sfuSubEvents){ try { sfuSubEvents.close(); } catch(_){} sfuSubEvents = null; }
if (sfuSubPC){ try { sfuSubPC.close(); } catch(_){} sfuSubPC = null; sfuSubPeerID = null; }
sfuPendingTracks.clear();
sfuStreamsByPubHex.clear();
}
/* ==================================================================
@ -773,7 +805,7 @@ async function handleSignal(raw){
renderRoom();
break;
case 'state':
roomEpoch = m.epoch; applyState(m.state); flushSfuPendingTracks(); renderRoom(); break;
roomEpoch = m.epoch; applyState(m.state); flushSfuStreams(); renderRoom(); break;
case 'peer-joined':
members.set(m.uuid, { uuid:m.uuid, pubkey:m.pubkey, handle:m.handle, role:m.role, joined_at: Date.now()/1000 });
logLine('', m.handle+' joined as '+m.role);
@ -785,7 +817,7 @@ async function handleSignal(raw){
}
/* a new member may resolve a queued SFU track (e.g. host's rejoin
* race where ontrack fired before peer-joined) */
flushSfuPendingTracks();
flushSfuStreams();
/* establish mesh PC if both us and them are speakers (or mods).
* present-member-offers: existing speaker offers when a new speaker
* arrives. deterministic by uuid string compare. */
@ -898,7 +930,7 @@ async function handleSignal(raw){
if (m.new_host_uuid === myUUID){ myRole = 'host'; setStatus('connected as host','ok'); onRoleChanged('cohost','host'); }
else { setStatus('connected as '+myRole, 'ok'); } /* clears the space-closing warning */
logLine('', (mm.handle||shortHex(m.new_host_uuid))+' is now host'); }
flushSfuPendingTracks();
flushSfuStreams();
renderRoom();
}
break;
@ -1251,8 +1283,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-05-31</span><br>
md5 <span class="stamp-md5">b6cc79823e4897e91fd798b328bac81d</span><br>
sha256 <span class="stamp-sha">cf0360f7b16465f256b22bd6c8f44790d909bc933fd9993a890c21137916b4e3</span><br>
md5 <span class="stamp-md5">4dc64b26953ef97c743bc54fb9a084d5</span><br>
sha256 <span class="stamp-sha">ef1848a02178345ebe976ce392a049eaa17aab5c7d3f1d6291db1575b4acb126</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>