zebra-spaces: pre-bless audio-element pool on entry click

CLIENT_LOG confirmed the listener-phone autoplay rejection:

  rtc autoplay 72b026b4: The play method is not allowed by the user
    agent or the platform in the current context, possibly because
    the user denied permission.

attachSfuTrack runs several async hops after the entry-button click
(WS join → welcome → onRoleEntered → sfuSubscribe → SFU PC
negotiate → ontrack). Firefox Android's user-activation window has
aged out by then. Speakers dodge this because getUserMedia for the
mic counts as confirmed audio engagement; listeners have no
equivalent.

Fix: primeAudioOnGesture now also pre-creates a pool of 16 <audio>
elements and calls play() on each inside the entry-button gesture.
Firefox Android grants autoplay PER ELEMENT and the engagement bit
persists on the element after the click activation expires.
attachSfuTrack now leases from the pool (leaseAudioElement) instead
of creating fresh — the leased element is already blessed for
unmuted autoplay, so srcObject + play() succeed without a fresh
gesture. Pool falls back to fresh-element creation on exhaustion.
This commit is contained in:
Russell Ballestrini 2026-06-03 22:12:49 -04:00
parent 9eb5b82f7a
commit 44e05b23db
No known key found for this signature in database

View file

@ -1627,17 +1627,17 @@ const sfuStreamsByPubHex = new Map(); // pubHex -> MediaStream
function attachSfuTrack(uuid, stream){
let a = remoteAudio.get(uuid);
if (!a){
a = document.createElement('audio'); a.autoplay = true;
a.playsInline = true;
document.body.appendChild(a); remoteAudio.set(uuid, a);
/* lease from the pre-blessed audio pool so Firefox Android's
* per-element autoplay grant carries over from the entry-button
* gesture. attachSfuTrack runs after several async hops (WS join,
* SFU subscribe negotiation, ontrack) so the click activation
* window has expired by now — only a pre-played pool element
* still has the engagement bit set. */
a = leaseAudioElement();
remoteAudio.set(uuid, a);
applySinkTo(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). On Firefox
* Android the play() promise will reject if the gesture grace has aged
* out; the listener's play button re-runs play() inside its own gesture
* to recover. */
try {
const p = a.play();
if (p && p.catch) p.catch(e => logLine('err','rtc autoplay '+uuid+': '+e.message));
@ -4743,23 +4743,65 @@ const SILENCE_WAV = 'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAA
* after a successful first prime. Mobile browsers can suspend the
* audio session on tab background / leave; the next entry click needs
* a fresh play() to re-bless audio for the new session. */
/* Pool of audio elements pre-blessed for autoplay by the user gesture
* (entry button click). Firefox Android grants media autoplay
* permission PER ELEMENT, not per document — so attachSfuTrack creating
* a fresh <audio> later (after the gesture activation has aged out)
* gets its play() rejected with NotAllowedError. We pre-create a pool
* inside the click handler, call play() on each (succeeds inside the
* gesture), and attachSfuTrack consumes from the pool when a real
* stream arrives. The element has retained 'engagement' so setting
* srcObject and play() works without a fresh gesture. */
const AUDIO_POOL_SIZE = 16;
const audioPool = [];
function primeAudioOnGesture(){
/* legacy silent primer kept for AudioContext + chime engagement */
const a = document.createElement('audio');
a.src = SILENCE_WAV; a.muted = false; a.volume = 0;
a.style.display = 'none';
document.body.appendChild(a);
const p = a.play();
if (p && p.then) p.then(() => {
/* keep the element around briefly so the play() actually emits a
* frame; then yank it from the DOM to avoid a growing pile of
* primer elements after many entry clicks. */
setTimeout(() => { try { a.remove(); } catch(_){} }, 500);
}).catch(()=>{ try { a.remove(); } catch(_){} });
/* Also try resuming any existing AudioContext (meter / chime).
* resume() on an already-running context is a no-op, and on a
* suspended one it transitions to running — granting Web Audio
* playback permission alongside <audio>. */
try { if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume(); } catch(_){}
/* pre-bless a pool of audio elements for unmuted autoplay. Each one
* gets play() called inside this click handler — the gesture grants
* each element individual engagement that survives the click's
* activation window. attachSfuTrack later assigns srcObject onto a
* pool element instead of creating a fresh one. */
for (let i = 0; i < AUDIO_POOL_SIZE; i++){
const el = document.createElement('audio');
el.autoplay = true; el.playsInline = true;
el.style.display = 'none';
document.body.appendChild(el);
/* assign a 1-frame silent source so play() has something to chew
* on. The element retains engagement after this play(); subsequent
* srcObject= assignments inherit it. */
el.src = SILENCE_WAV;
try {
const pp = el.play();
if (pp && pp.catch) pp.catch(()=>{});
} catch(_){}
audioPool.push(el);
}
logLine('', 'audio pool primed: '+audioPool.length+' elements');
}
function leaseAudioElement(){
/* hand out a pre-blessed element from the pool; if the pool is dry,
* fall back to creating a fresh one (will probably fail autoplay
* on Firefox Android listeners but at least logs the rejection). */
if (audioPool.length > 0){
const el = audioPool.shift();
try { el.removeAttribute('src'); el.load(); } catch(_){}
return el;
}
logLine('err', 'audio pool exhausted — autoplay may fail');
const fresh = document.createElement('audio');
fresh.autoplay = true; fresh.playsInline = true;
fresh.style.display = 'none';
document.body.appendChild(fresh);
return fresh;
}
$('btn-enter').addEventListener('click', () => { primeAudioOnGesture(); joinSpace(); });
$('rdv-code').addEventListener('keydown', e=>{
@ -4880,8 +4922,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-04</span><br>
md5 <span class="stamp-md5">627a038a99648b35f87e7ad75f3d057a</span><br>
sha256 <span class="stamp-sha">a3ea7e91b1a8ed05a02414bd86ab4a4cd9968f8ec7f2bf9521f24ccc41783c29</span><br>
md5 <span class="stamp-md5">e8ab005d36f6cf2fb05d8064d8f15a97</span><br>
sha256 <span class="stamp-sha">8688b810622d9696cad31f3f4da127c2e8a7613ab12919b5a679d79887ca2ac3</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>