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