zebra-spaces: prime audio on the entry-button click, drop tap-to-resume
The tap-anywhere-to-resume queue was a regression — users saw a "tap anywhere to start" message and tapping often did nothing, with no feedback about why. Replace with the obvious-in-hindsight approach: seize the user gesture from the entry button click itself. primeAudioOnGesture() plays a 1-frame silent WAV through a hidden <audio> element inside the entry click handler. Mobile Firefox / Safari treat that as gesture-driven audio playback and grant the page audio permission for the session. It also resumes any suspended AudioContext (meter / chime path) the same way. By the time auto-enrolment into DJ mode runs (async hops after entry), the page already has audio permission — dynamic <audio> elements created later play() without further user interaction. No "tap anywhere", no queue, no flip-flop on stalled. Bound to btn-enter click + rdv-code Enter key. Tap-to-resume queue and pendingAutoplay/installTapResume helpers removed. Tests green (83 fsm + 16 zebra-spaces).
This commit is contained in:
parent
e1ba62e7ac
commit
24bfc81ac0
1 changed files with 46 additions and 66 deletions
|
|
@ -4050,55 +4050,14 @@ const streamAudio = new Map(); // uuid -> <audio> pulling stream
|
|||
function streamUrlFor(pubHex){
|
||||
return SFU_BASE + '/stream?room=' + encodeURIComponent(roomID) + '&pub=' + pubHex;
|
||||
}
|
||||
/* Tap-to-resume queue: when mobile Firefox/Safari blocks autoplay
|
||||
* (auto-enrolment runs too far away from the entry-button gesture
|
||||
* for the activation window to still be alive), we stash the audio
|
||||
* element here and retry its play() on the first user touch anywhere
|
||||
* on the page. One global listener handles every queued stream at
|
||||
* once. Idempotent install. */
|
||||
const pendingAutoplay = new Set();
|
||||
let tapResumeInstalled = false;
|
||||
let tapResumeRetry = null;
|
||||
function uninstallTapResume(){
|
||||
if (!tapResumeInstalled || !tapResumeRetry) return;
|
||||
document.removeEventListener('pointerdown', tapResumeRetry);
|
||||
document.removeEventListener('touchstart', tapResumeRetry);
|
||||
document.removeEventListener('click', tapResumeRetry);
|
||||
tapResumeInstalled = false; tapResumeRetry = null;
|
||||
}
|
||||
function installTapResume(){
|
||||
if (tapResumeInstalled) return;
|
||||
tapResumeInstalled = true;
|
||||
tapResumeRetry = () => {
|
||||
if (!pendingAutoplay.size){ uninstallTapResume(); return; }
|
||||
/* The prior load was aborted by the browser when it denied
|
||||
* autoplay; the <audio> element is in error state. Re-load it
|
||||
* inside the gesture handler so play() has a fresh request to
|
||||
* attach to — bare a.play() on a stuck element silently no-ops. */
|
||||
for (const a of [...pendingAutoplay]){
|
||||
try { a.load(); } catch(_){}
|
||||
const p = a.play();
|
||||
if (p && p.then){
|
||||
p.then(() => {
|
||||
pendingAutoplay.delete(a);
|
||||
logLine('', 'stream resumed after tap');
|
||||
if (!pendingAutoplay.size) uninstallTapResume();
|
||||
}).catch(e => {
|
||||
logLine('err', 'stream tap-retry failed: '+(e && e.message || e));
|
||||
});
|
||||
} else {
|
||||
pendingAutoplay.delete(a);
|
||||
if (!pendingAutoplay.size) uninstallTapResume();
|
||||
}
|
||||
}
|
||||
};
|
||||
/* Listen on click in addition to pointer/touch — Firefox Android
|
||||
* sometimes denies gesture-activation on pointerdown alone and
|
||||
* only grants it on the click event after release. */
|
||||
document.addEventListener('pointerdown', tapResumeRetry, { passive: true });
|
||||
document.addEventListener('touchstart', tapResumeRetry, { passive: true });
|
||||
document.addEventListener('click', tapResumeRetry, { passive: true });
|
||||
}
|
||||
/* Audio activation now happens in the entry-button click handler via
|
||||
* primeAudioOnGesture() — see below the leave/entry section. By the
|
||||
* time auto-enrolment fires we already have the page's audio
|
||||
* permission granted, so dynamic <audio> elements can play() without
|
||||
* further user interaction. The prior tap-anywhere-to-resume queue
|
||||
* is gone: it was a regression because (a) it didn't actually fix
|
||||
* playback in many cases and (b) it added user-visible noise where
|
||||
* none should exist. */
|
||||
async function startStream(uuid, pubHex){
|
||||
/* DON'T mute the WebRTC audio yet — if the fresh <audio>'s autoplay
|
||||
* is blocked (common on mobile after the entry gesture's grace has
|
||||
|
|
@ -4155,19 +4114,7 @@ async function startStream(uuid, pubHex){
|
|||
const p = a.play();
|
||||
if (p && p.then){
|
||||
p.then(() => logLine('', 'stream on for '+pubHex.slice(0,12)+' — DJ mode (~2s delay, glitch-free)'))
|
||||
.catch(e => {
|
||||
/* mobile Firefox / Safari block autoplay when the gesture
|
||||
* activation window has expired between entry-click and
|
||||
* auto-enrolment. Don't permanently fall back to WebRTC —
|
||||
* queue this element for tap-to-resume so the next tap
|
||||
* anywhere on the page retries play(). */
|
||||
pendingAutoplay.add(a);
|
||||
installTapResume();
|
||||
logLine('', 'stream for '+pubHex.slice(0,12)+' — tap anywhere to start ('+e.message+')');
|
||||
/* Keep WebRTC audible as the fallback while waiting for a tap. */
|
||||
const w = remoteAudio.get(uuid);
|
||||
if (w) try { w.muted = false; } catch(_){}
|
||||
});
|
||||
.catch(e => unmuteWebRtcOnFail('autoplay blocked: '+e.message));
|
||||
} else {
|
||||
logLine('', 'stream on for '+pubHex.slice(0,12)+' — DJ mode (~2s delay, glitch-free)');
|
||||
}
|
||||
|
|
@ -4737,8 +4684,41 @@ document.addEventListener('visibilitychange', () => {
|
|||
/* ==================================================================
|
||||
* leave / entry buttons
|
||||
* ================================================================== */
|
||||
$('btn-enter').addEventListener('click', joinSpace);
|
||||
$('rdv-code').addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); joinSpace(); } });
|
||||
/* Audio activation: mobile Firefox / Safari require a user gesture to
|
||||
* allow <audio> playback. The auto-enrolment into DJ-stream happens
|
||||
* many async hops after the entry click, well past the gesture
|
||||
* window — so audio.play() rejects with autoplay-block.
|
||||
*
|
||||
* Fix: during the entry click handler, play a 1-frame silent buffer
|
||||
* through a hidden <audio>. That counts as gesture-driven audio
|
||||
* playback and on most mobile browsers also resumes any suspended
|
||||
* AudioContext, granting the page audio permission for the session.
|
||||
* Subsequent dynamic <audio> elements (the actual DJ streams) can
|
||||
* then play() without further user interaction.
|
||||
*
|
||||
* Tiny WAV header + 1 sample of silence — the smallest valid PCM
|
||||
* audio resource a browser will accept. Base64 ~80 bytes. */
|
||||
const SILENCE_WAV = 'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQIAAAAAAA==';
|
||||
let audioPrimed = false;
|
||||
function primeAudioOnGesture(){
|
||||
if (audioPrimed) return;
|
||||
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(() => { audioPrimed = true; })
|
||||
.catch(()=>{ /* still try the rest of join */ });
|
||||
/* 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(_){}
|
||||
}
|
||||
$('btn-enter').addEventListener('click', () => { primeAudioOnGesture(); joinSpace(); });
|
||||
$('rdv-code').addEventListener('keydown', e=>{
|
||||
if(e.key==='Enter'){ e.preventDefault(); primeAudioOnGesture(); joinSpace(); }
|
||||
});
|
||||
|
||||
/* ?code=… autofills the rendezvous code (used by the share URL). The code
|
||||
* stays in the URL so a refresh keeps you in the same space; if you want
|
||||
|
|
@ -4841,8 +4821,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-04</span><br>
|
||||
md5 <span class="stamp-md5">36ae23a6e8baa61fba8cf6fe48d1c45b</span><br>
|
||||
sha256 <span class="stamp-sha">ef709e213eaad4024f34fc231c804780632207d5b9dd6d1a9a18c9e0eb4b053b</span><br>
|
||||
md5 <span class="stamp-md5">b8196a33fa6be70b8e72cd07172be78e</span><br>
|
||||
sha256 <span class="stamp-sha">a9b3eba52dc56bad81abc32bcc36f51f5441a9b113dccc96d0f0e67f9fb3520c</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