From cdcc15365a3d4b2e530a65a90e03c37b2f139dfd Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Tue, 2 Jun 2026 10:40:38 -0400 Subject: [PATCH] =?UTF-8?q?zebra-spaces:=20spotlight=20broadcast=20?= =?UTF-8?q?=E2=80=94=20log=20+=20popularity-sort=20thumbnails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per fox — make viewing public so the room can see what's holding attention and so thumbnail order reflects what people actually watch (prevents a speaker dropping inappropriate content from drifting into peripheral view unless folks tune in). - spotlights: Map tracking who's viewing what big tile - send {type:'spotlight', key:'kind:pubHex'} on every local spotlight change; broadcast back via signal server (already deployed) as {type:'spotlight', uuid, key} - log line on each change: 'alice now viewing bob's screen' / 'alice looked away' — social pressure tool, names what the room is focused on - tileScore = role boost on the OWNER (host 10000, cohost 5000, speaker 0) plus viewer count. Thumbnails re-sorted descending so host content sits at top, cohost second, speakers ranked by viewer count - reorderTiles fires on: own spotlight change, inbound spotlight signal, peer-left, role-change, host-promoted, and at welcome (broadcasts the auto-spotlit first tile) - pubHex + kind stored as tile.dataset for the sort to read Paired with proxy.unturf.com#719e0e5 which routes the new message type through the signal server. --- web/zebra-spaces.html | 88 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index b2b5adc..94eff97 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1010,8 +1010,58 @@ const TILE_KINDS = { }; /* the active spotlight, or null when nothing is spotlit */ let spotlight = null; // { kind, pubHex, tile (DOM), video (DOM) } +/* who in the room is viewing what big tile. Keyed by uuid → key + * ("kind:pubHex" or "" for none). Drives popularity sort + log lines. */ +const spotlights = new Map(); 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 memberOf(pubHex){ + for (const [uuid, mm] of members){ + try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex) return { uuid, mm }; } catch(_){} + } + return null; +} +function ownerLabel(kind, 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); +} +/* 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. */ +function tileScore(kind, pubHex){ + let boost = 0; + const m = memberOf(pubHex); + if (m){ + if (m.mm.role === 'host') boost = 10000; + else if (m.mm.role === 'cohost') boost = 5000; + else if (m.mm.role === 'speaker') boost = 0; + } + const key = kind + ':' + pubHex; + let viewers = 0; + for (const [, k] of spotlights) if (k === key) viewers++; + return boost + viewers; +} +function sortThumbContainer(containerId, kind){ + const c = $(containerId); if (!c) return; + const els = Array.from(c.children); + els.sort((a, b) => tileScore(kind, b.dataset.pubHex) - tileScore(kind, a.dataset.pubHex)); + for (const el of els) c.appendChild(el); +} +function reorderTiles(){ + sortThumbContainer('cameras', 'camera'); + sortThumbContainer('screens-thumbs', 'screen'); +} +function broadcastSpotlight(){ + /* fire-and-forget to the signal server; no-op if not connected yet */ + try { send({ type: 'spotlight', key: spotlightKey() }); } catch(_){} +} +function logSpotlightChange(uuid, key){ + const handle = (members.get(uuid) || {}).handle || shortHex(uuid); + if (!key){ logLine('', handle + ' looked away'); return; } + const [kind, pubHex] = key.split(':'); + logLine('', handle + ' now viewing ' + ownerLabel(kind, pubHex)); +} function updateContainerVisibility(){ $('sec-spotlight').classList.toggle('hidden', !spotlight); @@ -1028,6 +1078,8 @@ function buildTile(kind, pubHex, label, opts){ const isThumb = !!(opts && opts.thumb); const tile = document.createElement('div'); tile.className = k.tileClass + (isThumb ? ' tile-thumb' : ''); + tile.dataset.pubHex = pubHex; + tile.dataset.kind = kind; const video = document.createElement('video'); video.setAttribute('autoplay', ''); video.setAttribute('playsinline', ''); @@ -1081,6 +1133,13 @@ function setSpotlight(kind, pubHex){ 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()); + broadcastSpotlight(); + } + reorderTiles(); } function clearSpotlightDOM(){ const sp = $('spotlight'); @@ -1098,6 +1157,11 @@ function pickNextSpotlight(){ if (spotlight) clearSpotlightDOM(); spotlight = null; updateContainerVisibility(); + if (myUUID){ + spotlights.set(myUUID, ''); + logSpotlightChange(myUUID, ''); + broadcastSpotlight(); + } } function renderVideoTile(kind, pubHex, stream, opts){ @@ -1753,6 +1817,11 @@ async function handleSignal(raw){ case 'welcome': myUUID = m.your_uuid; myRole = m.role; roomEpoch = m.epoch; applyState(m.state); + /* any tile auto-spotlit before welcome (during fast-path subscribe) + * needs to be broadcast now so the room sees our viewing state */ + spotlights.set(myUUID, spotlightKey()); + broadcastSpotlight(); + reorderTiles(); logLine('', 'joined as '+myRole+' — uuid '+shortHex(myUUID)); setStatus('connected as '+myRole, 'ok'); $('dot-call').className='dot ok'; @@ -1816,9 +1885,11 @@ async function handleSignal(raw){ } members.delete(m.uuid); handraise.delete(m.uuid); + spotlights.delete(m.uuid); tearPeer(m.uuid); if (hostUUID === m.uuid) hostUUID = ''; renderRoom(); + reorderTiles(); } break; case 'sdp-from': @@ -1829,6 +1900,17 @@ async function handleSignal(raw){ try { const s = JSON.parse(await aesDecrypt(sigKey, unb64(m.data))); const mm = members.get(m.uuid); if (mm){ mm.muted = !!s.muted; renderRoom(); } } catch(_){} break; + case 'spotlight': + /* who's looking at what — drives thumbnail popularity sort + log + * line so the room can see where attention is going */ + { const prev = spotlights.get(m.uuid) || ''; + const next = m.key || ''; + if (prev === next) break; + spotlights.set(m.uuid, next); + if (m.uuid !== myUUID) logSpotlightChange(m.uuid, next); + reorderTiles(); + } + break; case 'hand-raised': handraise.add(m.uuid); renderRoom(); { const mm = members.get(m.uuid); if (mm) logLine('', mm.handle+' raised hand'); } @@ -1885,6 +1967,7 @@ async function handleSignal(raw){ } } renderRoom(); + reorderTiles(); } } break; @@ -1926,6 +2009,7 @@ async function handleSignal(raw){ logLine('', (mm.handle||shortHex(m.new_host_uuid))+' is now host'); } flushSfuStreams(); renderRoom(); + reorderTiles(); } break; case 'space-closing': @@ -2572,8 +2656,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');