zebra-spaces: games are proper spotlight tiles — click broadcasts + iframe loads big

Per fox: each game is its own tile in the left column. Click makes it
the spotlight (big middle slot) AND broadcasts via the same spotlight
channel cameras/screens use, so the log says 'alice now viewing
unmario' and popularity-sort can rank games by viewer count.

Changes:
- GAMES = { unmario, cake } map (id → label + src) — single source
  of truth, easy to extend
- buildGameThumb(id) creates a 16:9 clickable card with the game name
  and a 'viewing' badge that surfaces when spotlit
- renderGamesColumn() rebuilds #games-thumbs from the GAMES map at
  page init (idempotent, can re-run on config change)
- thumbElementFor(kind, pubHex) unified thumb lookup so spotlight
  unmark-previous works across camera / screen / game without case
  branches in the hot path
- setSpotlight refactored: previous-thumb cleanup runs first, then
  branches on kind=game (build iframe-backed big tile) vs kind=
  camera/screen (existing video-backed path). Both still go through
  the same spotlights.set(myUUID, ...) + broadcastSpotlight() +
  reorderTiles() at the end, so the broadcast path is unified.
- ownerLabel('game', id) → game label so log lines read 'alice now
  viewing unmario' instead of 'alice now viewing <hex>'s game'
- old #game-frame + game-tabs DOM and handler removed; their CSS too
This commit is contained in:
Russell Ballestrini 2026-06-02 11:29:08 -04:00
parent 6a72e77518
commit 04e13e1f3a
No known key found for this signature in database

View file

@ -176,26 +176,44 @@
position: relative;
}
.controls { min-width: 0; }
/* games live in the LEFT column now (under cameras + screens-thumbs)
* so people can play casually during a meeting without competing
* with screen-shares for the middle slot. The tabs wrap if the
* column is narrow; the iframe is sized for the column. */
/* games live in the LEFT column under cameras + screens-thumbs as
* proper tile-thumbs. Click one to spotlight it in the middle (an
* iframe loads at full size); the click also broadcasts the spotlight
* choice through the same channel cameras/screens use, so the log
* shows 'alice now viewing unmario' and the thumb-order popularity
* sort knows people are watching. */
.games-h2 { margin-top: 0.5rem; }
.game-tabs {
display: grid; grid-auto-columns: minmax(0, 1fr); grid-auto-flow: row;
gap: 0.25rem; margin-bottom: 0.35rem;
#games-thumbs { display: grid; grid-template-columns: 1fr; gap: 0.45rem; }
.game-thumb {
border: 1px solid #ddd; background: #fff; padding: 0;
display: grid; grid-template-columns: 1fr; gap: 0;
cursor: pointer; position: relative;
aspect-ratio: 16 / 9;
}
.game-tabs button {
background: #fff; color: #000; border: 1px solid #000;
padding: 0.25rem 0.5rem; font-family: monospace; font-size: 0.72rem;
cursor: pointer; text-align: left;
.game-thumb:hover { outline: 1px solid #888; }
.game-thumb .game-label {
display: grid; place-items: center; text-align: center;
font-family: monospace; font-size: 0.85rem; padding: 0.5rem;
background: #f0f0f0; color: #333;
}
.game-tabs button.active { background: #000; color: #fff; }
.game-tabs button:hover:not(.active) { background: #f0f0f0; }
.game-tile-frame {
width: 100%; aspect-ratio: 4 / 3;
border: 1px solid #ddd; background: #fff; display: block;
/* spotlit game shows the iframe at full size in the middle column */
#spotlight > .game-tile {
border: 1px solid #ddd; background: #fff;
display: grid; grid-template-rows: 1fr auto;
}
#spotlight > .game-tile iframe {
width: 100%; height: 80vh;
border: none; background: #fff; display: block;
}
#spotlight > .game-tile .screen-meta {
background: #f0f0f0; color: #333; font-size: 0.7rem; padding: 0.3rem 0.5rem;
display: grid; grid-template-columns: 1fr auto; align-items: center;
}
html.theme-dark .game-thumb { background: #000; border-color: #000; }
html.theme-dark .game-thumb .game-label { background: #111; color: #ddd; }
html.theme-dark #spotlight > .game-tile { background: #000; border-color: #000; }
html.theme-dark #spotlight > .game-tile iframe { background: #000; }
html.theme-dark #spotlight > .game-tile .screen-meta { background: #111; color: #ddd; }
@media (max-width: 800px) {
body { padding: 1.25rem; }
/* mobile: single column, all three sections stack to grid-column:1 */
@ -513,17 +531,7 @@ try {
<h2 class="screens-thumbs-h2 hidden">screens</h2>
<div id="screens-thumbs"></div>
<h2 class="games-h2">games</h2>
<div class="game-tabs" id="game-tabs">
<button type="button" class="active" data-game="unmario"
data-src="https://unmario.com/">unmario</button>
<button type="button" data-game="cake"
data-src="https://cuppcb.com/games/cake-murder-adventure/">cake murder adventure</button>
</div>
<iframe id="game-frame" class="game-tile-frame"
src="https://unmario.com/"
title="game"
loading="lazy"
referrerpolicy="no-referrer"></iframe>
<div id="games-thumbs"></div>
</aside>
<main class="timeline">
@ -1505,11 +1513,50 @@ function memberOf(pubHex){
}
return null;
}
/* games are tiles too. The 'pubHex' for a game is its short id below;
* spotlight broadcasts use 'game:<id>' as the key, so the room sees
* 'alice now viewing unmario' in the log just like 'alice now viewing
* bob's screen'. */
const GAMES = {
unmario: { label: 'unmario', src: 'https://unmario.com/' },
cake: { label: 'cake murder adventure', src: 'https://cuppcb.com/games/cake-murder-adventure/' },
};
function ownerLabel(kind, pubHex){
if (kind === 'game'){
const g = GAMES[pubHex];
return g ? g.label : pubHex;
}
const m = memberOf(pubHex);
const handle = m ? (m.mm.handle || shortHex(pubHex)) : shortHex(pubHex);
return handle + "'s " + (TILE_KINDS[kind] ? TILE_KINDS[kind].labelPrefix : kind);
}
/* game thumbs live in #games-thumbs and click → setSpotlight('game', id) */
function buildGameThumb(id){
const g = GAMES[id]; if (!g) return null;
const tile = document.createElement('div');
tile.className = 'game-thumb';
tile.dataset.pubHex = id;
tile.dataset.kind = 'game';
const label = document.createElement('div');
label.className = 'game-label';
label.textContent = g.label;
tile.appendChild(label);
const view = document.createElement('div'); view.className = 'viewing-badge'; view.textContent = 'viewing';
tile.appendChild(view);
tile.addEventListener('click', () => {
if (spotlight && spotlight.kind === 'game' && spotlight.pubHex === id) return;
setSpotlight('game', id);
});
return tile;
}
function renderGamesColumn(){
const c = $('games-thumbs'); if (!c) return;
c.innerHTML = '';
for (const id of Object.keys(GAMES)){
const t = buildGameThumb(id);
if (t) c.appendChild(t);
}
}
/* tile score = role-based boost on the OWNER + count of current viewers.
* Host always at top, co-hosts second, then speakers sorted by viewers.
* Listeners can't publish so they never appear. */
@ -1605,24 +1652,52 @@ function buildTile(kind, pubHex, label, opts){
return { tile, video, fsBtn };
}
function setSpotlight(kind, pubHex){
const entry = getEntry(kind, pubHex);
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();
/* find a thumb element by kind+pubHex regardless of whether it's a
* camera/screen tile or a game card. Returned element gets the
* 'viewing' class toggle so the thumb→spotlight mapping is obvious. */
function thumbElementFor(kind, pubHex){
if (kind === 'game'){
return document.querySelector('.game-thumb[data-pub-hex="'+pubHex+'"]');
}
const e = getEntry(kind, pubHex);
return e ? e.tile : null;
}
function setSpotlight(kind, pubHex){
/* unmark previous spotlight's thumb + tear its big tile */
if (spotlight){
const prevThumb = thumbElementFor(spotlight.kind, spotlight.pubHex);
if (prevThumb) prevThumb.classList.remove('viewing');
clearSpotlightDOM();
spotlight = null;
}
if (kind === 'game'){
const g = GAMES[pubHex]; if (!g){ updateContainerVisibility(); return; }
const big = document.createElement('div'); big.className = 'game-tile';
const iframe = document.createElement('iframe');
iframe.src = g.src;
iframe.title = g.label;
iframe.setAttribute('referrerpolicy', 'no-referrer');
iframe.loading = 'eager';
const meta = document.createElement('div'); meta.className = 'screen-meta';
const who = document.createElement('span'); who.textContent = 'game: ' + g.label;
meta.appendChild(who);
big.appendChild(iframe); big.appendChild(meta);
$('spotlight').appendChild(big);
const thumb = thumbElementFor('game', pubHex);
if (thumb) thumb.classList.add('viewing');
spotlight = { kind, pubHex, tile: big, video: null };
} else {
const entry = getEntry(kind, pubHex);
if (!entry){ updateContainerVisibility(); return; }
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 };
}
/* 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();
/* track our own choice, broadcast it, log it, re-sort */
if (myUUID){
spotlights.set(myUUID, spotlightKey());
logSpotlightChange(myUUID, spotlightKey());
@ -3214,15 +3289,9 @@ $('btn-copy-share').addEventListener('click', async () => {
setTimeout(() => { joinSpace().catch(e => logLine('err', 'auto-rejoin failed: ' + e.message)); }, 100);
})();
/* game-tabs: click a button → swap iframe src + mark active. data-src is
* the only source of truth so new games just need a button. */
$('game-tabs').addEventListener('click', (ev) => {
const b = ev.target.closest('button[data-src]');
if (!b) return;
const frame = $('game-frame');
if (frame.src !== b.dataset.src) frame.src = b.dataset.src;
for (const el of $('game-tabs').querySelectorAll('button')) el.classList.toggle('active', el === b);
});
/* games as left-column tiles — click to spotlight, broadcast through
* the same channel cameras/screens use so the room sees the choice */
renderGamesColumn();
$('btn-leave').addEventListener('click', async () => {
wantConnected = false;
roomMachines.call.send('LEAVE');
@ -3262,8 +3331,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">02bfc336d703a55e23a2ce5f1a69adf1</span><br>
sha256 <span class="stamp-sha">f554d4c9c6b226f5b79954222c75a48a38f031154d6de8cd6b4df0fd8bd07184</span><br>
md5 <span class="stamp-md5">435fd27480ef8c017d88914d3aa9f308</span><br>
sha256 <span class="stamp-sha">2757004e72c5cc250a93476d1bd642e1e6669e589e76387eedd185cb6d5d5aa2</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>