zebra-spaces: spotlight tile DOM-move (instant switch, lip-sync intact)

Fox 2026-06-05: "listeners should still be synced it should just be
that the gesture to change feeds after they are all live should be
immediately to make that feed full sized... reverse your bs and do
what I asked."

Reverted the listener-video-no-delay shortcut. Listener video keeps
playoutDelayHint matched to audio (lip-sync preserved). The actual
fix for slow tile-switching: REUSE the existing thumb tile's <video>
element instead of building a fresh one when promoting to spotlight.

Before: setSpotlight built a brand-new <video> in #spotlight and
assigned the existing MediaStream as its srcObject. The fresh video
element had no decoder state — had to wait for the next keyframe
inside the receiver's 4s-deep buffer to show its first frame,
manifesting as up to 4s of "blank" on listener mobile.

After: setSpotlight DOM-moves the existing thumb tile (with its
already-playing <video> element) into #spotlight. The video element
NEVER stops — no keyframe wait, no re-buffer, no decode-init.
clearSpotlightDOM does the reverse: moves the spotlit tile back to
its thumbs container. Browser DOM-move on an HTMLMediaElement does
not reset playback state.

Supporting changes:

1. CSS: viewing-badge default-hidden; only visible in .tile-thumb.viewing
   context (which no longer exists post-DOM-move since the tile IS
   the spotlight, no separate thumb to mark).

2. CSS: .tile-thumb .fs-btn { display: none } — buildTile always
   includes the fullscreen button now (so we don't have to add it
   dynamically when moving thumb → spotlight). The button is hidden
   when the tile is in thumb context.

3. buildTile: always builds fsBtn (used to be !isThumb only).

4. setSpotlight: same-target click = no-op (idempotent); previous
   spotlight is moved back via clearSpotlightDOM before installing
   the new one.

5. The mid-spotlight supplant path (publisher republishes the same
   kind): the entry-video swap also IS the spotlight-video swap now
   (same DOM node) — removed the redundant second swap, just mirror
   the new entry.video reference into spotlight.video.

6. Game spotlight unchanged — game tiles are static iframes built
   fresh each spotlight, not DOM-moved.

End result: lip-sync stays correct for every role (audio + video
share the same playoutDelayHint), tile switching is instant.
This commit is contained in:
Russell Ballestrini 2026-06-05 13:00:41 -04:00
parent 209a15a1a9
commit 227588f147
No known key found for this signature in database

View file

