@homie-rocks/studio 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/worker/site.mjs CHANGED
@@ -51,6 +51,7 @@ const I = {
51
51
  arrow: '<path d="M5 12h13M13 6l6 6-6 6"/>',
52
52
  spark: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6"/>',
53
53
  home: '<path d="M4 11.5 12 5l8 6.5V20H4z"/><path d="M10 20v-5h4v5"/>',
54
+ eye: '<path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>',
54
55
  };
55
56
  const icon = (name, cls = 'ico') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${I[name] ?? ''}</svg>`;
56
57
  const PLAY_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 4.8v14.4a1 1 0 0 0 1.5.86l12-7.2a1 1 0 0 0 0-1.72l-12-7.2A1 1 0 0 0 7 4.8Z" fill="currentColor"/></svg>';
@@ -236,6 +237,11 @@ img,video{display:block;max-width:100%}
236
237
  .pips i{width:8px;height:8px;border-radius:50%;background:color-mix(in srgb,var(--fg) 18%,transparent)}
237
238
  .pips i.on{background:#43ff9e;box-shadow:0 0 8px rgba(67,255,158,.55)}
238
239
  .join{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 18px;border-radius:12px;background:var(--hot);color:var(--hot-ink);font:800 15px/1 var(--display);text-decoration:none}
240
+ .room-acts{display:flex;gap:8px;align-items:center}
241
+ .watchb{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:44px;padding:0 14px;border-radius:12px;border:1px solid color-mix(in srgb,var(--fg) 24%,transparent);color:var(--fg);font:700 15px/1 var(--display);text-decoration:none}
242
+ .watchb:hover{border-color:var(--fg);background:color-mix(in srgb,var(--fg) 6%,transparent)}
243
+ .watchb svg{width:18px;height:18px;flex:none}
244
+ @media (max-width:379px){.watchb span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}.watchb{padding:0 12px}}
239
245
  .quiet{margin:22px 0 0;padding:18px 20px;border-radius:var(--r);border:1px dashed color-mix(in srgb,var(--fg) 22%,transparent);color:var(--soft)}
240
246
  .stat{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px}
241
247
  .stat span{padding:8px 12px;border-radius:999px;background:var(--panel);border:1px solid var(--line);font:600 14px/1.2 var(--text);color:var(--soft)}
@@ -423,10 +429,13 @@ d.addEventListener('click',function(ev){var b=ev.target&&ev.target.closest&&ev.t
423
429
  if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(t).then(done,function(){})});
424
430
  // Live: the line under Play and the rooms list follow the site's own room counts.
425
431
  var el=function(tag,cls,text){var e=d.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e};
432
+ var EYE='<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>';
426
433
  var roomRow=function(r){var li=el('li','room');var th=el('span','thumb');if(r.cover){var im=el('img');im.src=r.cover;im.alt='';im.loading='lazy';th.appendChild(im)}li.appendChild(th);
427
434
  var mid=el('div');mid.appendChild(el('b',null,(r.name?r.name+' · ':'')+r.label));mid.appendChild(el('span',null,r.players+' of '+r.max+' seats taken · bots hold the rest'));
428
435
  var pips=el('div','pips');for(var i=0;i<Math.min(r.max,32);i++)pips.appendChild(el('i',i<r.players?'on':''));mid.appendChild(pips);li.appendChild(mid);
429
- var a=el('a','join','Join');a.href=r.play;a.setAttribute('data-play','');li.appendChild(a);return li};
436
+ var acts=el('div','room-acts');
437
+ if(r.watch){var w=el('a','watchb');w.href=r.watch;w.setAttribute('aria-label','Watch '+(r.name?r.name+' · ':'')+r.label);w.innerHTML=EYE;w.appendChild(el('span',null,'Watch'));acts.appendChild(w)}
438
+ var a=el('a','join','Join');a.href=r.play;a.setAttribute('data-play','');acts.appendChild(a);li.appendChild(acts);return li};
430
439
  var paintRooms=function(list,rooms){if(!list)return;var quiet=d.querySelector('[data-rooms-quiet]');list.textContent='';
431
440
  rooms.forEach(function(r){list.appendChild(roomRow(r))});list.hidden=!rooms.length;if(quiet)quiet.hidden=!!rooms.length;hand()};
432
441
  var live=d.querySelector('[data-live]'),list=d.querySelector('[data-rooms]');
@@ -639,14 +648,30 @@ function gameCard(g, playing = 0) {
639
648
  </article>`;
