diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index f9cad31..3a6389a 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -4050,23 +4050,47 @@ const streamAudio = new Map(); // uuid -> pulling stream function streamUrlFor(pubHex){ return SFU_BASE + '/stream?room=' + encodeURIComponent(roomID) + '&pub=' + pubHex; } -function startStream(uuid, pubHex){ +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 * passed) we'd be left with zero audio. Mute only after 'playing' * fires so the WebRTC path stays as the live fallback. */ + const wantUrl = streamUrlFor(pubHex); let a = streamAudio.get(uuid); + /* Idempotent: a second toggle-on for the same pubHex while we're + * already loading/playing is a no-op. The previous behaviour reset + * .src which aborted the in-flight load — Firefox surfaces this as + * 'fetching process aborted at user's request' on the play() + * promise, which we then mistakenly logged as autoplay-blocked. */ + if (a && a.src === wantUrl && !a.error) { + return; + } if (!a){ a = document.createElement('audio'); a.autoplay = true; a.controls = false; + /* preload=auto: encourage the browser to start buffering immediately + * instead of waiting for play() — helps slow mobile links fill + * the initial buffer before the user-gesture window expires. + * playsInline: tell Safari/iOS not to escalate audio playback into + * a fullscreen player. No effect on Firefox/Chrome but cheap. */ + a.preload = 'auto'; a.playsInline = true; document.body.appendChild(a); streamAudio.set(uuid, a); - applySinkTo(a); + /* setSinkId is async and can re-init the media pipeline. If we + * call it AFTER setting src + play(), the re-init aborts the load + * with the abort error fox saw. Await it FIRST, before any src + * assignment. Safe to await on browsers that don't support it — + * applySinkTo early-returns. */ + await applySinkTo(a); } a.muted = false; - a.src = streamUrlFor(pubHex); + a.src = wantUrl; /* mute WebRTC the moment the HTTP stream actually plays a frame, - * not before. Removed on stop / error so WebRTC takes over again. */ + * not before. Removed on error / autoplay reject so WebRTC takes + * over again. NOTE: 'stalled' is NOT treated as a failure — it + * fires constantly during normal startup on mobile cellular and + * each fire was causing the audio path to flip-flop. Real failure + * shows up as 'error' or as a rejected play() promise. */ const muteWebRtcOnPlay = () => { const w = remoteAudio.get(uuid); if (w) try { w.muted = true; } catch(_){} @@ -4078,7 +4102,6 @@ function startStream(uuid, pubHex){ }; a.addEventListener('playing', muteWebRtcOnPlay, { once: true }); a.addEventListener('error', () => unmuteWebRtcOnFail('error'), { once: true }); - a.addEventListener('stalled', () => unmuteWebRtcOnFail('stalled')); try { const p = a.play(); if (p && p.then){ @@ -4756,9 +4779,9 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');