diff --git a/web/chat.html b/web/chat.html
index d0af6ea..ddce7e2 100644
--- a/web/chat.html
+++ b/web/chat.html
@@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
})();
- page integrity · built 2026-06-03
- md5 15478b0cdc5ae94c6ddc9c625443058f
- sha256 793f3811124cba596f54f48ae11fab805ecf670991669002542b8ee8fdefc6f6
+ page integrity · built 2026-06-04
+ md5 b7b1b1b9c612636a17adf94eca5835b5
+ sha256 7dff2ae15621b8c56d4c0c5a50afe5b3305317a161b281d66c2417c3b1cbf1a6
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or host your own community
diff --git a/web/host-your-own.html b/web/host-your-own.html
index 9385b5c..4355e63 100644
--- a/web/host-your-own.html
+++ b/web/host-your-own.html
@@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1
- page integrity · built 2026-06-03
- md5 24217bcab41f06970625faecdc0723e2
- sha256 9dda09c0e6405bd71a611c12722235ba1188cd11679575763a3c67d708ff3cc5
+ page integrity · built 2026-06-04
+ md5 e4e8e782260db19486c05c042f4f1afc
+ sha256 0d6678f32e1c4dcc0dd3adcb052b4b0cc5dc86fb6f2dbc5c9c79775e3feb683e
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=
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 {
- page integrity · built 2026-06-03
- md5 e84a8aec0fd1558d63501f776929d883
- sha256 5d5258d99c37a5a8236bdb779971735f1c59d53ec0154e69dd406673f44c9a89
+ page integrity · built 2026-06-04
+ md5 9870bd4e4dae3439f3238f6c7bfbb0e1
+ sha256 7bf54074bc06b6a9378d75cfc678aadf626c2c2e921149db5610a7e0ed86ef6a
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or host your own community
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();
- page integrity · built 2026-06-03
- md5 e9a723dc188a32c1cebccc01bdaa3191
- sha256 822e6a4cba5887f70af6b8b4350bb87c5599292ff019f6f24f89672195a2725d
+ page integrity · built 2026-06-04
+ md5 704bc6f8c294b01704add1b59ec23d69
+ sha256 06b739c56ebdfab90591ea4c5e8d8cb6b5cd854c02f526753f724857c518a64e
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or host your own community
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');
page integrity · built 2026-06-04
- md5 b8196a33fa6be70b8e72cd07172be78e
- sha256 a9b3eba52dc56bad81abc32bcc36f51f5441a9b113dccc96d0f0e67f9fb3520c
+ md5 86588d00e3993870a95504ac16973b26
+ sha256 1e8dc752014189b0ba6e45bad1981950079700e3cf8da33f15560c006f4a314e
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or host your own community