zebra-spaces: spotlight model — one tile big in middle, click thumbnail to swap

Per fox: every camera and screen tile becomes clickable. One tile at a
time sits in the middle 'spotlight' slot at full size; all others
render as thumbnails in the cameras column (with screens stacked
under the cameras group). Clicking any thumbnail promotes it to the
spotlight and demotes the previous spotlight tile back to its kind's
thumb container. Thumbnails hide the fullscreen button (the tile-click
is now the action) and shrink to ~22vh letterbox.

Implementation:
- DOM: replaced #screens with #spotlight (middle); added
  #screens-thumbs to cameras-col (under #cameras); cameras-thumbs h2
  appears when at least one thumbnail screen exists.
- TILE_KINDS now points at thumb containers only; spotlight is shared
  across kinds via a single global 'spotlight = {kind, pubHex}' var.
- renderVideoTile: first tile auto-promotes to spotlight; subsequent
  tiles go to their kind's thumb container. Tile click handler calls
  setSpotlight; fullscreen + tap-to-play buttons stopPropagation so
  they don't trigger the swap.
- removeVideoTile: if the removed tile was the spotlight, pickNext-
  Spotlight() promotes a screen (preferred) or camera. Otherwise just
  updates container visibility.
- updateContainerVisibility: hides sec-spotlight when nothing spotlit,
  hides sec-cameras when no tiles at all, hides screens-thumbs h2
  when no thumbnail screens are present.
- CSS: .tile-thumb shrinks video (22vh max) + meta fontsize; spotlight
  cameras use object-fit:contain so the whole face frame is visible.
This commit is contained in:
Russell Ballestrini 2026-06-02 10:25:14 -04:00
parent ae9721e336
commit 4f11738731
No known key found for this signature in database

View file

