zebra-report: spotlight tile model + click-to-swap — mirror
This commit is contained in:
parent
6e4d58ffab
commit
5e4693dc1e
1 changed files with 146 additions and 31 deletions
|
|
@ -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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue