zebra-report: listener mute button doubles as audio-unlock gesture
Mirror of zebra-report 334ee82.
This commit is contained in:
parent
995a9b230e
commit
fb1fabc810
5 changed files with 101 additions and 61 deletions
|
|
@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1
|
|||
</p>
|
||||
|
||||
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-03</span><br>
|
||||
md5 <span class="stamp-md5">24217bcab41f06970625faecdc0723e2</span><br>
|
||||
sha256 <span class="stamp-sha">9dda09c0e6405bd71a611c12722235ba1188cd11679575763a3c67d708ff3cc5</span><br>
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">e4e8e782260db19486c05c042f4f1afc</span><br>
|
||||
sha256 <span class="stamp-sha">0d6678f32e1c4dcc0dd3adcb052b4b0cc5dc86fb6f2dbc5c9c79775e3feb683e</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 it against these hashes; point it at your own servers with ?signal= and ?turncred=</span>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -341,9 +341,9 @@ try {
|
|||
</p>
|
||||
|
||||
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-03</span><br>
|
||||
md5 <span class="stamp-md5">e84a8aec0fd1558d63501f776929d883</span><br>
|
||||
sha256 <span class="stamp-sha">5d5258d99c37a5a8236bdb779971735f1c59d53ec0154e69dd406673f44c9a89</span><br>
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">9870bd4e4dae3439f3238f6c7bfbb0e1</span><br>
|
||||
sha256 <span class="stamp-sha">7bf54074bc06b6a9378d75cfc678aadf626c2c2e921149db5610a7e0ed86ef6a</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 it against these hashes; point it 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>
|
||||
|
|
|
|||
|
|
@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
|
|||
})();
|
||||
</script>
|
||||
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-03</span><br>
|
||||
md5 <span class="stamp-md5">15478b0cdc5ae94c6ddc9c625443058f</span><br>
|
||||
sha256 <span class="stamp-sha">793f3811124cba596f54f48ae11fab805ecf670991669002542b8ee8fdefc6f6</span><br>
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">b7b1b1b9c612636a17adf94eca5835b5</span><br>
|
||||
sha256 <span class="stamp-sha">7dff2ae15621b8c56d4c0c5a50afe5b3305317a161b281d66c2417c3b1cbf1a6</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 it against these hashes; point it 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>
|
||||
|
|
|
|||
|
|
@ -747,9 +747,9 @@ else wirePuppet();
|
|||
</section>
|
||||
|
||||
<footer style="max-width:560px;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> · built <span class="stamp-date">2026-06-03</span><br>
|
||||
md5 <span class="stamp-md5">e9a723dc188a32c1cebccc01bdaa3191</span><br>
|
||||
sha256 <span class="stamp-sha">822e6a4cba5887f70af6b8b4350bb87c5599292ff019f6f24f89672195a2725d</span><br>
|
||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">704bc6f8c294b01704add1b59ec23d69</span><br>
|
||||
sha256 <span class="stamp-sha">06b739c56ebdfab90591ea4c5e8d8cb6b5cd854c02f526753f724857c518a64e</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 it against these hashes; point it 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>
|
||||
|
|
|
|||
|
|
@ -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 -> <audio> pulling stream
|
||||
/* per-peer audio path: 'dj' = HTTP Ogg <audio> 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 <audio> 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 <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. */
|
||||
/* 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');
|
|||
|
||||
<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> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">b8196a33fa6be70b8e72cd07172be78e</span><br>
|
||||
sha256 <span class="stamp-sha">a9b3eba52dc56bad81abc32bcc36f51f5441a9b113dccc96d0f0e67f9fb3520c</span><br>
|
||||
md5 <span class="stamp-md5">86588d00e3993870a95504ac16973b26</span><br>
|
||||
sha256 <span class="stamp-sha">1e8dc752014189b0ba6e45bad1981950079700e3cf8da33f15560c006f4a314e</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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue