zebra-spaces: stream-toggle reliability — await sinkId, drop stalled handler, idempotent

Fox saw repeated "stream stalled" and "autoplay blocked: aborted at
user's request" log lines on both auto-enrolled mobile listeners and
host self-monitor clicks. Three coupled causes:

1. applySinkTo() was called sync-fire-and-forget right before setting
   .src and calling .play(). setSinkId() can re-init the media
   pipeline; when it landed during the in-flight load it aborted the
   request — Firefox surfaces that as
   "The fetching process for the media resource was aborted by the
   user agent at the user's request." on the play() promise. We
   wrongly logged that as autoplay-blocked. Fix: await applySinkTo()
   BEFORE assigning .src.

2. The 'stalled' event handler called unmuteWebRtcOnFail every time
   it fired. 'stalled' fires constantly during normal mobile-cellular
   buffering and isn't a terminal failure. Each fire ping-ponged the
   audio path between WebRTC (unmuted) and the still-loading HTTP
   stream. Drop the handler — only 'error' and a rejected play()
   promise indicate real failure.

3. A second toggle-on for the same pubHex (auto-enroll race when
   peer-joined fires during initial enrol) reassigned .src on the
   same <audio> element, aborting the prior load with the same abort
   error. Make startStream idempotent: if the element already has
   our wantUrl and no .error, return immediately.

Plus two cheap mobile-friendly attrs on the dynamic <audio>:
  - preload="auto" so buffering starts before play() (gives the
    user-gesture window time to last past the initial fill)
  - playsInline so iOS/Safari doesn't escalate to a fullscreen player

startStream is now async — callers (toggleStreamFor,
autoEnableDjModeForListener) fire-and-forget the returned promise,
which is fine since all error paths are already caught inside.

Tests green (83 fsm + 16 zebra-spaces).
This commit is contained in:
Russell Ballestrini 2026-06-03 20:06:36 -04:00
parent a3c49f1a28
commit d9198727d9
No known key found for this signature in database

View file

@ -4050,23 +4050,47 @@ const streamAudio = new Map(); // uuid -> <audio> 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 <audio>'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');
</script>
<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-03</span><br>
md5 <span class="stamp-md5">14406e1c86dd563732f048cef3cf4cbb</span><br>
sha256 <span class="stamp-sha">9b7b9f6b416b367475d3c1bf181acafbb684589ed18188fcb14b29927320c940</span><br>
<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">a0d7d8646e0dcec5b7563cc20ee69ade</span><br>
sha256 <span class="stamp-sha">712548a89b0e0ebfa299b91a360ef0a75128e5f35d17b2ffd7519bf0ff4423fa</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>