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');