zebra-report: spotlight tile model + click-to-swap — mirror

This commit is contained in:
russell@unturf.com 2026-06-02 10:25:16 -04:00
parent 6e4d58ffab
commit 5e4693dc1e
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>