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