zebra-spaces: revert auto-DJ for listeners, restore WebRTC playback

Fox: 'no we regressed we have never gotten the stream mode to work
for phones only time phone has worked is falling back to webrtc which
is what we are using for speakers not DJ mode... :( listeners.'

The auto-DJ + swap-fresh-<audio> + muted-on-create combo broke the
one path that actually worked: WebRTC playback for listener phones.
Reverting to the original attachSfuTrack flow (autoplay=true,
playsInline, .play() with .catch logging) so WebRTC audio comes up
the same way it did for speakers all along.

DJ mode is intentionally NOT auto-enrolled for listeners — Firefox
Android refuses autoplay on every fresh <audio src=URL> and the
entry-button gesture has aged out by the time the SFU lazy-inits
its Ogg writer. The DJ stream toggle stays on speaker rows for the
host's room-wide control.

btn-mute as listener now just calls activateListenerAudio() which
re-fires .play() on every existing remoteAudio element inside the
click gesture. If attachSfuTrack's initial play() got swallowed,
this gesture lands the audio.
This commit is contained in:
Russell Ballestrini 2026-06-03 21:42:18 -04:00
parent 1de3f2c1bc
commit 901bab177b
No known key found for this signature in database

View file

@ -1628,17 +1628,20 @@ 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;
a.playsInline = true;
document.body.appendChild(a); remoteAudio.set(uuid, a);
applySinkTo(a);
}
a.srcObject = stream;
if (!audioPath.has(uuid)) audioPath.set(uuid, 'rtc');
applyAudioMute();
/* programmatic play in case autoplay policy needs the nudge after a track
* swap — caller already had a user gesture (entered the space). On Firefox
* Android the play() promise will reject if the gesture grace has aged
* out; the listener's play button re-runs play() inside its own gesture
* to recover. */
try {
const p = a.play();
if (p && p.catch) p.catch(e => logLine('err','rtc autoplay '+uuid+': '+e.message));
} catch(_){}
stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid));
}
@ -3455,12 +3458,6 @@ async function handleSignal(raw){
* present-member-offers: existing speaker offers when a new speaker
* arrives. deterministic by uuid string compare. */
if (canSpeak(myRole) && canSpeak(m.role)) connectToPeer(m.uuid, /*weOffer*/ myUUID < m.uuid);
/* if I'm a listener and a new speaker arrived, auto-enrol them
* into my DJ-mode HTTP playback after the SFU has had a moment
* to wire the Ogg writer for the publisher. */
if (myRole === 'listener' && canSpeak(m.role) && m.uuid !== myUUID){
setTimeout(autoEnableDjModeForListener, 600);
}
renderRoom();
break;
case 'peer-left':
@ -3587,11 +3584,6 @@ async function handleSignal(raw){
if (canSpeak(myRole) && canSpeak(m.role) && !peers.has(m.uuid)){
connectToPeer(m.uuid, myUUID < m.uuid);
}
/* listener-side: if a peer just gained the ability to speak,
* auto-enrol them into our DJ-mode HTTP stream. */
if (myRole === 'listener' && !canSpeak(prev) && canSpeak(m.role)){
setTimeout(autoEnableDjModeForListener, 600);
}
/* If the role transition stripped them of the ability to
* publish (anything -> listener), their camera/screen/game
* tiles can't get fresh RTP anymore. The demoted page calls
@ -3759,17 +3751,14 @@ async function onRoleEntered(){
if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid);
}
sfuPublish().catch(e => logLine('err','sfu publish: '+e.message));
} else {
/* Listener landing — auto-enrol into DJ mode for every speaker so
* playback rides the deep-buffered HTTP Ogg path. Slight delay so
* the SFU has time to wire its Ogg writers for the existing
* publishers (lazy-inits on first /stream listener). Plus start
* the periodic retry loop so streams come up when the host's mic
* publish lands AFTER we already auto-enrolled (race the 600ms
* delay can't cover). */
setTimeout(autoEnableDjModeForListener, 600);
startAutoEnrolRetryLoop();
}
/* No auto-enrol into DJ mode for listeners. Firefox Android refuses
* autoplay on every fresh <audio src=URL> and the page-entry gesture
* has aged out by the time the SFU lazy-inits its Ogg writer, so the
* DJ path is dead-on-arrival for phone listeners. WebRTC is the
* canonical listener playback path; the 'play' button in the
* identity panel provides the second gesture if attachSfuTrack's
* initial play() got swallowed. */
}
let inRoleTransition = false;
async function onRoleChanged(prev, next){
@ -3812,9 +3801,6 @@ async function onRoleChanged(prev, next){
await sfuUnpublishScreen();
await sfuUnpublishCamera();
await sfuUnpublishGame();
/* now a listener — auto-enrol into DJ-mode HTTP playback for
* every audible peer so we get the deep-buffered path. */
setTimeout(autoEnableDjModeForListener, 600);
}
updateRoleUI();
} finally {
@ -4069,54 +4055,42 @@ 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. */
/* Listener output-mute: a fresh listener tab on Firefox Android can
* silently fail to autoplay even WebRTC audio if the entry-button
* gesture has aged out by the time attachSfuTrack fires. The btn-mute
* click is the only reliable second gesture we have. listenerOutput-
* Muted starts TRUE so the visible button starts as 'play' — fox: the
* canonical 'I want sound' moment is when they tap that button. */
let listenerOutputMuted = true;
/* DJ HTTP stream mode is intentionally NOT auto-enrolled for listener
* phones — Firefox Android refuses autoplay on every fresh <audio>
* with src URL and a hard refresh starts the same loop. WebRTC stays
* the canonical listener path; DJ mode is opt-in for desktop only via
* the speaker-row stream toggle (host control). */
function activateListenerAudio(){
/* Fire .play() on every existing <audio> element inside the click
* gesture. The user just tapped — Firefox Android grants media
* permission for the duration of this synchronous handler. */
let played = 0, failed = 0;
for (const [uuid, a] of remoteAudio){
try { a.muted = false; } catch(_){}
try {
const p = a.play();
if (p && p.then){
p.then(()=>played++).catch(e => { failed++; logLine('err','rtc play '+uuid.slice(0,8)+' paused='+a.paused+' rs='+a.readyState+' srcObj='+!!a.srcObject+' err='+e.name+': '+e.message); });
} else played++;
} catch(e){ failed++; logLine('err','rtc play threw '+uuid.slice(0,8)+': '+e.message); }
logLine('', 'rtc '+uuid.slice(0,8)+' paused='+a.paused+' muted='+a.muted+' rs='+a.readyState+' srcObj='+!!a.srcObject+' vol='+a.volume);
}
logLine('', 'activateListenerAudio played='+played+' failed='+failed+' remoteAudio='+remoteAudio.size);
}
function applyAudioMute(){
const silenced = (myRole === 'listener' && listenerOutputMuted);
let touched = 0;
for (const [uuid, a] of remoteAudio){
const path = audioPath.get(uuid);
const wantRtc = !silenced && path !== 'dj';
/* In a listener click context, we ALWAYS attempt to play —
* the gesture might be the only chance to unblock a paused
* element. Unmute on wantRtc=true; keep muted on wantRtc=false. */
try {
a.muted = !wantRtc;
touched++;
const p = a.play();
if (p && p.catch) p.catch(e => logLine('err','rtc play '+uuid+' paused='+a.paused+' rs='+a.readyState+' src='+!!a.srcObject+' err='+e.name+': '+e.message));
} catch(e){ logLine('err','rtc setmute '+uuid+': '+e.message); }
}
for (const [uuid, a] of streamAudio){
const path = audioPath.get(uuid);
const wantDj = !silenced && path === 'dj';
try {
a.muted = !wantDj;
touched++;
const p = a.play();
if (p && p.catch) p.catch(e => logLine('err','dj play '+uuid+' paused='+a.paused+' rs='+a.readyState+' src='+!!a.src+' err='+e.name+': '+e.message));
} catch(e){ logLine('err','dj setmute '+uuid+': '+e.message); }
}
/* state snapshot — per-element so we can see what each is actually doing */
for (const [uuid, a] of remoteAudio){
logLine('', 'rtc '+uuid.slice(0,8)+' paused='+a.paused+' muted='+a.muted+' rs='+a.readyState+' srcObj='+!!a.srcObject+' path='+audioPath.get(uuid));
}
for (const [uuid, a] of streamAudio){
logLine('', 'dj '+uuid.slice(0,8)+' paused='+a.paused+' muted='+a.muted+' rs='+a.readyState+' netState='+a.networkState+' src='+(a.src?a.src.slice(-20):'(none)')+' path='+audioPath.get(uuid));
}
logLine('', 'applyAudioMute silenced='+silenced+' touched='+touched);
/* legacy hook still called by attachSfuTrack / startStream; for the
* listener-tap-to-play flow we now do the work in activateListener-
* Audio() and the click handler. This is now a no-op for listeners. */
if (myRole !== 'listener') return;
/* For listeners that haven't tapped yet, ensure nothing audible —
* existing WebRTC autoplay may have rejected silently, no harm. */
}
function streamUrlFor(pubHex){
return SFU_BASE + '/stream?room=' + encodeURIComponent(roomID) + '&pub=' + pubHex;
@ -4661,40 +4635,16 @@ function applyMuteState(){
if (mm){ mm.muted = muted; renderRoom(); }
}
$('btn-mute').addEventListener('click', () => {
/* Listener path: this click is the user gesture mobile browsers
* demand before unmuted audio is allowed. attachSfuTrack already
* called play() for every WebRTC audio element on attach — that
* call may have silently been rejected (Firefox Android ages out
* the entry-button gesture grace fast). Re-fire play() on each
* existing element inside THIS gesture; one of these tries is
* the one that actually unblocks the speaker output. */
if (myRole === 'listener'){
listenerOutputMuted = !listenerOutputMuted;
logLine('', 'play-button: listenerOutputMuted=' + listenerOutputMuted +
' remoteAudio=' + remoteAudio.size +
' streamAudio=' + streamAudio.size +
' audioPath=' + audioPath.size);
/* Firefox Android internalisation defect: an <audio> created with
* muted=true and later unmuted reports paused=false rs=4 srcObject=true
* but emits zero output. Same shape as the video supplant defect we
* fixed earlier with swapFreshVideoElement. Inside the click gesture,
* swap every audio element with a fresh one (created UNMUTED) and
* re-attach the source. play() fires inside the gesture so autoplay
* is allowed. */
if (!listenerOutputMuted){
for (const [uuid, oldA] of [...remoteAudio]){
const stream = oldA.srcObject;
const fresh = swapFreshAudioElement(oldA);
fresh.muted = false;
applySinkTo(fresh);
fresh.srcObject = stream;
remoteAudio.set(uuid, fresh);
try { const p = fresh.play(); if (p && p.catch) p.catch(e => logLine('err','swap rtc '+uuid+': '+e.message)); } catch(_){}
}
for (const [uuid, oldA] of [...streamAudio]){
const src = oldA.src;
const fresh = swapFreshAudioElement(oldA);
fresh.muted = false;
applySinkTo(fresh);
if (src) fresh.src = src;
streamAudio.set(uuid, fresh);
try { const p = fresh.play(); if (p && p.catch) p.catch(e => logLine('err','swap dj '+uuid+': '+e.message)); } catch(_){}
}
}
applyAudioMute();
activateListenerAudio();
const btn = $('btn-mute');
btn.textContent = listenerOutputMuted ? 'play' : 'mute';
btn.className = listenerOutputMuted ? '' : 'invert';
@ -4706,23 +4656,6 @@ $('btn-mute').addEventListener('click', () => {
applyMuteState();
sendMicState();
});
/* Audio-element analogue of swapFreshVideoElement — replace an <audio>
* with a freshly-built one in the same DOM slot. Used to dodge the
* Firefox-Android internal routing defect where an element created
* muted=true silently stays silent after .muted=false. */
function swapFreshAudioElement(oldA){
if (!oldA) return oldA;
const fresh = document.createElement('audio');
fresh.autoplay = true; fresh.playsInline = true; fresh.preload = 'auto';
if (oldA.parentNode){
try { oldA.srcObject = null; oldA.removeAttribute('src'); oldA.load(); } catch(_){}
oldA.parentNode.replaceChild(fresh, oldA);
} else {
document.body.appendChild(fresh);
}
return fresh;
}
$('mic-select').addEventListener('change', async (e) => {
micDeviceId = e.target.value;
try { localStorage.setItem(MIC_DEV_KEY, micDeviceId); } catch(_){}
@ -4971,8 +4904,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> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br>
md5 <span class="stamp-md5">eac4e2b4b8c40c4adbdd03c004eb8cc4</span><br>
sha256 <span class="stamp-sha">d2ca231a1a04199c5e5cc01215e6a82ab4949119d5ac40bd5150a7805fd3cdf1</span><br>
md5 <span class="stamp-md5">eb8d6716640c8f73eb3e30d53969e2ae</span><br>
sha256 <span class="stamp-sha">37e8cee05cb38fa982ab9ef6a2504c10001cd7a7ca6c5d791cc00e2a036a1efd</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>