zebra-spaces: thumbnails stay put — gray + 'viewing' overlay instead of disappearing

Per fox: never hide a thumbnail when its tile is up big. Instead gray
out the thumbnail and overlay 'viewing' so the mapping between thumb
and spotlight is always visible.

Implementation:
- thumbnails are now permanent — created once when the tile arrives
  and never moved out of the cameras column
- spotlight builds a SEPARATE big tile DOM pointing at the same
  MediaStream via srcObject; multiple <video> elements share one
  stream cleanly in every browser we care about
- thumb gets a .viewing class when its tile is the current spotlight;
  CSS dims the video to 35% opacity and shows a 'VIEWING' overlay
  centered on the tile
- clicking the viewing thumb is a no-op (already up); cursor changes
  to default + hover outline suppressed so the user can tell
- renderVideoTile mirrors stream into spotlight's video element when
  the same tile is currently spotlit (handles stream-swap on
  reconnect / renegotiation)
- removeVideoTile tears the big tile too when its thumb leaves; if
  the spotlight goes away pickNextSpotlight promotes a screen first
  then a camera
This commit is contained in:
Russell Ballestrini 2026-06-02 10:29:59 -04:00
parent 4f11738731
commit a64dcc29c7
No known key found for this signature in database

View file

@ -219,8 +219,24 @@
.screens-thumbs-h2.hidden { display: none; }
/* a thumbnail tile is the same DOM as a spotlight tile but with a
* thumb modifier class; clicking it triggers a spotlight swap */
.tile-thumb { cursor: pointer; }
.tile-thumb { cursor: pointer; position: relative; }
.tile-thumb:hover { outline: 1px solid #888; }
/* 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;
background: rgba(0,0,0,0.55); color: #fff;
font-family: monospace; font-size: 0.75rem; letter-spacing: 0.05em;
text-transform: uppercase; pointer-events: none;
opacity: 0; transition: opacity 0.12s;
z-index: 5;
}
.tile-thumb.viewing { cursor: default; }
.tile-thumb.viewing:hover { outline: none; }
.tile-thumb.viewing > video { opacity: 0.35; }
.tile-thumb.viewing .viewing-badge { opacity: 1; }
/* spotlight sizing — a camera in the spotlight uses contain so the
* whole frame is visible (not cropped like the thumb cover) */
#spotlight > .camera-tile video {
@ -977,71 +993,109 @@ function flushSfuStreams(){
}
}
/* Spotlight model — every tile (camera OR screen) lives either in the
* middle spotlight slot OR as a thumbnail in the cameras column.
* Click any thumbnail to swap it into the spotlight; the previous
* spotlight returns to its kind's thumbnail container.
/* Spotlight model — every tile keeps its thumbnail permanently in the
* cameras column (cameras above, screens-thumbs below). When a tile is
* spotlit, a SECOND larger tile renders in the middle slot pointing at
* the same MediaStream; the thumbnail gets a 'viewing' overlay + gray
* tint so the user can see which tile is up big. Clicking another
* thumbnail swaps the spotlight.
*
* Containers:
* #spotlight middle column (only ever holds zero or one tile)
* #cameras left column: camera thumbnails
* #screens-thumbs left column: screen thumbnails (under cameras)
*
* Two parallel Maps per kind cache tile state keyed by pubHex. */
* #spotlight middle column (zero or one larger 'spotlight tile')
* #cameras left column: camera thumbnails (always rendered)
* #screens-thumbs left column: screen thumbnails (always rendered) */
const TILE_KINDS = {
screen: { thumbContainer:'screens-thumbs', tileClass:'screen-tile', labelPrefix:'screen', store: screenVideos, streams: screenStreams },
camera: { thumbContainer:'cameras', tileClass:'camera-tile', labelPrefix:'camera', store: cameraVideos, streams: cameraStreams },
};
/* the active spotlight, or null when nothing is spotlit */
let spotlight = null; // { kind, pubHex }
let spotlight = null; // { kind, pubHex, tile (DOM), video (DOM) }
function spotlightKey(){ return spotlight ? spotlight.kind+':'+spotlight.pubHex : ''; }
function getEntry(kind, pubHex){ const k = TILE_KINDS[kind]; return k ? k.store.get(pubHex) : null; }
function updateContainerVisibility(){
/* spotlight section visible only when something is spotlit */
$('sec-spotlight').classList.toggle('hidden', !spotlight);
/* cameras-col visible when any tile (camera OR screen) exists */
const anyCam = cameraVideos.size > 0;
const anyScreen = screenVideos.size > 0;
$('sec-cameras').classList.toggle('hidden', !(anyCam || anyScreen));
/* screens-thumbs h2 visible only when there are thumbnail screens
* (any screen tile NOT currently in the spotlight slot) */
const screenThumbCount = $('screens-thumbs').children.length;
document.querySelector('.screens-thumbs-h2').classList.toggle('hidden', screenThumbCount === 0);
document.querySelector('.screens-thumbs-h2').classList.toggle('hidden', !anyScreen);
}
function moveTileTo(kind, pubHex, dest){
/* dest: 'spotlight' or 'thumb' */
const entry = getEntry(kind, pubHex); if (!entry) return;
const isSpotlight = (dest === 'spotlight');
entry.tile.classList.toggle('tile-thumb', !isSpotlight);
/* hide fullscreen button on thumbs — clicking the tile is the action */
if (entry.fsBtn) entry.fsBtn.style.display = isSpotlight ? '' : 'none';
const targetId = isSpotlight ? 'spotlight' : TILE_KINDS[kind].thumbContainer;
const target = $(targetId);
if (entry.tile.parentElement !== target) target.appendChild(entry.tile);
/* build a tile DOM. opts.thumb = true for thumb-style (no fullscreen
* button, gets .tile-thumb class). Returns { tile, video, fsBtn }. */
function buildTile(kind, pubHex, label, opts){
const k = TILE_KINDS[kind];
const isThumb = !!(opts && opts.thumb);
const tile = document.createElement('div');
tile.className = k.tileClass + (isThumb ? ' tile-thumb' : '');
const video = document.createElement('video');
video.setAttribute('autoplay', '');
video.setAttribute('playsinline', '');
video.setAttribute('muted', '');
video.autoplay = true; video.playsInline = true; video.muted = true;
const tap = document.createElement('div'); tap.className = 'tap-play';
tap.textContent = 'tap to play';
tap.onclick = (ev) => {
ev.stopPropagation();
try { video.play().then(() => { tile.classList.remove('needs-tap'); })
.catch(e => logLine('err','play after tap: '+e.message)); } catch(_){}
};
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);
}
meta.appendChild(who); meta.appendChild(ctl);
/* "viewing" overlay only ever shown on thumbs whose tile is currently spotlit */
if (isThumb){
const view = document.createElement('div'); view.className = 'viewing-badge';
view.textContent = 'viewing';
tile.appendChild(view);
}
tile.appendChild(video); tile.appendChild(tap); tile.appendChild(meta);
return { tile, video, fsBtn };
}
function setSpotlight(kind, pubHex){
/* demote the current spotlight to its kind's thumb container */
if (spotlight){
const prev = spotlight;
spotlight = null;
moveTileTo(prev.kind, prev.pubHex, 'thumb');
}
/* promote the requested tile */
const entry = getEntry(kind, pubHex);
if (!entry) { updateContainerVisibility(); return; }
spotlight = { kind, pubHex };
moveTileTo(kind, pubHex, 'spotlight');
if (!entry){ if (spotlight) clearSpotlightDOM(); spotlight = null; updateContainerVisibility(); return; }
/* unmark the previous spotlight's thumb, tear its big tile DOM */
if (spotlight){
const prev = getEntry(spotlight.kind, spotlight.pubHex);
if (prev) prev.tile.classList.remove('viewing');
clearSpotlightDOM();
}
/* build a fresh big tile for the new selection, hand it the same stream */
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 };
updateContainerVisibility();
}
function clearSpotlightDOM(){
const sp = $('spotlight');
while (sp.firstChild){
const v = sp.firstChild.querySelector && sp.firstChild.querySelector('video');
if (v) try { v.srcObject = null; } catch(_){}
sp.removeChild(sp.firstChild);
}
}
function pickNextSpotlight(){
/* called when the current spotlight tile is removed — promote whatever
* exists, preferring screens over cameras (the big content) */
/* called when the current spotlight tile is removed — prefer a screen */
for (const [pubHex] of screenVideos){ setSpotlight('screen', pubHex); return; }
for (const [pubHex] of cameraVideos){ setSpotlight('camera', pubHex); return; }
if (spotlight) clearSpotlightDOM();
spotlight = null;
updateContainerVisibility();
}
@ -1049,7 +1103,6 @@ function pickNextSpotlight(){
function renderVideoTile(kind, pubHex, stream, opts){
const k = TILE_KINDS[kind]; if (!k) return;
const local = !!(opts && opts.local);
/* find the matching member for the title; self isn't in members map */
let label = shortHex(pubHex);
if (local){
label = (myHandle || label) + ' (you)';
@ -1062,52 +1115,15 @@ function renderVideoTile(kind, pubHex, stream, opts){
let isNew = false;
if (!entry){
isNew = true;
const tile = document.createElement('div'); tile.className = k.tileClass;
const video = document.createElement('video');
/* set BOTH the HTML attributes and the IDL properties — Firefox Android
* checks the attribute when deciding autoplay eligibility, not just the
* .muted property. */
video.setAttribute('autoplay', '');
video.setAttribute('playsinline', '');
video.setAttribute('muted', '');
video.autoplay = true; video.playsInline = true; video.muted = true;
const tap = document.createElement('div'); tap.className = 'tap-play';
tap.textContent = 'tap to play';
tap.onclick = (ev) => {
ev.stopPropagation();
try { video.play().then(() => { tile.classList.remove('needs-tap'); })
.catch(e => logLine('err','play after tap: '+e.message)); } catch(_){}
};
const meta = document.createElement('div'); meta.className = 'screen-meta';
const who = document.createElement('span'); who.textContent = k.labelPrefix+': '+label;
const ctl = document.createElement('span');
const fsBtn = document.createElement('button'); fsBtn.className = 'small';
fsBtn.textContent = 'fullscreen';
fsBtn.onclick = (ev) => {
ev.stopPropagation();
if (video.requestFullscreen) video.requestFullscreen().catch(()=>{});
};
ctl.appendChild(fsBtn);
meta.appendChild(who); meta.appendChild(ctl);
tile.appendChild(video); tile.appendChild(tap); tile.appendChild(meta);
/* clicking anywhere on the tile (except the fullscreen button or tap-
* to-play overlay) spotlights it. Thumb tiles thus become clickable
* targets; clicking the spotlight tile is a no-op (already spotlit). */
tile.addEventListener('click', () => {
/* thumbnail lives in the cameras column permanently */
const built = buildTile(kind, pubHex, label, { thumb: true });
built.tile.addEventListener('click', () => {
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex) return;
setSpotlight(kind, pubHex);
});
entry = { tile, video, label, fsBtn };
$(k.thumbContainer).appendChild(built.tile);
entry = { tile: built.tile, video: built.video, label };
k.store.set(pubHex, entry);
/* first-tile auto-promotes to spotlight */
if (!spotlight){
$('spotlight').appendChild(tile);
spotlight = { kind, pubHex };
} else {
$(k.thumbContainer).appendChild(tile);
tile.classList.add('tile-thumb');
fsBtn.style.display = 'none';
}
}
entry.video.srcObject = stream;
try {
@ -1123,6 +1139,14 @@ 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 */
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex && spotlight.video){
spotlight.video.srcObject = stream;
}
/* first arrival auto-spotlights — no spotlight up yet AND this is new */
if (isNew && !spotlight){
setSpotlight(kind, pubHex);
}
updateContainerVisibility();
if (isNew) logLine('', k.labelPrefix+' share: '+(local?'local preview ':'receiving ')+label);
}
@ -1135,6 +1159,7 @@ function removeVideoTile(kind, pubHex){
k.store.delete(pubHex);
k.streams.delete(pubHex);
if (wasSpotlight){
clearSpotlightDOM();
spotlight = null;
pickNextSpotlight();
} else {
@ -2547,8 +2572,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-02</span><br>
md5 <span class="stamp-md5">1858dfa2ee7a0b04e5c025b08627692d</span><br>
sha256 <span class="stamp-sha">9c7c5aad040469c3d8eb5deb69ff178f25e440fdbe81b176ac09356e297d912b</span><br>
md5 <span class="stamp-md5">cb57f9dfa52c34a5aaa29f7f76968206</span><br>
sha256 <span class="stamp-sha">ddc8747d462fd1cf61f96b976cfc11760b232e0e7eb5a4dc0561f6e4a8dbd1e8</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>