@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/README.md +56 -4
- package/bin/homie-studio.mjs +173 -5
- package/lib/build.mjs +2 -0
- package/lib/check.mjs +139 -34
- package/lib/look.mjs +3 -2
- package/lib/media.mjs +8 -1
- package/lib/port-check.mjs +29 -6
- package/lib/progress.mjs +420 -0
- package/lib/scaffold.mjs +13 -1
- package/lib/site.mjs +8 -1
- package/lib/template-history.json +157 -0
- package/lib/upgrade.mjs +285 -0
- package/media/MEDIA.md +13 -0
- package/package.json +1 -1
- package/site/SITE.md +51 -6
- package/worker/index.mjs +50 -21
- package/worker/pages.mjs +67 -11
- package/worker/site.mjs +64 -19
- package/worker/stats.mjs +17 -0
- package/worker/version.mjs +1 -1
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,
|
|
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
|
-
/**
|
|
167
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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
|
|
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-
|
|
66
|
-
.room.at-bottom-
|
|
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
|
-
|
|
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
|
|
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-${
|
|
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
|
-
/**
|
|
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:${
|
|
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
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
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']) {
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.9.0';
|