@ -209,10 +209,34 @@
* sinks for the WebRTC track, no UI required */
audio { display: none; }
/* screen-share tiles render in the LEFT (timeline) column at full width.
* When sec-screens is shown the game tabs + iframe are hidden so the
* screen owns the column. */
#screens { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
/* spotlight: the active tile (camera OR screen) renders full-width
* in the middle column. All other tiles render as thumbnails inside
* the cameras-col. Clicking a thumbnail promotes it to the spotlight
* and demotes the current one back to the column. */
#spotlight { display: grid; grid-template-columns: 1fr; gap: 0; }
#screens-thumbs { display: grid; grid-template-columns: 1fr; gap: 0.45rem; margin-top: 0.5rem; }
.screens-thumbs-h2 { margin-top: 0.5rem; }
.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:hover { outline: 1px solid #888; }
/* 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 {
aspect-ratio: 16 / 9;
object-fit: contain;
max-height: 92vh;
}
/* thumb sizing for either kind: small, cropped 16:9 */
.tile-thumb > video {
width: 100%; height: auto;
aspect-ratio: 16 / 9; object-fit: cover;
max-height: 22vh;
}
.tile-thumb > .screen-meta {
font-size: 0.6rem; padding: 0.15rem 0.3rem;
}
/* tile chrome (border + bg + meta) defaults to light-theme palette so
* the screen-share blends with a white page. Dark-theme overrides
* further down restore the black tile chrome for the night palette. */
@ -239,9 +263,9 @@
font-family: monospace; font-size: 1rem; user-select: none;
}
.screen-tile.needs-tap .tap-play { display: grid; }
/* sec-screens visible → push everything else in the timeline column down or hide */
.timeline:has(> #sec-screens:not(.hidden)) > .game-tabs,
.timeline:has(> #sec-screens:not(.hidden)) > #game-frame { display: none; }
/* spotlight active → hide the game iframe so the spotlight tile owns the column */
.timeline:has(> #sec-spotlight:not(.hidden)) > .game-tabs,
.timeline:has(> #sec-spotlight:not(.hidden)) > #game-frame { display: none; }
/* camera tiles live in the LEFT column now — single column, stacked
* vertically. Each tile keeps a 16:9 letterbox so faces don't squash. */
@ -463,15 +487,16 @@ try {
<aside id="sec-cameras" class="cameras-col hidden">
<h2>cameras</h2>
<div id="cameras"></div>
<h2 class="screens-thumbs-h2 hidden">screens</h2>
<div id="screens-thumbs"></div>
</aside>
<main class="timeline">
<!-- shared-screen tiles take priority over the game iframe. When sec-screens
is visible (someone is sharing) the game tabs + iframe get hidden via
CSS so the screen has the full middle column. -->
<section id="sec-screens" class="hidden">
<h2>screens</h2>
<div id="screens"></div>
<!-- spotlight: the active camera or screen tile occupies this slot at
full size. All other tiles render as thumbnails in the cameras
column. Click a thumbnail to swap it into the spotlight. -->
<section id="sec-spotlight" class="hidden">
<div id="spotlight"></div>
</section>
<!-- placeholder content until the v0.3 event timeline lands: a game switcher
@ -952,14 +977,75 @@ function flushSfuStreams(){
}
}
/* shared video-tile renderer for screens + cameras. The two kinds share
* the autoplay + tap-to-play + unmute machinery; only the DOM target
* (container + tile class), label prefix, and backing Map differ.
* Removed on peer-left / boot / SFU track removal via removeVideoTile. */
/* 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.
*
* 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. */
const TILE_KINDS = {
screen: { container:'screens', section:'sec-screens', tileClass:'screen-tile', labelPrefix:'screen', store: screenVideos, streams: screenStreams },
camera: { container:'cameras', section:'sec-cameras', tileClass:'camera-tile', labelPrefix:'camera', store: cameraVideos, streams: cameraStreams },
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 }
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);
}
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);
}
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');
updateContainerVisibility();
}
function pickNextSpotlight(){
/* called when the current spotlight tile is removed — promote whatever
* exists, preferring screens over cameras (the big content) */
for (const [pubHex] of screenVideos){ setSpotlight('screen', pubHex); return; }
for (const [pubHex] of cameraVideos){ setSpotlight('camera', pubHex); return; }
spotlight = null;
updateContainerVisibility();
}
function renderVideoTile(kind, pubHex, stream, opts){
const k = TILE_KINDS[kind]; if (!k) return;
const local = !!(opts && opts.local);
@ -973,7 +1059,9 @@ function renderVideoTile(kind, pubHex, stream, opts){
}
}
let entry = k.store.get(pubHex);
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
@ -985,21 +1073,41 @@ function renderVideoTile(kind, pubHex, stream, opts){
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 = () => {
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 pop = document.createElement('button'); pop.className = 'small';
pop.textContent = 'fullscreen'; pop.onclick = () => video.requestFullscreen && video.requestFullscreen().catch(()=>{});
ctl.appendChild(pop);
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);
$(k.container).appendChild(tile);
entry = { tile, video, label };
/* 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', () => {
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex) return;
setSpotlight(kind, pubHex);
});
entry = { tile, video, label, fsBtn };
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 {
@ -1015,17 +1123,23 @@ function renderVideoTile(kind, pubHex, stream, opts){
logLine('err','play threw: '+e.message);
entry.tile.classList.add('needs-tap');
}
$(k.section).classList.remove('hidden');
logLine('', k.labelPrefix+' share: '+(local?'local preview ':'receiving ')+label);
updateContainerVisibility();
if (isNew) logLine('', k.labelPrefix+' share: '+(local?'local preview ':'receiving ')+label);
}
function removeVideoTile(kind, pubHex){
const k = TILE_KINDS[kind]; if (!k) return;
const entry = k.store.get(pubHex);
if (!entry) return;
const wasSpotlight = spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex;
try { entry.video.srcObject = null; entry.tile.remove(); } catch(_){}
k.store.delete(pubHex);
k.streams.delete(pubHex);
if (k.store.size === 0) $(k.section).classList.add('hidden');
if (wasSpotlight){
spotlight = null;
pickNextSpotlight();
} else {
updateContainerVisibility();
}
}
/* back-compat shims — keep the old names callable so the rest of the
* file doesn't have to be rewritten in one shot */
@ -2415,7 +2529,8 @@ $('btn-leave').addEventListener('click', async () => {
$('sec-listener-actions').classList.add('hidden');
$('sec-share').classList.add('hidden');
$('sec-screen-share').classList.add('hidden');
$('sec-screens').classList.add('hidden');
$('sec-spotlight').classList.add('hidden');
spotlight = null;
hideNotice();
$('dot-call').className='dot warn';
setStatus('left', null);
@ -2432,8 +2547,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">4d956692f61f60657077d2e04b576b71</span><br>
sha256 <span class="stamp-sha">eb02187ba33a4618b347444a41e25d6d2f58ff6c52c7f7ff0e3437a020dbfa25</span><br>
md5 <span class="stamp-md5">1858dfa2ee7a0b04e5c025b08627692d</span><br>
sha256 <span class="stamp-sha">9c7c5aad040469c3d8eb5deb69ff178f25e440fdbe81b176ac09356e297d912b</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>