@homie-rocks/studio 0.2.0 → 0.5.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/pages.mjs CHANGED
@@ -27,19 +27,65 @@ header.top { display: flex; align-items: baseline; justify-content: space-betwee
27
27
  .hero p { color: var(--dim); max-width: 60ch; font-size: 18px; margin: 0 0 28px; }
28
28
  .row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
29
29
  footer { margin-top: 48px; color: var(--dim); font-size: 13px; }
30
- @media (max-width: 540px) { .wrap { padding: 20px 16px 48px; } .btn { width: 100%; } }
30
+ h2.section { margin: 44px 0 0; font-size: 15px; letter-spacing: .08em; text-transform: uppercase; color: var(--dim); }
31
+ h2.section a { text-decoration: none; }
32
+ .card .kind { color: var(--accent); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; }
33
+ .thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 12px; background: #000; display: block; }
34
+ .thumb.square { aspect-ratio: 1; }
35
+ .player { margin: 8px 0 20px; }
36
+ .player audio { width: 100%; }
37
+ .player video { width: 100%; max-height: 78vh; border-radius: 14px; background: #000; display: block; }
38
+ .player video.vertical { max-width: 420px; margin: 0 auto; }
39
+ .facts { color: var(--dim); font-size: 14px; display: flex; gap: 16px; flex-wrap: wrap; margin: 0 0 18px; }
40
+ .rights { color: var(--dim); font-size: 13px; border-top: 1px solid var(--line); padding-top: 14px; margin-top: 24px; max-width: 70ch; }
41
+ .lyrics { white-space: pre-wrap; font-size: 17px; line-height: 1.6; max-width: 60ch; }
42
+ .files { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; max-width: 560px; }
43
+ .files a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 14px; border: 1px solid var(--line); border-radius: 12px; text-decoration: none; }
44
+ .files span { color: var(--dim); font-size: 13px; }
45
+ .cover { width: min(320px, 100%); aspect-ratio: 1; object-fit: cover; border-radius: 16px; float: right; margin: 0 0 16px 24px; }
46
+ @media (max-width: 540px) { .wrap { padding: 20px 16px 48px; } .btn { width: 100%; } .cover { float: none; margin: 0 0 16px; width: 100%; } }
31
47
  `;
32
48
 
33
- function page(title, body, { extraHead = '' } = {}) {
49
+ function page(title, body, { extraHead = '', viewport = 'width=device-width, initial-scale=1, viewport-fit=cover' } = {}) {
34
50
  return new Response(`<!doctype html>
35
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
51
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="${viewport}">
36
52
  <title>${esc(title)}</title><link rel="icon" href="data:,"><style>${BASE_CSS}</style>${extraHead}</head>
37
53
  <body>${body}</body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
38
54
  }
39
55
 
