@homie-rocks/studio 0.7.0 → 0.9.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/index.mjs CHANGED
@@ -20,7 +20,10 @@
20
20
  * /<game>/__net?room= the room's netplay socket (Table Durable Object)
21
21
  * /<game>/__watch?room= the room's facts, for the shell
22
22
  * /<game>/api/lobby which public room to join (Lobby Durable Object)
23
- * /api/games, /api/rooms the catalogue plus live counts; every public room playing now
23
+ * /api/games, /api/rooms the catalogue plus live counts; every public room playing now (/api/rooms may be
24
+ * cached for 15 s)
25
+ * /__homie/..., /<game>/__homie/... `not-a-homie`: the answer a game with Homie's arcade controls gets when it
26
+ * knocks for a Homie box, so it stops knocking (a studio site is not a box)
24
27
  * /.well-known/homie-studio.json what the homie.rocks directory reads
25
28
  * /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
26
29
  * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
@@ -49,10 +52,10 @@ import { ROOM_ID, badRoomPage, frameAncestors, playPage } from './pages.mjs';
49
52
  import { qrSvg } from './qr.mjs';
50
53
  import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
51
54
  import {
52
- SITE_JS, atomFeed, creditsPage, customPage, gameLanding, gamesPage, homePage, jsonFeed, mediaIndexPage, notFoundPage,
53
- postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage,
55
+ SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
56
+ notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage,
54
57
  } from './site.mjs';
55
- import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
58
+ import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
56
59
  import { ownerRoutes } from './stats-page.mjs';
57
60
  import { STUDIO_VERSION_TAG } from './version.mjs';
58
61
 
@@ -163,18 +166,34 @@ async function mediaObject(request, env, key) {
163
166
  return new Response(obj.body, { headers });
164
167
  }
165
168
 
166
- /** A catalogue media row as the directory reads it (absolute addresses). */
167
- function mediaRow(e, origin, kind) {
169
+ /**
170
+ * A catalogue media row as the directory reads it (absolute addresses). A song's cover (a video's poster) is its
171
+ * own, else the manifest's default, else the landing still of the game it was made for (site.mjs mediaArt).
172
+ */
173
+ function mediaRow(e, origin, kind, cat) {
168
174
  const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u ?? null);
169
175
  const main = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'audio' : 'video'));
170
- const art = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'cover' : 'poster'));
171
176
  return {
172
177
  slug: e.slug, kind: e.kind, title: e.title, blurb: e.blurb ?? '', duration: e.duration ?? null,
173
- page: `${origin}/${kind}/${e.slug}/`, [kind === 'music' ? 'audio' : 'video']: abs(main?.url), [kind === 'music' ? 'cover' : 'poster']: abs(art?.url),
178
+ page: `${origin}/${kind}/${e.slug}/`, [kind === 'music' ? 'audio' : 'video']: abs(main?.url), [kind === 'music' ? 'cover' : 'poster']: abs(mediaArt(e, kind, cat)),
174
179
  credits: e.credits ?? null, rights: e.rights ?? null, for: e.for ?? null,
175
180
  };
176
181
  }
177
182
 
183
+ /**
184
+ * THE ARCADE KNOCK, ANSWERED AT THE ROOT. A game made with Homie's arcade controls (@homie-rocks/arcade, and the
185
+ * older builds of it that Homie's own games carry) asks its page's own origin for a Homie box with a GET on
186
+ * /__homie/call, and a score goes there as a POST. The game's frame is /<game>/__game/, so that absolute path lands
187
+ * on the site's ROOT, not under the game. `not-a-homie` (JSON, CORS-open, because the frame is an opaque origin) makes the knock stop for
188
+ * good and refuses a call in words; it answers 200, so no browser logs a failed load for it, and a studio needs no
189
+ * wrapper Worker of its own for this. A preflight (a POST's content-type) is answered too.
190
+ */
191
+ const KNOCK_CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '86400' };
192
+ function notAHomie(request) {
193
+ if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...KNOCK_CORS, 'cache-control': 'no-store' } });
194
+ return json({ ok: false, error: 'not-a-homie', message: 'this is a studio site, not a Homie box; a game here plays on its own' }, 200, KNOCK_CORS);
195
+ }
196
+
178
197
  /** The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). */
179
198
  async function gameDocument(request, env, url, game, meta, cat) {
180
199
  const asked = url.searchParams.get('room');
@@ -287,6 +306,7 @@ async function route(request, env, ctx) {
287
306
  const getCat = () => (catP ??= catalogue(env, url.origin));
288
307
 
289
308
  if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: getCat });
309
+ if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
290
310
  if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
291
311
  if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
