diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 500c612..760af78 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -4050,55 +4050,14 @@ const streamAudio = new Map(); // uuid -> 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 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 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 '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 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 . 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 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 . */ + 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');