640
649
  }
641
650
 
651
+ /**
652
+ * What a game lets its watchers see (game.json "watch", NETPLAY.md section 16): `follow` (any player's view; the
653
+ * default), `overview` (the whole room only: a game with hidden hands or roles), or `off` (no watch door at all).
654
+ */
655
+ export function watchOf(g) {
656
+ const w = g?.watch;
657
+ if (w === false || w === 'off') return 'off';
658
+ if (w === 'overview') return 'overview';
659
+ return 'follow';
660
+ }
661
+
642
662
  /** A room as the live list draws it (the same fields /api/rooms and /<game>/live give the script). */
643
663
  export function roomView(g, r, max) {
644
664
  const n = Number(String(r.name ?? r.room ?? '').replace(/^pub-/, '')) || null;
645
- return { game: g.id, name: g.name, label: n ? `Room ${n}` : 'A room', room: r.name ?? r.room, players: r.players, max, cover: coverOf(g), play: `/${g.id}/play?room=${encodeURIComponent(r.name ?? r.room)}` };
665
+ const room = r.name ?? r.room;
666
+ return {
667
+ game: g.id, name: g.name, label: n ? `Room ${n}` : 'A room', room, players: r.players, max, cover: coverOf(g), play: `/${g.id}/play?room=${encodeURIComponent(room)}`,
668
+ // Watch this room from any player's view (section 16); a game that cannot be watched has no link.
669
+ watch: watchOf(g) === 'off' ? null : `/${g.id}/watch?room=${encodeURIComponent(room)}`,
670
+ };
646
671
  }
647
672
 
648
673
  function roomRows(rows, { names = true } = {}) {
649
- return rows.map((r) => `<li class="room"><span class="thumb">${r.cover ? `<img src="${esc(r.cover)}" alt="" loading="lazy">` : ''}</span><div><b>${names ? `${esc(r.name)} · ` : ''}${esc(r.label)}</b><span>${esc(r.players)} of ${esc(r.max)} seats taken · bots hold the rest</span><div class="pips">${Array.from({ length: Math.min(r.max, 32) }, (_, i) => `<i${i < r.players ? ' class="on"' : ''}></i>`).join('')}</div></div><a class="join" href="${esc(r.play)}" data-play>Join</a></li>`).join('');
674
+ return rows.map((r) => `<li class="room"><span class="thumb">${r.cover ? `<img src="${esc(r.cover)}" alt="" loading="lazy">` : ''}</span><div><b>${names ? `${esc(r.name)} · ` : ''}${esc(r.label)}</b><span>${esc(r.players)} of ${esc(r.max)} seats taken · bots hold the rest</span><div class="pips">${Array.from({ length: Math.min(r.max, 32) }, (_, i) => `<i${i < r.players ? ' class="on"' : ''}></i>`).join('')}</div></div><div class="room-acts">${r.watch ? `<a class="watchb" href="${esc(r.watch)}" aria-label="Watch ${names ? `${esc(r.name)} · ` : ''}${esc(r.label)}">${icon('eye', 'ico')}<span>Watch</span></a>` : ''}<a class="join" href="${esc(r.play)}" data-play>Join</a></div></li>`).join('');
650
675
  }
651
676
 
652
677
  const fmtDay = (iso) => { const d = new Date(iso); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }) : ''; };
@@ -944,7 +969,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
944
969
  <a class="play" href="/${esc(g.id)}/play" data-play>${PLAY_ICON}<span>Play now — free</span></a>
945
970
  ${liveLine(g, playing, { idle: `Bots hold every empty seat · one tap and you are in` })}