292
312
  if (path === '/api/stats') {
@@ -304,7 +324,10 @@ async function route(request, env, ctx) {
304
324
  }
305
325
  if (path === '/.well-known/homie-studio.json') {
306
326
  const cat = await getCat();
307
- const played = cat.studio?.stats?.share ? await playedThisWeek(env) : null;
327
+ const sharing = Boolean(cat.studio?.stats?.share);
328
+ const [played, byGame] = sharing ? await Promise.all([playedThisWeek(env), playedByGame(env)]) : [null, null];
329
+ // studio.json `"rooms": { "share": false }` keeps the rooms off the hub: a manifest that names no `rooms` shares none.
330
+ const shareRooms = cat.studio?.rooms?.share !== false;
308
331
  return json({
309
332
  v: 1,
310
333
  kind: 'homie-studio',
@@ -314,15 +337,21 @@ async function route(request, env, ctx) {
314
337
  site: url.origin,
315
338
  studio: cat.studio?.version ?? null,
316
339
  claim: await claimOf(env),
317
- games: (cat.games ?? []).map((g) => ({
318
- id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
319
- page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: g.cover ? `${url.origin}/games/${g.id}/${g.cover}` : null,
320
- })),
321
- songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music')),
322
- videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos')),
340
+ games: (cat.games ?? []).map((g) => {
341
+ // The card picture is the landing's hero still (what the landing leads with), else the game's cover.
342
+ const cover = gameCover(g);
343
+ return {
344
+ id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
345
+ page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: cover ? (cover.startsWith('/') ? `${url.origin}${cover}` : cover) : null,
346
+ // Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
347
+ ...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
348
+ };
349
+ }),
350
+ songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music', cat)),
351
+ videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos', cat)),
323
352
  // The studio's posts, for the hub (the full text is in /posts/feed.json).
324
353
  posts: (cat.posts ?? []).slice(0, 20).map((p) => ({ slug: p.slug, title: p.title, date: p.date, summary: p.summary, page: `${url.origin}/posts/${p.slug}/`, image: p.image ? (p.image.startsWith('/') ? `${url.origin}${p.image}` : p.image) : null, links: p.links ?? {} })),
325
- rooms: `${url.origin}/api/rooms`,
354
+ ...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
326
355
  // Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
327
356
  ...(played ? { played } : {}),
328
357
  }, 200, { 'access-control-allow-origin': '*' });
@@ -334,7 +363,9 @@ async function route(request, env, ctx) {
334
363
  if (path === '/api/rooms') {
335
364
  const cat = await getCat();
336
365
  const { rooms } = await roomsOf(env, cat.games ?? []);
337
- return json({ ok: true, playing: rooms.reduce((n, r) => n + r.players, 0), rooms }, 200, { 'access-control-allow-origin': '*' });
366
+ // Short-lived and cacheable: the hub reads every studio's rooms at most every 45 s, and a room's count is a
367
+ // few seconds stale at worst. (The site's own pages ask with cache: 'no-store'.)
368
+ return json({ ok: true, playing: rooms.reduce((n, r) => n + r.players, 0), rooms }, 200, { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=15' });
338
369
  }
339
370
  if (path.startsWith('/media/')) {
340
371
  if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
@@ -473,10 +504,8 @@ async function route(request, env, ctx) {
473
504
  const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
474
505
  return stub.fetch(new Request(target, request));
475
506
  }
476
- if (sub.startsWith('__homie/')) {
477
- // A game made with Homie's arcade controls asks here for a Homie host; "not-a-homie" makes it stop asking (the same answer homie.rocks gives).
478
- return json({ ok: false, error: 'not-a-homie' }, 200, { 'access-control-allow-origin': '*' });
479
- }
507
+ // The same knock, relative to the game's own page: the same answer (see notAHomie).
508
+ if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
480
509
  if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') return gameDocument(request, env, url, game, meta, cat);
481
510
  if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
482
511
  }
package/worker/pages.mjs CHANGED
@@ -31,16 +31,49 @@ export function badRoomPage(cat, g, raw, { screen = false } = {}) {
31
31
  });
32
32
  }
33
33
 
34
+ /** Where the room button may sit: a corner, or the middle of the top edge. */
35
+ export const SHARE_PLACES = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-right'];
36
+
37
+ /**
38
+ * Where the room button sits on each device, from game.json `screen.share`, so it never covers the game's own
39
+ * HUD (a scoreboard in the top right, a fuel bar across a phone's top):
40
+ *
41
+ * "share": "top-left" every device (a place from SHARE_PLACES)
42
+ * "share": { "at": "top-right", "y": 64 } moved 64 px in from its edge (x: from its side)
43
+ * "share": { "desk": "bottom-left", "phone": { "at": "top-left", "y": 56 }, "sideways": "top-center" }
44
+ * "share": { "at": "top-right", "label": false } the button stays a small round icon (the room code is
45
+ * in its sheet), for a corner with little room
46
+ *
47
+ * `desk` is a computer, `phone` a phone held upright, `sideways` a phone turned sideways (else as `phone`). For a
48
+ * corner, `x` and `y` move the button in from its side and its edge (0 to 600 px); for top-center, `x` moves it
49
+ * right (or left, negative) and `y` down. Anything else is the default: the top right, as before.
50
+ */
51
+ export function sharePlaces(value) {
52
+ const one = (v) => {
53
+ const o = typeof v === 'string' ? { at: v } : v && typeof v === 'object' && !Array.isArray(v) ? v : null;
54
+ if (!o || !SHARE_PLACES.includes(o.at)) return null;
55
+ const num = (n, lo, hi) => (Number.isFinite(Number(n)) ? Math.max(lo, Math.min(hi, Math.round(Number(n)))) : 0);
56
+ return { at: o.at, x: num(o.x, o.at === 'top-center' ? -600 : 0, 600), y: num(o.y, 0, 600), label: o.label !== false };
57
+ };
58
+ // A place for every device (a string, or an object with `at`), then any device's own over it.
59
+ const all = one(value) ?? { at: 'top-right', x: 0, y: 0, label: true };
60
+ const per = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
61
+ const desk = one(per.desk) ?? all;
62
+ const phone = one(per.phone) ?? all;
63
+ return { desk, phone, sideways: one(per.sideways) ?? phone };
64
+ }
65
+
34
66
  /**
35
67
  * The play shell. It asks the Lobby for a public room, boots the game frame at once (seated, no waiting), keeps
36
68
  * the seat token for a reload, writes the room into the address (so a reload or a copied address comes back to
37
69
  * it), and keeps the room's facts in `window.__shell` (what a test or a big screen reads). A small room button at
38
- * the edge (game.json `screen.share` picks the corner) opens Invite, Big screen and the room code; nothing covers
39
- * the middle of the screen or a thumb.
70
+ * the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
71
+ * covers the middle of the screen or a thumb.
40
72
  */
41
73
  export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null } = {}) {
42
74
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
43
75
  const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
76
+ const places = sharePlaces(g.screen?.share);
44
77
  // The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
45
78
  // (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
46
79
  const css = `:root{--hot:${/^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a'}}
@@ -59,17 +92,22 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
59
92
  .join .qr svg { width: 100%; height: 100%; display: block; }
60
93
  .join b { display: block; color: var(--hot); font-size: 1.15em; margin-bottom: 4px; }
61
94
  .join span { opacity: .85; word-break: break-all; }
62
- .room { position: fixed; z-index: 7; display: flex; flex-direction: column; gap: 8px; font: 600 13px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif; color: #eef1f8; }
63
- .room.at-top-right { top: max(8px, env(safe-area-inset-top)); right: max(8px, env(safe-area-inset-right)); align-items: flex-end; }
64
- .room.at-top-left { top: max(8px, env(safe-area-inset-top)); left: max(8px, env(safe-area-inset-left)); align-items: flex-start; }
65
- .room.at-bottom-right { bottom: max(8px, env(safe-area-inset-bottom)); right: max(8px, env(safe-area-inset-right)); align-items: flex-end; flex-direction: column-reverse; }
66
- .room.at-bottom-left { bottom: max(8px, env(safe-area-inset-bottom)); left: max(8px, env(safe-area-inset-left)); align-items: flex-start; flex-direction: column-reverse; }
95
+ .room { --dx: 0px; --dy: 0px; position: fixed; z-index: 7; display: flex; flex-direction: column; gap: 8px; font: 600 13px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif; color: #eef1f8; }
96
+ .room.at-top-right { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); right: calc(max(8px, env(safe-area-inset-right)) + var(--dx)); align-items: flex-end; }
97
+ .room.at-top-left { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); left: calc(max(8px, env(safe-area-inset-left)) + var(--dx)); align-items: flex-start; }
98
+ .room.at-top-center { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); left: calc(50% + var(--dx)); transform: translateX(-50%); align-items: center; }
99
+ .room.at-bottom-right { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); right: calc(max(8px, env(safe-area-inset-right)) + var(--dx)); align-items: flex-end; flex-direction: column-reverse; }
100
+ .room.at-bottom-left { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); left: calc(max(8px, env(safe-area-inset-left)) + var(--dx)); align-items: flex-start; flex-direction: column-reverse; }
101
+ .chip.chip-right { left: auto; right: max(10px, env(safe-area-inset-right)); }
67
102
  .pill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 11px 0 9px; border-radius: 999px; border: 1px solid rgba(255,255,255,.18); background: rgba(6,9,16,.62); color: inherit; font: inherit; cursor: pointer; touch-action: manipulation; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: opacity .5s; }
68
103
  .pill svg { width: 15px; height: 15px; flex: none; }
69
104
  .pill.dim { opacity: .38; width: 34px; padding: 0; justify-content: center; }
70
105
  .pill.dim span { display: none; }
71
106
  .pill:hover, .pill:focus-visible, .pill[aria-expanded="true"] { opacity: 1; width: auto; padding: 0 11px 0 9px; }
72
107
  .pill:hover span, .pill:focus-visible span, .pill[aria-expanded="true"] span { display: inline; }
108
+ /* game.json screen.share "label": false: always the small round icon, even while it is open (the code is in the sheet). */
109
+ .pill.icon, .pill.icon:hover, .pill.icon:focus-visible, .pill.icon[aria-expanded="true"] { width: 34px; padding: 0; justify-content: center; }
110
+ .pill.icon span, .pill.icon:hover span, .pill.icon:focus-visible span, .pill.icon[aria-expanded="true"] span { display: none; }
73
111
  .sheet { box-sizing: border-box; width: min(300px, calc(100vw - 16px)); padding: 12px; border-radius: 16px; background: rgba(8,12,22,.94); border: 1px solid rgba(255,255,255,.16); box-shadow: 0 18px 50px rgba(0,0,0,.5); -webkit-user-select: text; user-select: text; touch-action: manipulation; }
74
112
  .sheet .code { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 2px 2px 10px; }
75
113
  .sheet .code b { font: 800 20px/1.1 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.01em; }
@@ -84,13 +122,14 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
84
122
  .sheet .foot a:hover, .sheet .foot button:hover { color: #fff; }
85
123
  .toast { position: fixed; left: 50%; top: max(12px, env(safe-area-inset-top)); z-index: 8; transform: translateX(-50%); padding: 8px 14px; border-radius: 999px; background: rgba(8,12,22,.9); color: #fff; font: 600 13px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
86
124
  [hidden] { display: none !important; }`;
87
- const boot = { game: g.id, name: g.name, screen: Boolean(screen), ...(room ? { room } : {}) };
125
+ // The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
126
+ const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}) };
88
127
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
89
128
  const joinCorner = corner('join', 'bottom-right');
