diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 74bdff3..6df764e 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -2323,6 +2323,14 @@ async function refreshLipSyncForUuid(uuid){
* use a fixed HTTP_STREAM_DELAY_SEC estimate (set by startStream).
* Apply it directly with the same threshold/hysteresis as the
* worklet path. */
+ /* Listener role: skip the video-receiver retarget entirely. Their
+ * tiles need to switch instantly when the user spotlights a
+ * different share — applying a 4s buffer to video defeats that.
+ * Audio stays at full cushion via the worklet; listener accepts
+ * mouth-leads-voice as the price of responsive switching.
+ * Fox 2026-06-05: "we should switch feeds immediately I don't
+ * want any algo slowing that down." */
+ if (myRole === 'listener') return;
const override = httpLipSyncOverride.get(pubHex);
if (typeof override === 'number'){
if (Math.abs(override - e.lastApplied) < LIP_SYNC_THRESHOLD) return;
@@ -3933,17 +3941,20 @@ function handleRemoteSfuTrack(ev){
if (kind === 'screen' || kind === 'camera' || kind === 'game'){
logLine('', 'sfu ontrack: kind=' + kind + ' pub=' + pubHex +
' track=' + ev.track.kind + ' mute=' + ev.track.muted + ' state=' + ev.track.readyState);
- /* Video receivers (screen / camera / game) match the audio receiver's
- * playoutDelayHint so the picture stays in sync with the voice. The
- * mic jitter buffer is 4s; without a matching hint on video, mouse
- * clicks / mouth movement / keystrokes lead the voice by ~4s. Audio
- * is more vital than video — video adapts to audio's delay, never
- * the other way around. */
- try { if (ev.receiver) ev.receiver.playoutDelayHint = playoutDelayForRole(myRole); } catch(_){}
- try { if (ev.receiver) ev.receiver.jitterBufferTarget = playoutDelayForRole(myRole) * 1000; } catch(_){}
- /* register for the dynamic lip-sync algorithm — the next worklet
- * 'buffered' message will recompute this video receiver's target
- * to match the audio's total delay (native jbuf + worklet). */
+ /* Video receivers: speakers/cohosts/hosts match audio's delay for
+ * lip-sync. Listeners get playoutDelayHint=0 (browser-lowest)
+ * so spotlight tile switches are instant — fox 2026-06-05: "we
+ * should switch feeds immediately I don't want any algo slowing
+ * that down." Tradeoff: listener mouths lead voice by ~audio
+ * cushion (1.3–4s adaptive). Acceptable for content-consumption
+ * mode where switching shares is more critical than per-syllable
+ * lip-sync. */
+ const vDelay = (myRole === 'listener') ? 0 : playoutDelayForRole(myRole);
+ try { if (ev.receiver) ev.receiver.playoutDelayHint = vDelay; } catch(_){}
+ try { if (ev.receiver) ev.receiver.jitterBufferTarget = vDelay * 1000; } catch(_){}
+ /* Register for lip-sync — but the refresh loop also skips video
+ * apply for listeners, so this is a no-op for listener tiles.
+ * Kept registered in case role changes mid-session (promote). */
if (ev.receiver) registerLipSyncVideo(pubHex, kind, ev.receiver);
}
/* MSID-supplant safety: the SFU re-uses the same streamID
@@ -5593,15 +5604,19 @@ function retargetAllReceivers(role){
try { node.jbuf.port.postMessage({ cmd: 'retarget', targetSeconds: target }); } catch(_){}
}
}
- /* SFU sub PC receivers — audio (the native side, downstream of which
- * the worklet sits) AND video (which sits directly on the receiver). */
+ /* SFU sub PC receivers. Audio gets the role-aware target (matching
+ * the worklet downstream). Video for listeners gets 0 — instant tile
+ * switching. Video for non-listeners matches audio. */
+ const vTarget = role === 'listener' ? 0 : target;
if (sfuSubPC && typeof sfuSubPC.getReceivers === 'function'){
for (const r of sfuSubPC.getReceivers()){
- try { r.playoutDelayHint = target; } catch(_){}
- try { r.jitterBufferTarget = target * 1000; } catch(_){}
+ const isVideo = r.track && r.track.kind === 'video';
+ const t = isVideo ? vTarget : target;
+ try { r.playoutDelayHint = t; } catch(_){}
+ try { r.jitterBufferTarget = t * 1000; } catch(_){}
}
}
- logLine('', 'retarget all receivers → '+target+'s (role='+role+')');
+ logLine('', 'retarget all receivers → aud='+target+'s vid='+vTarget+'s (role='+role+')');
}
async function onRoleChanged(prev, next){
@@ -7081,8 +7096,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');