diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 6df764e..69774aa 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -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.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). */
+ /* 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');