@homie-rocks/studio 0.3.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,7 +27,23 @@ 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
49
  function page(title, body, { extraHead = '', viewport = 'width=device-width, initial-scale=1, viewport-fit=cover' } = {}) {
@@ -37,9 +53,39 @@ function page(title, body, { extraHead = '', viewport = 'width=device-width, ini
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">