946
971
  </div>
947
- ${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}${g.saves ? `<a class="also" href="/account/?next=${encodeURIComponent(`/${g.id}/play`)}" data-account>${icon('spark')}<span>Your progress follows you: sign in</span></a>` : ''}
972
+ ${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${playing > 0 && rooms.length && watchOf(g) !== 'off' ? `<a class="also" href="/${esc(g.id)}/watch">${icon('eye')}<span>Watch a live room</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}${g.saves ? `<a class="also" href="/account/?next=${encodeURIComponent(`/${g.id}/play`)}" data-account>${icon('spark')}<span>Your progress follows you: sign in</span></a>` : ''}
948
973
  </div>
949
974
  </section>`;
950
975
 
package/worker/stats.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  * visit a page opened (home, a game's page, /music/, a song's page, /videos/, a video's page)
10
10
  * play a game's play page opened: somebody pressed Play (or followed a Play link or QR code)
11
11
  * screen a big screen opened (/<game>/tv)
12
+ * watch a watch page opened (/<game>/watch: a live room from any player's view; 0.15.0)
12
13
  * room a room opened: the first seat taken in an empty room (public, or a named one)
13
14
  * round a round finished (source: `people` when a person was in it, `bots` when only bots were)
14
15
  * humans the people in finished rounds, summed
@@ -53,7 +54,7 @@ CREATE TABLE IF NOT EXISTS stats_keys (
53
54
  ) WITHOUT ROWID;
54
55
  `;
55
56
 
56
- export const METRICS = Object.freeze(['visit', 'play', 'screen', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video']);
57
+ export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video']);
57
58
  const MAX_METRICS = new Set(['peak', 'peak-room']);
58
59
  const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
59
60
  const DAY = /^\d{4}-\d{2}-\d{2}$/;
@@ -288,7 +289,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
288
289
  const of = (m, extra = () => true) => sum((r) => r.metric === m && r.subject === g.id && extra(r));
289
290
  return {
290
291
  id: g.id, name: g.name, seats: g.players?.max ?? null,
291
- visits: of('visit'), plays: of('play'), screens: of('screen'), rooms: of('room'),
292
+ visits: of('visit'), plays: of('play'), screens: of('screen'), watches: of('watch'), rooms: of('room'),
292
293
  rounds: of('round'), roundsWithPeople: of('round', (r) => r.source === 'people'), peopleInRounds: of('humans'),
293
294
  peakPlayers: maxOf((r) => r.metric === 'peak' && r.subject === g.id), peakInOneRoom: maxOf((r) => r.metric === 'peak-room' && r.subject === g.id),
294
295
  playingNow: now[g.id]?.players ?? 0, roomsNow: now[g.id]?.rooms ?? 0,
@@ -316,7 +317,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
316
317
  return {
317
318
  v: 1, kind: 'homie-studio-stats', studio: cat.studio?.name ?? null, range, ...(only ? { only } : {}),
318
319
  totals: {
319
- visits: sum((r) => r.metric === 'visit'), plays: sum((r) => r.metric === 'play'), screens: sum((r) => r.metric === 'screen'),
320
+ visits: sum((r) => r.metric === 'visit'), plays: sum((r) => r.metric === 'play'), screens: sum((r) => r.metric === 'screen'), watches: sum((r) => r.metric === 'watch'),
320
321
  rooms: sum((r) => r.metric === 'room'), rounds: sum((r) => r.metric === 'round'), roundsWithPeople: sum((r) => r.metric === 'round' && r.source === 'people'),
321
322
  peopleInRounds: sum((r) => r.metric === 'humans'), songPlays: sum((r) => r.metric === 'song'), videoViews: sum((r) => r.metric === 'video'),
322
323
  peakPlayers: Math.max(0, ...perGame.map((g) => g.peakPlayers)), peakInOneRoom: Math.max(0, ...perGame.map((g) => g.peakInOneRoom)),
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.14.4';
2
+ export const STUDIO_VERSION_TAG = '0.15.0';