diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 504efd3..b2b5adc 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -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');