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');