56
+ const fmtTime = (s) => (Number.isFinite(s) ? `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}` : '');
57
+ const fileOf = (e, role) => (e.files ?? []).find((f) => f.role === role) ?? null;
58
+ const KIND_WORD = { song: 'Song', score: 'Score', loop: 'Loop', stem: 'Stem', sfx: 'Sound', trailer: 'Trailer', 'music-video': 'Music video', cutscene: 'Cutscene', clip: 'Clip' };
59
+
60
+ function songCard(e) {
61
+ const cover = fileOf(e, 'cover');
62
+ return `
63
+ <article class="card">
64
+ ${cover ? `<img class="thumb square" src="${esc(cover.url)}" alt="" loading="lazy">` : ''}
65
+ <span class="kind">${esc(KIND_WORD[e.kind] ?? 'Music')}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span>
66
+ <h2>${esc(e.title)}</h2>
67
+ <p>${esc(e.blurb)}</p>
68
+ <div class="row"><a class="btn" href="/music/${esc(e.slug)}/">Listen</a></div>
69
+ </article>`;
70
+ }
71
+
72
+ function videoCard(e) {
73
+ const poster = fileOf(e, 'poster');
74
+ return `
75
+ <article class="card">
76
+ ${poster ? `<img class="thumb" src="${esc(poster.url)}" alt="" loading="lazy">` : ''}
77
+ <span class="kind">${esc(KIND_WORD[e.kind] ?? 'Video')}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span>
78
+ <h2>${esc(e.title)}</h2>
79
+ <p>${esc(e.blurb)}</p>
80
+ <div class="row"><a class="btn" href="/videos/${esc(e.slug)}/">Watch</a></div>
81
+ </article>`;
82
+ }
83
+
40
84
  export function homePage(cat, live = {}) {
41
85
  const name = cat.studio?.name ?? 'Studio';
42
86
  const games = cat.games ?? [];
87
+ const songs = cat.songs ?? [];
88
+ const videos = cat.videos ?? [];
43
89
  const cards = games.map((g) => `
44
90
  <article class="card">
45
91
  <h2>${esc(g.name)}</h2>
@@ -48,12 +94,88 @@ export function homePage(cat, live = {}) {
48
94
  <div class="row"><a class="btn" href="/${esc(g.id)}/play">Play</a><a class="btn ghost" href="/${esc(g.id)}/">About</a></div>
49
95
  </article>`).join('');
50
96
  return page(name, `<div class="wrap">
51
- <header class="top"><a class="brand" href="/">${esc(name)}</a><span class="tag">Press Play and you are in a live room.</span></header>
52
- ${games.length ? `<section class="grid">${cards}</section>` : '<p class="tag" style="margin-top:28px">No games published yet.</p>'}
97
+ <header class="top"><a class="brand" href="/">${esc(name)}</a><span class="tag">${games.length ? 'Press Play and you are in a live room.' : 'Games, music and videos.'}</span></header>
98
+ ${games.length ? `${songs.length || videos.length ? '<h2 class="section">Games</h2>' : ''}<section class="grid">${cards}</section>` : (songs.length || videos.length ? '' : '<p class="tag" style="margin-top:28px">Nothing published yet.</p>')}
99
+ ${videos.length ? `<h2 class="section"><a href="/videos/">Videos</a></h2><section class="grid">${videos.map(videoCard).join('')}</section>` : ''}
100
+ ${songs.length ? `<h2 class="section"><a href="/music/">Music</a></h2><section class="grid">${songs.map(songCard).join('')}</section>` : ''}
53
101
  <footer>${esc(name)} runs on its own Cloudflare. Made with Homie.</footer>
54
102
  </div>`);
55
103
  }
56
104
 
105
+ /** /music/ and /videos/: every published entry of one kind. */
106
+ export function mediaIndexPage(cat, kind) {
107
+ const name = cat.studio?.name ?? 'Studio';
108
+ const list = (kind === 'music' ? cat.songs : cat.videos) ?? [];
109
+ const title = kind === 'music' ? 'Music' : 'Videos';
110
+ return page(`${title} · ${name}`, `<div class="wrap">
111
+ <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><span class="tag">${esc(title)}</span></header>
112
+ ${list.length ? `<section class="grid">${list.map(kind === 'music' ? songCard : videoCard).join('')}</section>` : `<p class="tag" style="margin-top:28px">No ${kind === 'music' ? 'music' : 'videos'} published yet.</p>`}
113
+ </div>`);
114
+ }
115
+
116
+ function rightsLine(e) {
117
+ const bits = [];
118
+ if (e.credits) bits.push(esc(e.credits));
119
+ const r = e.rights;
120
+ if (r && typeof r === 'object') {
121
+ if (r.commercial === true) bits.push(`Licensed for commercial use${r.plan ? ` (made on the provider's ${esc(r.plan)} plan)` : ''}.`);
122
+ else if (r.commercial === false) bits.push(`Not licensed for commercial use${r.plan ? ` (made on the provider's ${esc(r.plan)} plan)` : ''}.`);
123
+ if (r.attribution) bits.push(esc(r.attribution));
124
+ }
125
+ if (e.honesty) bits.push(esc(e.honesty));
126
+ return bits.length ? `<p class="rights">${bits.join(' ')}</p>` : '';
127
+ }
128
+
129
+ const ogTags = (props) => Object.entries(props).filter(([, v]) => v).map(([k, v]) => `<meta property="${esc(k)}" content="${esc(v)}">`).join('');
130
+
131
+ /** /music/<slug>/: the player, the facts, the words, the loops and stems another game may use. */
132
+ export function songPage(cat, e, origin = '') {
133
+ const name = cat.studio?.name ?? 'Studio';
134
+ const audio = fileOf(e, 'audio');
135
+ const cover = fileOf(e, 'cover');
136
+ const extras = (e.files ?? []).filter((f) => f.role === 'loop' || f.role === 'stem');
137
+ const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
138
+ const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
139
+ return page(`${e.title} · ${name}`, `<div class="wrap">
140
+ <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><a class="tag" href="/music/">Music</a></header>
141
+ <section class="hero">
142
+ ${cover ? `<img class="cover" src="${esc(cover.url)}" alt="">` : ''}
143
+ <h1>${esc(e.title)}</h1>
144
+ ${e.blurb ? `<p>${esc(e.blurb)}</p>` : ''}
145
+ <div class="player"><audio controls preload="metadata" src="${esc(audio.url)}"></audio></div>
146
+ <div class="facts">${[KIND_WORD[e.kind], e.duration ? fmtTime(e.duration) : null, e.bpm ? `${e.bpm} BPM` : null, e.key].filter(Boolean).map((x) => `<span>${esc(x)}</span>`).join('')}${game ? `<a href="/${esc(game.id)}/">${esc(game.name)}</a>` : ''}</div>
147
+ </section>
148
+ ${e.lyrics ? `<h2 class="section">Words</h2><div class="lyrics">${esc(e.lyrics)}</div>` : ''}
149
+ ${extras.length ? `<h2 class="section">Loops and stems</h2><ul class="files">${extras.map((f) => `<li><a href="${esc(f.url)}" download><b>${esc(f.name ?? (f.role === 'loop' ? 'Loop' : 'Stem'))}</b><span>${esc([f.role, f.bars ? `${f.bars} bars` : null, f.bytes ? `${Math.max(1, Math.round(f.bytes / 1024))} KB` : null].filter(Boolean).join(' · '))}</span></a></li>`).join('')}</ul>` : ''}
150
+ ${rightsLine(e)}
151
+ </div>`, { extraHead: ogTags({ 'og:type': 'music.song', 'og:title': e.title, 'og:description': e.blurb, 'og:audio': abs(audio.url), 'og:image': abs(cover?.url) }) });
152
+ }
153
+
154
+ /** /videos/<slug>/: the player (16:9, or the 9:16 cut on a portrait phone), captions, credits. */
155
+ export function videoPage(cat, e, origin = '') {
156
+ const name = cat.studio?.name ?? 'Studio';
157
+ const video = fileOf(e, 'video');
158
+ const vertical = fileOf(e, 'vertical');
159
+ const poster = fileOf(e, 'poster');
160
+ const captions = fileOf(e, 'captions');
161
+ const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
162
+ const song = e.for?.song && (cat.songs ?? []).find((m) => m.slug === e.for.song);
163
+ const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
164
+ // A portrait phone gets the 9:16 cut when there is one; everything else the 16:9 cut.
165
+ const pick = vertical ? `<script>(function(){var v=document.querySelector('video[data-main]');if(!v)return;if(matchMedia('(orientation: portrait) and (max-width: 700px)').matches){v.src=${JSON.stringify(vertical.url).replace(/</g, '\u003c')};v.classList.add('vertical');}}());</script>` : '';
166
+ return page(`${e.title} · ${name}`, `<div class="wrap">
167
+ <header class="top"><a class="brand" href="/" style="font-size:22px">${esc(name)}</a><a class="tag" href="/videos/">Videos</a></header>
168
+ <section style="padding-top:28px">
169
+ <div class="player"><video data-main controls playsinline preload="metadata" src="${esc(video.url)}"${poster ? ` poster="${esc(poster.url)}"` : ''}>${captions ? `<track kind="captions" src="${esc(captions.url)}" srclang="en" label="English">` : ''}</video></div>
170
+ <h1 style="font-size:clamp(30px,6vw,56px);margin:0 0 10px;letter-spacing:-.02em">${esc(e.title)}</h1>
171
+ ${e.blurb ? `<p class="tag" style="font-size:17px;max-width:62ch">${esc(e.blurb)}</p>` : ''}
172
+ <div class="facts">${[KIND_WORD[e.kind], e.duration ? fmtTime(e.duration) : null].filter(Boolean).map((x) => `<span>${esc(x)}</span>`).join('')}${song ? `<a href="/music/${esc(song.slug)}/">${esc(song.title)}</a>` : ''}</div>
173
+ ${game ? `<div class="row"><a class="btn" href="/${esc(game.id)}/play">Play ${esc(game.name)}</a>${vertical ? `<a class="btn ghost" href="${esc(vertical.url)}">Vertical cut</a>` : ''}</div>` : (vertical ? `<div class="row"><a class="btn ghost" href="${esc(vertical.url)}">Vertical cut</a></div>` : '')}
174
+ </section>
175
+ ${rightsLine(e)}
176
+ </div>${pick}`, { extraHead: ogTags({ 'og:type': 'video.other', 'og:title': e.title, 'og:description': e.blurb, 'og:video': abs(video.url), 'og:image': abs(poster?.url) }) });
177
+ }
178
+
57
179
  export function gamePage(cat, g, playing = 0) {
58
180
  const name = cat.studio?.name ?? 'Studio';
59
181
  return page(`${g.name} · ${name}`, `<div class="wrap">
@@ -61,7 +183,7 @@ export function gamePage(cat, g, playing = 0) {
61
183
  <section class="hero">
62
184
  <h1>${esc(g.name)}</h1>
63
185
  <p>${esc(g.blurb)}</p>
64
- <div class="row"><a class="btn" href="/${esc(g.id)}/play">Play now</a><a class="btn ghost" href="/${esc(g.id)}/play?screen=1">Big screen</a></div>
186
+ <div class="row"><a class="btn" href="/${esc(g.id)}/play">Play now</a><a class="btn ghost" href="/${esc(g.id)}/tv">Big screen</a></div>
65
187
  <div class="meta" style="margin-top:18px"><span>${esc(g.players?.max ? `1–${g.players.max} players` : 'multiplayer')}</span>${g.roundSeconds ? `<span>${esc(g.roundSeconds)} s rounds</span>` : ''}<span>phone or computer</span></div>
66
188
  </section>
67
189
  </div>`);
@@ -77,8 +199,10 @@ export function notFoundPage(why) {
77
199
  * once (seated, no waiting), keeps the seat token for a reload, and keeps the
78
200
  * room's facts in `window.__shell` (what a test or a big screen reads).
79
201
  */
80
- export function playPage(cat, g) {
81
- const css = `html, body { height: 100%; overflow: hidden; overscroll-behavior: none; background: #04060c; }
202
+ export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null } = {}) {
203
+ // The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
204
+ // (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
205
+ const css = `html, body { height: 100%; overflow: hidden; overscroll-behavior: none; background: #04060c; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
82
206
  iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #04060c; touch-action: none; }
83
207
  .chip { position: fixed; left: 10px; bottom: max(10px, env(safe-area-inset-bottom)); z-index: 5; transition: opacity .6s; }
84
208
  .chip.quiet { opacity: 0; }
@@ -87,15 +211,27 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
87
211
  .card { position: fixed; right: 12px; bottom: 12px; z-index: 4; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: rgba(8,12,22,.82); border: 1px solid rgba(255,255,255,.14); color: #e8ecf5; font: 13px/1.35 ui-sans-serif, system-ui, sans-serif; }
88
212
  .card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #ffcf5a; }
89
213
  .card ol { margin: 0; padding-left: 18px; }
214
+ .join { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 6; display: flex; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 16px; background: rgba(8,12,22,.78); border: 1px solid rgba(255,255,255,.14); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 22px)/1.3 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
215
+ .join.join-top-left { top: max(16px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); right: auto; bottom: auto; }
216
+ .join.join-top-right { top: max(16px, env(safe-area-inset-top)); bottom: auto; }
217
+ .join.join-bottom-left { left: max(16px, env(safe-area-inset-left)); right: auto; }
218
+ .join .qr { width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
219
+ .join .qr svg { width: 100%; height: 100%; display: block; }
220
+ .join b { display: block; color: #ffcf5a; font-size: 1.15em; margin-bottom: 4px; }
221
+ .join span { opacity: .85; word-break: break-all; }
90
222
  [hidden] { display: none !important; }`;
91
- const boot = { game: g.id, name: g.name };
223
+ const boot = { game: g.id, name: g.name, screen: Boolean(screen), ...(room ? { room } : {}) };
224
+ // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
225
+ const corner = ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.join) ? g.screen.join : 'bottom-right';
226
+ const joinCard = screen && joinUrl ? `<div class="join join-${corner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
92
227
  return page(`${g.name} · play`, `
93
228
  <iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen; autoplay; gamepad"></iframe>
94
229
  <div class="chip" data-chip><a href="/${esc(g.id)}/">← ${esc(g.name)}</a> · <span data-status>finding a room…</span></div>
95
230
  <div class="card" data-results hidden></div>
96
231
  <div class="card" data-screen hidden></div>
232
+ ${joinCard}
97
233
  <script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
98
- <script>${SHELL_JS}</script>`, { extraHead: `<style>${css}</style>` });
234
+ <script>${SHELL_JS}</script>`, { extraHead: `<style>${css}</style>`, viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover' });
99
235
  }
100
236
 
101
237
  /* The shell script (runs on the site's own origin; the game runs in the sandboxed frame). */
@@ -103,7 +239,7 @@ const SHELL_JS = String.raw`(function () {
103
239
  'use strict';
104
240
  var boot = window.__HOMIE_PLAY;
105
241
  var params = new URLSearchParams(location.search);
106
- var screenMode = params.get('screen') === '1';
242
+ var screenMode = params.get('screen') === '1' || boot.screen === true;
107
243
  var asked = params.get('hand');
108
244
  var device = asked === 'phone' || asked === 'desk' || asked === 'tv' ? asked : Math.min(innerWidth, innerHeight) <= 540 ? 'phone' : 'desk';
109
245
  var want = screenMode ? 'screen' : 'play';
@@ -134,6 +270,8 @@ const SHELL_JS = String.raw`(function () {
134
270
  if (token) q.set('k', token);
135
271
  if (params.get('name')) q.set('name', params.get('name'));
136
272
  if (params.get('debug') === '1') q.set('debug', '1');
273
+ // The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
274
+ ['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
137
275
  frame.src = '/' + boot.game + '/__game/?' + q.toString();
138
276
  frame.addEventListener('load', function () { try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
139
277
  window.addEventListener('pointerdown', function () { try { frame.focus(); } catch (e) {} }, { passive: true });
@@ -150,7 +288,7 @@ const SHELL_JS = String.raw`(function () {
150
288
  paint();
151
289
  });
152
290
  watch(room);
153
- if (screenMode) {
291
+ if (screenMode && !document.querySelector('[data-join]')) {
154
292
  var h2 = document.createElement('h2'); h2.textContent = 'Join on your phone';
155
293
  var div = document.createElement('div'); div.textContent = location.origin + '/' + boot.game + '/play';
156
294
  screenCard.append(h2, div); screenCard.hidden = false;
@@ -198,7 +336,11 @@ const SHELL_JS = String.raw`(function () {
198
336
  say(bits.join(' · ') || 'joining…');
199
337
  }
200
338
 
201
- fetch('/' + boot.game + '/api/lobby', { method: 'POST' })
339
+ // A named room (?room=, a friend's link, a test, the big screen's QR) skips the lobby; everyone else meets strangers.
340
+ var askedRoom = params.get('room');
341
+ if (boot.room) start(boot.room);
342
+ else if (askedRoom && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
343
+ else fetch('/' + boot.game + '/api/lobby', { method: 'POST' })
202
344
  .then(function (r) { return r.json(); })
203
345
  .then(function (j) { start(j.room || 'main'); })
204
346
  .catch(function () { start('main'); });