@ -309,16 +309,26 @@
/* viewing state: the same tile is currently in the spotlight slot.
* Don't hide the thumb — gray it out and overlay 'viewing' so the
* user can see which thumb maps to the spotlight tile. */
.tile-thumb .viewing-badge {
position: absolute; inset: 0;
display: grid; place-items: center;
/* viewing-badge lives inside every tile, but is ONLY visible when
* the tile is rendered as a thumb AND is currently "viewing" (i.e.
* the spotlight is showing the same publisher's tile elsewhere).
* With spotlight-by-DOM-move 2026-06-05 the tile IS the spotlight,
* so no separate thumb exists for that publisher. The badge is
* default-hidden; only the .tile-thumb.viewing combo shows it. */
.viewing-badge { display: none; }
.tile-thumb.viewing .viewing-badge {
display: grid; position: absolute; inset: 0; place-items: center;
background: rgba(0,0,0,0.22); color: #fff;
font-family: monospace; font-size: 0.75rem; letter-spacing: 0.05em;
text-transform: uppercase; pointer-events: none;
text-shadow: 0 1px 2px rgba(0,0,0,0.75);
opacity: 0; transition: opacity 0.12s;
opacity: 1; transition: opacity 0.12s;
z-index: 5;
}
/* fullscreen button is always present in every tile (so we don't
* have to add it dynamically when DOM-moving thumb → spotlight),
* but hidden when the tile is in thumb context. */
.tile-thumb .fs-btn { display: none; }
.tile-thumb.viewing { cursor: default; }
.tile-thumb.viewing:hover { outline: none; }
/* keep the underlying stream legible — a soft dim is enough to
@ -2323,14 +2333,6 @@ 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;
@ -3186,16 +3188,18 @@ function buildTile(kind, pubHex, label, opts){
const meta = document.createElement('div'); meta.className = 'screen-meta';
const who = document.createElement('span'); who.textContent = k.labelPrefix+': '+label;
const ctl = document.createElement('span');
let fsBtn = null;
if (!isThumb){
fsBtn = document.createElement('button'); fsBtn.className = 'small';
fsBtn.textContent = 'fullscreen';
fsBtn.onclick = (ev) => {
ev.stopPropagation();
if (video.requestFullscreen) video.requestFullscreen().catch(()=>{});
};
ctl.appendChild(fsBtn);
}
/* Always include the fullscreen button. CSS hides it on .tile-thumb
* so it only appears when the tile is actually in spotlight position.
* This way setSpotlight can DOM-move a thumb into the spotlight
* container without re-creating the meta row. */
const fsBtn = document.createElement('button');
fsBtn.className = 'small fs-btn';
fsBtn.textContent = 'fullscreen';
fsBtn.onclick = (ev) => {
ev.stopPropagation();
if (video.requestFullscreen) video.requestFullscreen().catch(()=>{});
};
ctl.appendChild(fsBtn);
meta.appendChild(who); meta.appendChild(ctl);
/* "viewing" overlay only ever shown on thumbs whose tile is currently spotlit */
if (isThumb){
@ -3219,10 +3223,14 @@ function thumbElementFor(kind, pubHex){
}
function setSpotlight(kind, pubHex){
/* unmark previous spotlight's thumb + tear its big tile */
/* Same publisher, same kind — no-op (idempotent click on the
* currently-spotlit thumb shouldn't trigger any DOM churn). */
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex){
return;
}
/* move previous spotlight back to its thumb position before
* installing the new one. clearSpotlightDOM handles the move. */
if (spotlight){
const prevThumb = thumbElementFor(spotlight.kind, spotlight.pubHex);
if (prevThumb) prevThumb.classList.remove('viewing');
clearSpotlightDOM();
spotlight = null;
}
@ -3267,12 +3275,17 @@ function setSpotlight(kind, pubHex){
} else {
const entry = getEntry(kind, pubHex);
if (!entry){ updateContainerVisibility(); return; }
const big = buildTile(kind, pubHex, entry.label, { thumb: false });
big.video.srcObject = entry.video.srcObject;
try { const p = big.video.play(); if (p && p.catch) p.catch(()=>{ big.tile.classList.add('needs-tap'); }); } catch(_){}
$('spotlight').appendChild(big.tile);
entry.tile.classList.add('viewing');
spotlight = { kind, pubHex, tile: big.tile, video: big.video };
/* DOM-MOVE: take the EXISTING thumb tile (which has been
* decoding the live stream all along) and reparent it into the
* spotlight container. The video element keeps playing — no
* keyframe wait, no re-buffer, no 4s lag. The same applies in
* reverse when un-spotlighting. Fox 2026-06-05: "we should
* switch feeds immediately I don't want any algo slowing that
* down." */
entry.tile.classList.remove('tile-thumb');
entry.tile.classList.remove('viewing');
$('spotlight').appendChild(entry.tile);
spotlight = { kind, pubHex, tile: entry.tile, video: entry.video };
}
updateContainerVisibility();
if (myUUID){
@ -3284,6 +3297,23 @@ function setSpotlight(kind, pubHex){
}
function clearSpotlightDOM(){
const sp = $('spotlight');
/* If the currently-spotlit tile is a live video tile (camera /
* screen / gameshare), DOM-MOVE it back to its thumbs container
* instead of destroying it. Keeps the video element running so a
* re-spotlight is instant too. Game tiles (iframes) are built
* fresh each time and DO get destroyed. */
if (spotlight && spotlight.kind !== 'game' && spotlight.tile && spotlight.tile.parentNode === sp){
const k = TILE_KINDS[spotlight.kind];
spotlight.tile.classList.add('tile-thumb');
spotlight.tile.classList.remove('viewing');
if (k){
const thumbs = $(k.thumbContainer);
if (thumbs) thumbs.appendChild(spotlight.tile);
} else {
sp.removeChild(spotlight.tile);
}
}
/* clean up anything else (game iframe tile or stragglers) */
while (sp.firstChild){
const v = sp.firstChild.querySelector && sp.firstChild.querySelector('video');
if (v) try { v.srcObject = null; } catch(_){}
@ -3357,15 +3387,13 @@ function renderVideoTile(kind, pubHex, stream, opts){
logLine('err','play threw: '+e.message);
entry.tile.classList.add('needs-tap');
}
/* if the same tile is currently spotlit, mirror the stream into the big
* tile — same supplant-vs-autoplay trap, same fix. */
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex && spotlight.video){
spotlight.video = swapFreshVideoElement(spotlight.video);
spotlight.video.srcObject = stream;
try {
const p = spotlight.video.play();
if (p && p.catch) p.catch(()=>{ spotlight.tile.classList.add('needs-tap'); });
} catch(_){}
/* Spotlight uses DOM-move 2026-06-05 — spotlight.tile === entry.tile
* when the same publisher is currently spotlit. The entry swap above
* already replaced the live video element in the DOM, so we just
* mirror the new reference into the spotlight bookkeeping. No second
* swap needed. */
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex){
spotlight.video = entry.video;
}
/* spotlight promotion rules:
* - no spotlight yet → first tile auto-promotes (anyone)
@ -3941,20 +3969,17 @@ 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: 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.34s 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). */
/* Video receivers (screen / camera / game) match the audio
* receiver's playoutDelayHint so the picture stays in sync with
* the voice — for every role including listeners. Tile-switching
* speed is decoupled from this by reusing the existing thumb's
* video element (DOM-move in setSpotlight) instead of building
* a fresh one. */
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. */
if (ev.receiver) registerLipSyncVideo(pubHex, kind, ev.receiver);
}
/* MSID-supplant safety: the SFU re-uses the same streamID
@ -5604,19 +5629,15 @@ function retargetAllReceivers(role){
try { node.jbuf.port.postMessage({ cmd: 'retarget', targetSeconds: target }); } catch(_){}
}
}
/* 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;
/* SFU sub PC receivers — audio (the native side, downstream of which
* the worklet sits) AND video (which sits directly on the receiver). */
if (sfuSubPC && typeof sfuSubPC.getReceivers === 'function'){
for (const r of sfuSubPC.getReceivers()){
const isVideo = r.track && r.track.kind === 'video';
const t = isVideo ? vTarget : target;
try { r.playoutDelayHint = t; } catch(_){}
try { r.jitterBufferTarget = t * 1000; } catch(_){}
try { r.playoutDelayHint = target; } catch(_){}
try { r.jitterBufferTarget = target * 1000; } catch(_){}
}
}
logLine('', 'retarget all receivers → aud='+target+'s vid='+vTarget+'s (role='+role+')');
logLine('', 'retarget all receivers → '+target+'s (role='+role+')');
}
async function onRoleChanged(prev, next){
@ -7096,8 +7117,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-05</span><br>
md5 <span class="stamp-md5">563396c600c7710a1c213c2b22c314fc</span><br>
sha256 <span class="stamp-sha">39e3a4bd33947eecfd33b8ae7cc552858cedef97b24a79e017ebb496b48f16ca</span><br>
md5 <span class="stamp-md5">66fd5efdb6e64d748dbca14181864f64</span><br>
sha256 <span class="stamp-sha">2216311e5dacc548b5c5364b10acce241b5baaa7c2068ca169e749478d5e5fe6</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>