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:
parent
4f11738731
commit
a64dcc29c7
1 changed files with 109 additions and 84 deletions
|
|
@ -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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue