From 334ee82d6bbc44ea252e78171cb708a2170609cf Mon Sep 17 00:00:00 2001
From: Russell Ballestrini
Date: Wed, 3 Jun 2026 20:57:09 -0400
Subject: [PATCH] zebra-spaces: listener mute button doubles as audio-unlock
gesture
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fox 2026-06-04, after pulling CLIENT_LOG from the proxy:
stream for 25cc0aaf7eb0 autoplay blocked: ... — staying on live WebRTC
autoplay blocked: ... — tap the tile to play
Firefox Android refuses .play() on every
diff --git a/web/how-it-works.html b/web/how-it-works.html
index 83f40f4..d04e551 100644
--- a/web/how-it-works.html
+++ b/web/how-it-works.html
@@ -341,9 +341,9 @@ try {
diff --git a/web/zebra-audio.html b/web/zebra-audio.html
index 64fe765..3acec16 100644
--- a/web/zebra-audio.html
+++ b/web/zebra-audio.html
@@ -747,9 +747,9 @@ else wirePuppet();
diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 760af78..f692ee6 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -1628,13 +1628,17 @@ function attachSfuTrack(uuid, stream){
let a = remoteAudio.get(uuid);
if (!a){
a = document.createElement('audio'); a.autoplay = true;
+ /* always create muted=true so autoplay is universally allowed
+ * (muted autoplay is unconditional in all browsers). applyAudio-
+ * Mute() flips muted=false the moment we have a user gesture
+ * (btn-mute click) AND this peer's canonical path is 'rtc'. */
+ a.muted = true;
document.body.appendChild(a); 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) */
- try { const p = a.play(); if (p && p.catch) p.catch(()=>{}); } catch(_){}
+ if (!audioPath.has(uuid)) audioPath.set(uuid, 'rtc');
+ applyAudioMute();
stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid));
}
@@ -3847,6 +3851,18 @@ function updateScreenShareUI(){
function updateRoleUI(){
$('sec-listener-actions').classList.toggle('hidden', myRole !== 'listener');
updateScreenShareUI();
+ /* Listener mute-button repurposed as the audio-unlock gesture +
+ * local output toggle. Enabled regardless of micStream so a
+ * listener can tap it to provide the user gesture mobile browsers
+ * demand before unmuted audio is allowed. Speakers/cohosts/host
+ * still get the original self-mic-mute behavior (enabled by
+ * ensureMicAndUI once the mic is live). */
+ const btn = $('btn-mute');
+ if (myRole === 'listener'){
+ btn.disabled = false;
+ btn.textContent = listenerOutputMuted ? 'play' : 'mute';
+ btn.className = listenerOutputMuted ? '' : 'invert';
+ }
}
/* ==================================================================
@@ -4047,6 +4063,37 @@ function rankOf(role){ return {host:4, cohost:3, speaker:2, listener:1}[role] ||
* by pubHex (not uuid) so it survives session-uuid churn on rejoin. */
const streamMode = new Set(); // pubHex strings currently streaming
const streamAudio = new Map(); // uuid -> pulling stream
+/* per-peer audio path: 'dj' = HTTP Ogg is the canonical
+ * playback, 'rtc' = the WebRTC remoteAudio element is. applyAudioMute()
+ * reads this to decide which side gets unmuted under the listener
+ * output-mute toggle. Updated by startStream / stopStream / muteWebRtc-
+ * OnPlay / stream-error fallback. */
+const audioPath = new Map(); // uuid -> 'dj' | 'rtc'
+/* Listener output-mute: the page starts every muted on
+ * mobile so autoplay is allowed (muted autoplay is universally
+ * permitted; unmuted is gated behind a user gesture on Firefox
+ * Android / iOS Safari). The btn-mute click — the user gesture —
+ * flips this to false and unmutes whichever path is canonical for
+ * each peer. Default true so a fresh listener tab sits silent
+ * until tapped. */
+let listenerOutputMuted = true;
+function applyAudioMute(){
+ const silenced = (myRole === 'listener' && listenerOutputMuted);
+ for (const [uuid, a] of remoteAudio){
+ const wantRtc = !silenced && audioPath.get(uuid) !== 'dj';
+ try {
+ a.muted = !wantRtc;
+ if (wantRtc) { const p = a.play(); if (p && p.catch) p.catch(()=>{}); }
+ } catch(_){}
+ }
+ for (const [uuid, a] of streamAudio){
+ const wantDj = !silenced && audioPath.get(uuid) === 'dj';
+ try {
+ a.muted = !wantDj;
+ if (wantDj) { const p = a.play(); if (p && p.catch) p.catch(()=>{}); }
+ } catch(_){}
+ }
+}
function streamUrlFor(pubHex){
return SFU_BASE + '/stream?room=' + encodeURIComponent(roomID) + '&pub=' + pubHex;
}
@@ -4059,67 +4106,48 @@ function streamUrlFor(pubHex){
* 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
- * 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. */
+ /* Idempotent: second toggle-on for the same pubHex while we're
+ * already loading/playing is a no-op. Resetting .src abort the
+ * in-flight load — Firefox surfaces this as 'fetching aborted at
+ * user request' which we'd mistake for autoplay block. */
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;
+ /* start muted so autoplay is universally allowed (muted autoplay
+ * has no gesture requirement on any browser). applyAudioMute()
+ * unmutes the moment the user gesture lands (btn-mute click) AND
+ * this peer's canonical path is set to 'dj' by the 'playing'
+ * listener below. */
+ a.muted = true;
document.body.appendChild(a);
streamAudio.set(uuid, 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 = wantUrl;
- /* mute WebRTC the moment the HTTP stream actually plays a frame,
- * 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(_){}
+ const onPlaying = () => {
+ audioPath.set(uuid, 'dj');
+ applyAudioMute();
+ logLine('', 'stream on for '+pubHex.slice(0,12)+' — DJ mode (~2s delay, glitch-free)');
};
- const unmuteWebRtcOnFail = (why) => {
- const w = remoteAudio.get(uuid);
- if (w) try { w.muted = false; } catch(_){}
+ const onFail = (why) => {
+ audioPath.set(uuid, 'rtc');
+ streamMode.delete(pubHex); // allow next auto-enrol pass to retry
+ applyAudioMute();
logLine('err', 'stream for '+pubHex.slice(0,12)+' '+why+' — staying on live WebRTC');
};
- a.addEventListener('playing', muteWebRtcOnPlay, { once: true });
- a.addEventListener('error', () => unmuteWebRtcOnFail('error'), { once: true });
+ a.addEventListener('playing', onPlaying, { once: true });
+ a.addEventListener('error', () => onFail('error'), { once: true });
try {
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 => unmuteWebRtcOnFail('autoplay blocked: '+e.message));
- } else {
- logLine('', 'stream on for '+pubHex.slice(0,12)+' — DJ mode (~2s delay, glitch-free)');
- }
+ if (p && p.catch) p.catch(e => onFail('autoplay blocked: '+e.message));
} catch(e){
- unmuteWebRtcOnFail('play threw: '+e.message);
+ onFail('play threw: '+e.message);
}
}
function stopStream(uuid){
@@ -4128,9 +4156,8 @@ function stopStream(uuid){
try { a.pause(); } catch(_){}
try { a.removeAttribute('src'); a.load(); } catch(_){}
}
- /* unmute the WebRTC mic so conversation resumes */
- const w = remoteAudio.get(uuid);
- if (w) try { w.muted = false; } catch(_){}
+ audioPath.set(uuid, 'rtc');
+ applyAudioMute();
}
function toggleStreamFor(uuid, pubHex){
if (streamMode.has(pubHex)){
@@ -4586,6 +4613,19 @@ function applyMuteState(){
if (mm){ mm.muted = muted; renderRoom(); }
}
$('btn-mute').addEventListener('click', () => {
+ /* listener path: toggle output mute. The click itself is the
+ * user gesture mobile browsers wait for — even if listenerOutput-
+ * Muted was already false, applyAudioMute() will re-call play()
+ * inside this gesture and unblock any audio element that's been
+ * waiting. */
+ if (myRole === 'listener'){
+ listenerOutputMuted = !listenerOutputMuted;
+ applyAudioMute();
+ const btn = $('btn-mute');
+ btn.textContent = listenerOutputMuted ? 'play' : 'mute';
+ btn.className = listenerOutputMuted ? '' : 'invert';
+ return;
+ }
if (!micStream) return;
muted = !muted;
try { sessionStorage.setItem(MUTE_STATE_KEY, muted ? '1' : '0'); } catch(_){}
@@ -4821,8 +4861,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');