@homie-rocks/studio 0.8.0 → 0.10.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
 
@@ -87,9 +90,71 @@ async function postsOf(env, origin) {
87
90
  } catch { return []; }
88
91
  }
89
92
 
90
- async function claimOf(env) {
93
+ /*
94
+ * THE SITE CLAIMS ITSELF IN THE DIRECTORY (0.10.0). The homie.rocks directory lists a studio only when its site
95
+ * serves the claim the directory handed out for that exact address, which proves the studio controls the site.
96
+ * Before 0.10.0 `homie-studio deploy` fetched the claim and stored it in D1 from the person's computer; a site built
97
+ * by Cloudflare's Workers Builds has no such step. So the site asks for its own claim the first time its manifest
98
+ * is read (by the directory publishing it, or `deploy` reading it once), for the address it is being read at, and
99
+ * keeps it in its D1 (`meta`, `homie_claim:<origin>`).
100
+ *
101
+ * It never asks from a Preview (HOMIE_PREVIEW: a Preview is a branch under review, not the studio), from a studio
102
+ * whose studio.json says `homie.directory: false`, or without its D1. A directory that does not answer is asked
103
+ * again at most once a minute per address; the page answers without a claim meanwhile.
104
+ */
105
+ const CLAIM = /^[a-f0-9]{16,128}$/;
106
+ const claimed = new Map();
107
+ const claimMissed = new Map();
108
+
109
+ async function storedClaim(env, origin) {
91
110
  if (!env.DB) return null;
92
- try { return (await env.DB.prepare('SELECT value FROM meta WHERE key = ?').bind('homie_claim').first())?.value ?? null; } catch { return null; }
111
+ try {
112
+ const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2)').bind(`homie_claim:${origin}`, 'homie_claim').all()).results ?? [];
113
+ return { own: rows.find((r) => r.key === `homie_claim:${origin}`)?.value ?? null, older: rows.find((r) => r.key === 'homie_claim')?.value ?? null };
114
+ } catch { return null; }
115
+ }
116
+
117
+ export function directoryOf(cat) {
118
+ const d = cat?.studio?.directory;
119
+ if (d === null || d === false) return null;
120
+ const at = String(d ?? 'https://homie.rocks').replace(/\/+$/, '');
121
+ try {
122
+ const u = new URL(at);
123
+ return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(u.hostname)) ? u.origin : null;
124
+ } catch { return null; }
125
+ }
126
+
127
+ /** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
128
+ async function claimOf(env, cat, url) {
129
+ const origin = url.origin;
130
+ if (claimed.has(origin)) return claimed.get(origin);
131
+ const kept = await storedClaim(env, origin);
132
+ if (kept?.own) { claimed.set(origin, kept.own); return kept.own; }
133
+ const directory = directoryOf(cat);
134
+ const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
135
+ const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
136
+ && Date.now() - (claimMissed.get(origin) ?? 0) > 60_000;
137
+ if (may) {
138
+ try {
139
+ const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}`, {
140
+ headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION_TAG} (site claim)` }, signal: AbortSignal.timeout(5000),
141
+ });
142
+ const body = await res.json().catch(() => null);
143
+ if (res.ok && CLAIM.test(String(body?.claim ?? ''))) {
144
+ await env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim).run();
145
+ claimed.set(origin, body.claim);
146
+ return body.claim;
147
+ }
148
+ } catch { /* the directory is optional for a live site */ }
149
+ claimMissed.set(origin, Date.now());
150
+ }
151
+ return kept?.older ?? null;
152
+ }
153
+
154
+ /** A copy of the public template nobody has named yet shows the name typed into Cloudflare's form (STUDIO_NAME). */
155
+ function named(cat, env) {
156
+ if (!cat?.studio?.template || !env.STUDIO_NAME) return cat;
157
+ return { ...cat, studio: { ...cat.studio, name: String(env.STUDIO_NAME).slice(0, 60) } };
93
158
  }
94
159
 
95
160
  /** Every public room with people in it, per game (the Lobby's list), and the number playing per game. */
@@ -163,18 +228,34 @@ async function mediaObject(request, env, key) {
163
228
  return new Response(obj.body, { headers });
164
229
  }
165
230
 
166
- /** A catalogue media row as the directory reads it (absolute addresses). */
167
- function mediaRow(e, origin, kind) {
231
+ /**
232
+ * A catalogue media row as the directory reads it (absolute addresses). A song's cover (a video's poster) is its
233
+ * own, else the manifest's default, else the landing still of the game it was made for (site.mjs mediaArt).
234
+ */
235
+ function mediaRow(e, origin, kind, cat) {
168
236
  const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u ?? null);
169
237
  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
238
  return {
172
239
  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),
240
+ page: `${origin}/${kind}/${e.slug}/`, [kind === 'music' ? 'audio' : 'video']: abs(main?.url), [kind === 'music' ? 'cover' : 'poster']: abs(mediaArt(e, kind, cat)),
174
241
  credits: e.credits ?? null, rights: e.rights ?? null, for: e.for ?? null,
175
242
  };
176
243
  }
177
244
 