90
- const shareCorner = corner('share', 'top-right');
129
+ const first = places.desk;
91
130
  const joinCard = screen && joinUrl ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
92
- const share = screen ? '' : `<div class="room at-${shareCorner}" data-room-ui>
93
- <button class="pill" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room, invite and big screen"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
131
+ const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
132
+ <button class="pill${first.label ? '' : ' icon'}" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room, invite and big screen"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
94
133
  <div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
95
134
  <div class="code"><b data-room-label>This room</b><span data-room-count></span></div>
96
135
  <div class="acts">
@@ -163,6 +202,23 @@ const SHELL_JS = String.raw`(function () {
163
202
  var sheet = ui && ui.querySelector('[data-share-sheet]');
164
203
  var toast = document.querySelector('[data-toast]');
165
204
  var dimTimer = null;
205
+ // Where the button sits on THIS device (game.json screen.share, per computer, phone and phone turned sideways: a
206
+ // corner or the top's middle, moved in by x / y), so it never sits on the game's own scoreboard or fuel bar.
207
+ function place() {
208
+ if (!ui || !boot.share) return;
209
+ var key = device === 'phone' ? (innerWidth > innerHeight ? 'sideways' : 'phone') : 'desk';
210
+ var p = boot.share[key] || boot.share.desk;
211
+ if (!p) return;
212
+ ui.className = 'room at-' + p.at;
213
+ ui.style.setProperty('--dx', (p.x || 0) + 'px');
214
+ ui.style.setProperty('--dy', (p.y || 0) + 'px');
215
+ if (toggle) { if (p.label === false) toggle.classList.add('icon'); else toggle.classList.remove('icon'); }
216
+ // The status chip lives at the bottom left: a button there sends it to the bottom right.
217
+ if (p.at === 'bottom-left') chip.classList.add('chip-right'); else chip.classList.remove('chip-right');
218
+ state.share = { device: key, at: p.at, x: p.x || 0, y: p.y || 0, label: p.label !== false };
219
+ }
220
+ place();
221
+ addEventListener('resize', place);
166
222
  function labelOf(room) { var m = /^pub-(\d+)$/.exec(room); return m ? 'Room ' + m[1] : room; }
167
223
  function flash(text) { if (!toast) return; toast.textContent = text; toast.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(function () { toast.hidden = true; }, 1800); }
168
224
  function wake() { if (!toggle) return; toggle.classList.remove('dim'); clearTimeout(dimTimer); dimTimer = setTimeout(function () { if (sheet.hidden) toggle.classList.add('dim'); }, 6000); }
package/worker/site.mjs CHANGED
@@ -69,7 +69,7 @@ img,video{display:block;max-width:100%}
69
69
  .ico{width:18px;height:18px;flex:none}
70
70
  .skip{position:absolute;left:-9999px;top:12px;z-index:40;padding:10px 14px;border-radius:10px;background:var(--fg);color:var(--bg)}
71
71
  .skip:focus{left:12px}
72
- .kicker{margin:0 0 12px;font:600 12px/1.5 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--glow)}
72
+ .kicker{margin:0 0 12px;font:600 12px/1.5 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--glow-ink)}
73
73
  :focus-visible{outline:3px solid var(--fg);outline-offset:3px}
