diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 68cd8a1..5ad8d17 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -1702,7 +1702,7 @@ const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 120000;
* consistent. If you need lower latency for actual
* conversation, drop this back to 0.7 and accept the
* occasional under-run. */
-const RECV_PLAYOUT_DELAY_SEC = 2.0;
+const RECV_PLAYOUT_DELAY_SEC = 4.0;
function watchVideoTrackForRemoval(track, removeFn, windowMs){
if (!track) return;
if (typeof windowMs !== 'number' || !isFinite(windowMs) || windowMs <= 0){
@@ -3437,6 +3437,12 @@ 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':
@@ -3563,6 +3569,11 @@ 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
@@ -3700,6 +3711,12 @@ 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). */
+ setTimeout(autoEnableDjModeForListener, 600);
}
}
let inRoleTransition = false;
@@ -3719,6 +3736,9 @@ async function onRoleChanged(prev, next){
* the acquire. */
muted = true;
try { sessionStorage.setItem(MUTE_STATE_KEY, '1'); } catch(_){}
+ /* coming out of listener — tear down every HTTP DJ-mode tap so
+ * the WebRTC audio path takes over (low-latency for conversation). */
+ autoDisableDjModeForAll();
await ensureMicAndUI();
for (const [uuid, mm] of members){
if (uuid === myUUID) continue;
@@ -3738,6 +3758,9 @@ 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 {
@@ -4012,6 +4035,47 @@ function toggleStreamFor(uuid, pubHex){
renderRoom();
}
+/* Default-on DJ mode for listeners: skip the WebRTC mic playback path
+ * for every audible peer and pull HTTP Ogg/Opus instead. Browser's
+ *