zebra-spaces: spotlight broadcast — log + popularity-sort thumbnails

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<uuid, key> 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.
This commit is contained in:
Russell Ballestrini 2026-06-02 10:40:38 -04:00
parent a64dcc29c7
commit cdcc15365a
No known key found for this signature in database

View file

@ -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');
<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">cb57f9dfa52c34a5aaa29f7f76968206</span><br>
sha256 <span class="stamp-sha">ddc8747d462fd1cf61f96b976cfc11760b232e0e7eb5a4dc0561f6e4a8dbd1e8</span><br>
md5 <span class="stamp-md5">ad6a80d1c54036ff0d7782df2b98c4db</span><br>
sha256 <span class="stamp-sha">77b28cfbf2690bdf0b12697c141ab460d0c95301344ce0245936a1c498aa35a9</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>