74
74
  @media (max-width:559px){.k-opt{display:none}}
75
75
 
@@ -255,7 +255,7 @@ img,video{display:block;max-width:100%}
255
255
  .credits{display:grid;gap:18px;margin-top:30px}
256
256
  @media (min-width:760px){.credits{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}}
257
257
  .credit{overflow-wrap:anywhere;padding:20px 22px;border-radius:var(--r);background:var(--panel);border:1px solid var(--line);color:var(--soft);font-size:15px}
258
- .credit h3{margin:0 0 10px;font:600 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--glow)}
258
+ .credit h3{margin:0 0 10px;font:600 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--glow-ink)}
259
259
  .credit p{margin:0 0 8px}
260
260
  .credit ul{margin:0;padding-left:18px}
261
261
  .credit li{margin:4px 0}
@@ -310,7 +310,7 @@ img,video{display:block;max-width:100%}
310
310
  .plist{list-style:none;margin:28px 0 0;padding:0;display:grid;gap:14px}
311
311
  .pitem>a{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:center;padding:20px 22px;border-radius:var(--r);background:var(--panel);border:1px solid var(--line);text-decoration:none;transition:border-color .2s ease,transform .2s ease}
312
312
  .pitem>a:hover{border-color:color-mix(in srgb,var(--hot) 45%,transparent);transform:translateY(-2px)}