245
+ /**
246
+ * THE ARCADE KNOCK, ANSWERED AT THE ROOT. A game made with Homie's arcade controls (@homie-rocks/arcade, and the
247
+ * older builds of it that Homie's own games carry) asks its page's own origin for a Homie box with a GET on
248
+ * /__homie/call, and a score goes there as a POST. The game's frame is /<game>/__game/, so that absolute path lands
249
+ * 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
250
+ * good and refuses a call in words; it answers 200, so no browser logs a failed load for it, and a studio needs no
251
+ * wrapper Worker of its own for this. A preflight (a POST's content-type) is answered too.
252
+ */
253
+ const KNOCK_CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '86400' };
254
+ function notAHomie(request) {
255
+ if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...KNOCK_CORS, 'cache-control': 'no-store' } });
256
+ 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);
257
+ }
258
+
178
259
  /** The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). */
179
260
  async function gameDocument(request, env, url, game, meta, cat) {
180
261
  const asked = url.searchParams.get('room');
@@ -284,9 +365,16 @@ async function route(request, env, ctx) {
284
365
  const parts = path.split('/').filter(Boolean);
285
366
  const read = request.method === 'GET' || request.method === 'HEAD';
286
367
  let catP = null;
287
- const getCat = () => (catP ??= catalogue(env, url.origin));
368
+ const getCat = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
288
369
 
370
+ // "Connect to Claude" (the template's first-run band): the directory's setup page, for this site's address.
371
+ if (path === '/_studio/connect' && read) {
372
+ const directory = directoryOf(await getCat());
373
+ if (!directory || env.HOMIE_PREVIEW === '1') return json({ ok: false, error: 'no-directory', message: 'This studio is not connected to a directory.' }, 404);
374
+ return Response.redirect(`${directory}/studio/setup/connect?site=${encodeURIComponent(url.origin)}`, 302);
375
+ }
289
376
  if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: getCat });
377
+ if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
290
378
  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
379
  if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
292
380
  if (path === '/api/stats') {
@@ -304,7 +392,10 @@ async function route(request, env, ctx) {
304
392
  }
305
393
  if (path === '/.well-known/homie-studio.json') {
306
394
  const cat = await getCat();
307
- const played = cat.studio?.stats?.share ? await playedThisWeek(env) : null;
395
+ const sharing = Boolean(cat.studio?.stats?.share);
396
+ const [played, byGame] = sharing ? await Promise.all([playedThisWeek(env), playedByGame(env)]) : [null, null];
397
+ // studio.json `"rooms": { "share": false }` keeps the rooms off the hub: a manifest that names no `rooms` shares none.
398
+ const shareRooms = cat.studio?.rooms?.share !== false;
308
399
  return json({
309
400
  v: 1,
310
401
  kind: 'homie-studio',
@@ -313,16 +404,25 @@ async function route(request, env, ctx) {
313
404
  ...(cat.studio?.tagline ? { tagline: cat.studio.tagline } : {}),
314
405
  site: url.origin,
315
406
  studio: cat.studio?.version ?? null,
316
- 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')),
407
+ claim: await claimOf(env, cat, url),
408
+ // What is deployed: the commit, when, and the marks of the newest changes (a merged pull request is live when
409
+ // its mark is here). A Preview says it is one.
410
+ ...(cat.studio?.build ? { build: { commit: cat.studio.build.commit ?? null, branch: cat.studio.build.branch ?? null, at: cat.studio.build.at ?? null, changes: (cat.studio.build.changes ?? []).slice(0, 50), ...(env.HOMIE_PREVIEW === '1' ? { preview: true } : {}) } } : {}),
411
+ games: (cat.games ?? []).map((g) => {
412
+ // The card picture is the landing's hero still (what the landing leads with), else the game's cover.
413
+ const cover = gameCover(g);
414
+ return {
415
+ id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
416
+ page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: cover ? (cover.startsWith('/') ? `${url.origin}${cover}` : cover) : null,
417
+ // Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
418
+ ...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
419
+ };
420
+ }),
421
+ songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music', cat)),
422
+ videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos', cat)),
323
423
  // The studio's posts, for the hub (the full text is in /posts/feed.json).
324
424
  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`,
425
+ ...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
326
426
  // Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
327
427
  ...(played ? { played } : {}),
328
428
  }, 200, { 'access-control-allow-origin': '*' });
@@ -334,7 +434,9 @@ async function route(request, env, ctx) {
334
434
  if (path === '/api/rooms') {
335
435
  const cat = await getCat();
336
436
  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': '*' });
437
+ // Short-lived and cacheable: the hub reads every studio's rooms at most every 45 s, and a room's count is a
438
+ // few seconds stale at worst. (The site's own pages ask with cache: 'no-store'.)
439
+ 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
440
  }
339
441
  if (path.startsWith('/media/')) {
340
442
  if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
@@ -473,10 +575,8 @@ async function route(request, env, ctx) {
473
575
  const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
474
576
  return stub.fetch(new Request(target, request));
475
577
  }
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
- }
578
+ // The same knock, relative to the game's own page: the same answer (see notAHomie).
579
+ if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
480
580
  if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') return gameDocument(request, env, url, game, meta, cat);
481
581
  if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
482
582
  }
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.8.0';
2
+ export const STUDIO_VERSION_TAG = '0.10.0';