313
- .pitem time,.post-meta{font:600 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--glow)}
313
+ .pitem time,.post-meta{font:600 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--glow-ink)}
314
314
  .pitem h3{margin:6px 0 6px;font:800 clamp(22px,2.6vw,30px)/1.1 var(--display);letter-spacing:-.02em}
315
315
  .pitem p{margin:0;color:var(--soft)}
316
316
  .pitem img{width:168px;aspect-ratio:16/10;object-fit:cover;border-radius:12px}
@@ -444,21 +444,45 @@ if(src){
444
444
  /* ------------------------------------------------------------------ the shell */
445
445
 
446
446
  const TOKEN_DEFAULTS = { bg: '#0b0c12', fg: '#f1f3f9', accent: '#ffcf5a', glow: '#7dffb0' };
447
+ /**
448
+ * A game landing's own scheme (game.json `landing.scheme`), for a game whose picture is the other way round from
449
+ * the studio's look: a white or cream arena (a light hero) under a dark studio's tint turns grey, so a light
450
+ * landing draws the whole page light, its hero tinted and shaded with the light background and its words dark.
451
+ * `landing.theme` colours still win over these.
452
+ */
453
+ export const SCHEME_TOKENS = {
454
+ light: { bg: '#f7f6f1', fg: '#15161d', panel: 'color-mix(in srgb,var(--fg) 5%,var(--bg))' },
455
+ dark: { bg: '#0b0c12', fg: '#f1f3f9', panel: 'color-mix(in srgb,var(--fg) 5%,var(--bg))' },
456
+ };
447
457
 
448
- /** The studio's tokens as CSS custom properties (site/theme.json, checked at build), plus its fonts. */
458
+ /**
459
+ * The studio's tokens as CSS custom properties (site/theme.json, checked at build), plus its fonts. `over` is a
460
+ * page's own (a game landing's `landing.theme`, and its `scheme`). `--glow-ink` is the glow as small text: the
461
+ * glow itself on a dark page, and a darker mix of it on a light one (a pale glow on white cannot be read).
462
+ */
449
463
  function tokensCss(theme = {}, over = null) {
450
464
  const t = { ...TOKEN_DEFAULTS, ...theme, ...(over ?? {}) };
465
+ const scheme = (over?.scheme ?? theme.scheme) === 'light' ? 'light' : 'dark';
451
466
  const fonts = (theme.fonts ?? []).map((f) => `@font-face{font-family:"${f.family}";src:url("${f.src}");font-weight:${f.weight};font-style:${f.style};font-display:swap}`).join('');
452
467
  const display = theme.display ?? 'ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif';
453
468
  const text = theme.text ?? 'ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif';
454
469
  const mono = theme.mono ?? 'ui-monospace,"SF Mono",Menlo,Consolas,monospace';
455
470
  return `${fonts}:root{--bg:${t.bg};--fg:${t.fg};--hot:${t.accent};--glow:${t.glow};--hot-ink:${t.accentInk ?? t.bg};
471
+ --glow-ink:${scheme === 'light' ? 'color-mix(in srgb,var(--glow) 45%,var(--fg))' : 'var(--glow)'};
456
472
  --soft:color-mix(in srgb,var(--fg) 80%,transparent);--dim:color-mix(in srgb,var(--fg) 62%,transparent);--line:color-mix(in srgb,var(--fg) 14%,transparent);
457
473
  --panel:${t.panel ?? 'color-mix(in srgb,var(--fg) 5%,var(--bg))'};--display:${display};--text:${text};--mono:${mono};--r:${Number.isFinite(t.radius) ? t.radius : 18}px;
458
- --gutter:clamp(18px,5vw,72px);--top-h:72px;color-scheme:${theme.scheme === 'light' ? 'light' : 'dark'}${theme.uppercase === false ? ';--mark-case:none;--mark-track:.02em' : ''}}
474
+ --gutter:clamp(18px,5vw,72px);--top-h:72px;color-scheme:${scheme}${theme.uppercase === false ? ';--mark-case:none;--mark-track:.02em' : ''}}
459
475
  @media (max-width:719px){:root{--top-h:112px}}`;
460
476
  }
461
477
 
478
+ /** A landing's `over` tokens: its scheme's (light or dark, when it differs from the studio's), then its own colours. */
479
+ function landingTokens(cat, L) {
480
+ const studio = cat.studio?.theme?.scheme === 'light' ? 'light' : 'dark';
481
+ const want = L.scheme === 'light' || L.scheme === 'dark' ? L.scheme : null;
482
+ if (!want || want === studio) return L.theme ?? null;
483
+ return { ...SCHEME_TOKENS[want], ...(L.theme ?? {}), scheme: want };
484
+ }
485
+
462
486
  const studioName = (cat) => cat.studio?.name ?? 'Studio';
463
487
 
464
488
  /** Which sections this studio has: a section with nothing in it has no tab (and its page answers 404). */
@@ -533,7 +557,7 @@ ${icon}${feeds}${head}
533
557
  <style>${tokensCss(theme, over)}${BASE_CSS}${cat.site?.css ?? ''}</style>
534
558
  ${partial(cat, 'head') ?? ''}
535
559
  </head>
536
- <body class="${hero ? 'has-hero' : ''}" data-page="${esc(page)}">
560
+ <body class="${hero ? 'has-hero' : ''}" data-page="${esc(page)}" data-scheme="${(over?.scheme ?? theme.scheme) === 'light' ? 'light' : 'dark'}">
537
561
  <a class="skip" href="#main">Skip to content</a>
538
562
  ${header(cat, active)}
539
563
  <main id="main">
@@ -566,7 +590,13 @@ export function customPage(cat, html, { active = null } = {}) {
566
590
  const minutes = (s) => (Number.isFinite(s) && s > 0 ? (s % 60 ? `${s}\u00a0s rounds` : `${s / 60}\u2011minute rounds`) : null);
567
591
  const playersText = (g) => (g.players?.max ? (g.players.max === 1 ? 'one player' : `1–${g.players.max} players`) : 'multiplayer');
568
592
  const titleClass = (name) => { const n = String(name ?? '').length; return n > 22 ? 't-xlong' : n > 12 ? 't-long' : ''; };
569
- const coverOf = (g) => g.landing?.cover ?? (g.cover ? `/games/${g.id}/${g.cover}` : null);
593
+ /**
594
+ * A game's picture on every card (Home, Games, Rooms, a post), a room's row and the directory's manifest: the
595
+ * landing's hero still (hero/wide.jpg or game.json landing.hero.image, the picture the landing leads with), else
596
+ * a trailer's poster, else the game's cover. An old game.json cover never outranks the landing's own still.
597
+ */
598
+ export const gameCover = (g) => g.landing?.hero?.wideImage ?? g.landing?.hero?.tallImage ?? g.landing?.cover ?? (g.cover ? `/games/${g.id}/${g.cover}` : null);
599
+ const coverOf = gameCover;
570
600
 
571
601
  /** Hero media: the game's footage (wide and tall, AV1 first), else its art with slow motion, else the studio's colours. */
572
602
  function heroMedia(h = {}, { alt = '', word = '' } = {}) {
@@ -626,9 +656,22 @@ const KIND_WORD = { song: 'Song', score: 'Score', loop: 'Loop', stem: 'Stem', sf
626
656
  const fmtTime = (s) => (Number.isFinite(s) ? `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}` : '');
627
657
  const fileOf = (e, role) => (e.files ?? []).find((f) => f.role === role) ?? null;
628
658
 
629
- function mediaCard(e, kind) {
630
- const art = fileOf(e, kind === 'music' ? 'cover' : 'poster');
631
- return `<a class="media-card reveal" href="/${kind}/${esc(e.slug)}/"><span class="art">${art ? `<img src="${esc(art.url)}" alt="" loading="lazy" decoding="async">` : ''}<span class="big">${PLAY_ICON}</span></span><span class="cap"><b>${esc(e.title)}</b><span>${esc(KIND_WORD[e.kind] ?? (kind === 'music' ? 'Music' : 'Video'))}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span></span></a>`;
659
+ /**
660
+ * A song's cover (a video's poster), wherever it is drawn (its page, a card, the directory's manifest): its own
661
+ * file, else the manifest's default (music/manifest.json `cover`, videos/manifest.json `poster`, which the build
662
+ * gives every entry without one), else the landing still of the game it was made for (`for.game`), else the
663
+ * studio's share picture (site/theme.json `social`). Null when there is none of these.
664
+ */
665
+ export function mediaArt(e, kind, cat = null) {
666
+ const own = fileOf(e, kind === 'music' ? 'cover' : 'poster');
667
+ if (own?.url) return own.url;
668
+ const game = e.for?.game ? (cat?.games ?? []).find((g) => g.id === e.for.game) : null;
669
+ return (game ? gameCover(game) : null) ?? cat?.studio?.theme?.social ?? null;
670
+ }
671
+
672
+ function mediaCard(e, kind, cat = null) {
673
+ const art = mediaArt(e, kind, cat);
674
+ return `<a class="media-card reveal" href="/${kind}/${esc(e.slug)}/"><span class="art">${art ? `<img src="${esc(art)}" alt="" loading="lazy" decoding="async">` : ''}<span class="big">${PLAY_ICON}</span></span><span class="cap"><b>${esc(e.title)}</b><span>${esc(KIND_WORD[e.kind] ?? (kind === 'music' ? 'Music' : 'Video'))}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span></span></a>`;
632
675
  }
633
676
 
634
677
  /* ------------------------------------------------------------------ Home */
@@ -663,7 +706,7 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
663
706
  </section>`;
664
707
  } else {
665
708
  const lead = videos[0] ?? songs[0] ?? null;
666
- const art = lead ? fileOf(lead, videos[0] ? 'poster' : 'cover')?.url : null;
709
+ const art = lead ? mediaArt(lead, videos[0] ? 'videos' : 'music', cat) : null;
667
710
  hero = `<section class="hero home" aria-labelledby="hero-title">
668
711
  ${heroMedia(art ? { wideImage: art } : {}, { word: name })}
669
712
  <div class="hero-copy">
@@ -688,7 +731,7 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
688
731
  </div></section>` : '';
689
732
  const mediaBand = (list, kind, title) => (list.length ? `<section class="band" aria-labelledby="${kind}-title"><div class="band-in">
690
733
  <div class="head-row reveal"><div><p class="kicker">${kind === 'music' ? 'Music' : 'Videos'}</p><h2 id="${kind}-title">${esc(title)}</h2></div><a class="more" href="/${kind}/">All ${kind === 'music' ? 'music' : 'videos'} ${icon('arrow')}</a></div>
691
- <div class="cards">${list.slice(0, 3).map((e) => mediaCard(e, kind)).join('')}</div>
734
+ <div class="cards">${list.slice(0, 3).map((e) => mediaCard(e, kind, cat)).join('')}</div>
692
735
  </div></section>` : '');
693
736
  const extra = partial(cat, 'home');
694
737
  return layout(cat, {
@@ -751,7 +794,7 @@ export function postPage(cat, p, { origin = '' } = {}) {
751
794
  const video = p.links?.video ? (cat.videos ?? []).find((x) => x.slug === p.links.video) : null;
752
795
  const others = (cat.posts ?? []).filter((x) => x.slug !== p.slug).slice(0, 2);
753
796
  const extra = partial(cat, 'post', { 'post.title': p.title, 'post.slug': p.slug });
754
- const linked = [g ? gameCard(g) : '', video ? mediaCard(video, 'videos') : '', song ? mediaCard(song, 'music') : ''].filter(Boolean).join('');
797
+ const linked = [g ? gameCard(g) : '', video ? mediaCard(video, 'videos', cat) : '', song ? mediaCard(song, 'music', cat) : ''].filter(Boolean).join('');
755
798
  return layout(cat, {
756
799
  title: `${p.title} · ${name}`, description: p.summary, origin, path: `/posts/${p.slug}/`, image: p.image ?? (g ? coverOf(g) : null), type: 'article', page: 'post', active: 'posts',
757
800
  head: `<meta property="article:published_time" content="${esc(p.date)}">${p.updated ? `<meta property="article:modified_time" content="${esc(p.updated)}">` : ''}`,
@@ -898,7 +941,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
898
941
  </div></section>`;
899
942
 
900
943
  const facts = ['free, in the browser', 'bots fill empty seats', 'join any time: take a bot’s place', 'rounds restart on their own', ...(tvOn ? ['phones as controllers on a TV'] : [])];
901
- const side = trailer ? mediaCard(trailer, 'videos') : song ? mediaCard(song, 'music') : (L.cover ? `<figure class="media-card reveal" style="margin:0"><span class="art"><img src="${esc(L.cover)}" alt="${esc(g.name)}, a moment of play" loading="lazy"></span><figcaption class="cap"><b>${esc(g.name)}</b><span>${esc(playersText(g))}${minutes(g.roundSeconds) ? ` · ${esc(minutes(g.roundSeconds))}` : ''}</span></figcaption></figure>` : '');
944
+ const side = trailer ? mediaCard(trailer, 'videos', cat) : song ? mediaCard(song, 'music', cat) : (L.cover ? `<figure class="media-card reveal" style="margin:0"><span class="art"><img src="${esc(L.cover)}" alt="${esc(g.name)}, a moment of play" loading="lazy"></span><figcaption class="cap"><b>${esc(g.name)}</b><span>${esc(playersText(g))}${minutes(g.roundSeconds) ? ` · ${esc(minutes(g.roundSeconds))}` : ''}</span></figcaption></figure>` : '');
902
945
  const howBand = `<section class="band hotband" aria-labelledby="how-title"><div class="band-in split">
903
946
  <div class="reveal">
904
947
  <p class="kicker">How to play</p>
@@ -907,7 +950,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
907
950
  ${(L.how ?? []).length ? `<ul class="howto">${L.how.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : ''}
908
951
  <ul class="facts">${facts.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>
909
952
  </div>
910
- ${side ? `<div>${side}${trailer && song ? `<div style="margin-top:14px">${mediaCard(song, 'music')}</div>` : ''}</div>` : ''}
953
+ ${side ? `<div>${side}${trailer && song ? `<div style="margin-top:14px">${mediaCard(song, 'music', cat)}</div>` : ''}</div>` : ''}
911
954
  </div></section>`;
912
955
 
913
956
  const C = L.credits ?? {};
@@ -944,7 +987,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
944
987
  return layout(cat, {
945
988
  title: `${g.name} — play free in your browser`,
946
989
  description: `${g.name}: ${L.pitch ?? g.blurb ?? ''} Free in your browser, nothing to download; a TV or laptop browser can be the big screen, with phones as controllers.`.replace(/\s+/g, ' ').trim(),
947
- origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: L.theme ?? null,
990
+ origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: landingTokens(cat, L),
948
991
  head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}<script type="application/ld+json">${jsonScript(ld)}</script>`,
949
992
  main: `${hero}${ways}${liveBand}${howBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}${makeBand}`,
950
993
  });
@@ -986,14 +1029,15 @@ export function mediaIndexPage(cat, kind, { origin = '' } = {}) {
986
1029
  return layout(cat, {
987
1030
  title: `${title} · ${name}`, description: `${title} from ${name}.`, origin, path: `/${kind}/`, page: kind, active: kind,
988
1031
  main: `${pageHead(name, title, kind === 'music' ? 'Songs, scores and loops from the studio.' : 'Trailers, music videos and clips from the studio.')}
989
- <div class="wrap"><div class="cards">${list.map((e) => mediaCard(e, kind)).join('')}</div></div>`,
1032
+ <div class="wrap"><div class="cards">${list.map((e) => mediaCard(e, kind, cat)).join('')}</div></div>`,
990
1033
  });
991
1034
  }
992
1035
 
993
1036
  export function songPage(cat, e, origin = '') {
994
1037
  const name = studioName(cat);
995
1038
  const audio = fileOf(e, 'audio');
996
- const cover = fileOf(e, 'cover');
1039
+ const coverUrl = mediaArt(e, 'music', cat);
1040
+ const cover = coverUrl ? { url: coverUrl } : null;
997
1041
  const extras = (e.files ?? []).filter((f) => f.role === 'loop' || f.role === 'stem');
998
1042
  const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
999
1043
  const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
@@ -1023,7 +1067,8 @@ export function videoPage(cat, e, origin = '') {
1023
1067
  const name = studioName(cat);
1024
1068
  const video = fileOf(e, 'video');
1025
1069
  const vertical = fileOf(e, 'vertical');
1026
- const poster = fileOf(e, 'poster');
1070
+ const posterUrl = mediaArt(e, 'videos', cat);
1071
+ const poster = posterUrl ? { url: posterUrl } : null;
1027
1072
  const captions = fileOf(e, 'captions');
1028
1073
  const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
1029
1074
  const song = e.for?.song && (cat.songs ?? []).find((m) => m.slug === e.for.song);
package/worker/stats.mjs CHANGED
@@ -304,6 +304,23 @@ export async function playedThisWeek(env, game = null) {
304
304
  } catch { return null; }
305
305
  }
306
306
 
307
+ /**
308
+ * "Played this week" for every game at once (one query, grouped by game), for the manifest's `games[].played`
309
+ * when the studio shares its numbers: `{ [game]: { days: 7, plays, rounds } }`, or null when the counters cannot be
310
+ * read (before migration 0002, or no D1). A game nobody played this week is absent: its numbers are 0.
311
+ */
312
+ export async function playedByGame(env) {
313
+ if (!env?.DB) return null;
314
+ const to = today();
315
+ try {
316
+ const { results } = await env.DB.prepare(`SELECT subject, SUM(CASE WHEN metric = 'play' THEN n ELSE 0 END) AS plays, SUM(CASE WHEN metric = 'round' AND source = 'people' THEN n ELSE 0 END) AS rounds FROM stats_daily WHERE day >= ?1 AND day <= ?2 AND metric IN ('play', 'round') GROUP BY subject`)
317
+ .bind(dayOffset(to, -6), to).all();
318
+ const out = {};
319
+ for (const row of results ?? []) if (SLUG.test(String(row.subject ?? ''))) out[row.subject] = { days: 7, plays: Number(row.plays) || 0, rounds: Number(row.rounds) || 0 };
320
+ return out;
321
+ } catch { return null; }
322
+ }
323
+
307
324
  /** Parse ?game= / ?song= / ?video= into one narrowing, or null. */
308
325
  export function onlyOf(params) {
309
326
  for (const kind of ['game', 'song', 'video']) {
@@ -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.7.0';
2
+ export const STUDIO_VERSION_TAG = '0.9.0';