@x12i/pocket-videos 0.0.0-stage → 1.1.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/app.js ADDED
@@ -0,0 +1,959 @@
1
+ // Pocket Videos. One media element plays a playlist, track after track.
2
+ import {
3
+ featuredIndex, dailyCardIndex, formatTime, normalizeCatalog, lowerFirst,
4
+ emptyMarks, applyMark, trackMarkKey, playlistVisible, visibleTrackIndexes,
5
+ stepVisible, likedQueue, stepQueue,
6
+ } from './logic.js';
7
+
8
+ const $ = (sel, root = document) => root.querySelector(sel);
9
+ const media = $('#media');
10
+
11
+ const LS = { endMode: 'pv.endMode', last: 'pv.last', iosTip: 'pv.iosTipDismissed', marks: 'pv.marks' };
12
+ const store = {
13
+ get(key) { try { return localStorage.getItem(key); } catch { return null; } },
14
+ set(key, value) { try { localStorage.setItem(key, value); } catch { /* private mode: fine */ } },
15
+ };
16
+
17
+ const state = {
18
+ app: {}, playlists: [], cards: [],
19
+ playlistIndex: -1, trackIndex: 0,
20
+ playing: false,
21
+ finished: false, // the playlist ended in Pause mode, so Up next is showing
22
+ endMode: 'repeat',
23
+ view: 'home',
24
+ resume: null, // last position from the previous visit, offered as "Continue"
25
+ retried: false,
26
+ listPlaylistId: null,
27
+ queue: null, // 'liked' while the cross-playlist liked list is what plays next
28
+ marks: emptyMarks(),
29
+ homeFilter: 'all', // 'all' hides disliked; 'liked' shows only likes
30
+ trackFilter: 'all',
31
+ showDislikedHome: false,
32
+ showDislikedTracks: false,
33
+ lyricsOpen: false,
34
+ };
35
+
36
+ const term = (key) => (state.app.terms && state.app.terms[key]) || key;
37
+ const termLower = (key) => lowerFirst(term(key));
38
+
39
+ /* ---------------------------------------------------------------- tiny DOM helpers */
40
+
41
+ function h(tag, props, ...kids) {
42
+ const el = document.createElement(tag);
43
+ for (const [k, v] of Object.entries(props || {})) {
44
+ if (v == null || v === false) continue;
45
+ if (k === 'class') el.className = v;
46
+ else if (k === 'text') el.textContent = v;
47
+ else if (k === 'dataset') Object.assign(el.dataset, v);
48
+ else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
49
+ else el.setAttribute(k, v === true ? '' : v);
50
+ }
51
+ for (const kid of kids.flat()) {
52
+ if (kid == null || kid === false) continue;
53
+ el.append(kid.nodeType ? kid : document.createTextNode(kid));
54
+ }
55
+ return el;
56
+ }
57
+
58
+ const PATH = {
59
+ play: '<path d="M8 5.2v13.6a.6.6 0 0 0 .92.5l10.4-6.8a.6.6 0 0 0 0-1L8.92 4.7a.6.6 0 0 0-.92.5z" fill="currentColor"/>',
60
+ pause: '<rect x="6" y="5" width="4.2" height="14" rx="1.2" fill="currentColor"/><rect x="13.8" y="5" width="4.2" height="14" rx="1.2" fill="currentColor"/>',
61
+ back: '<path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>',
62
+ };
63
+ function svg(path, size = 24) {
64
+ const t = document.createElement('template');
65
+ t.innerHTML = `<svg viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true">${path}</svg>`;
66
+ return t.content.firstChild;
67
+ }
68
+
69
+ const abs = (url) => new URL(url, document.baseURI).href;
70
+ const mimeFor = (url) => (/\.png(\?|$)/i.test(url) ? 'image/png' : /\.webp(\?|$)/i.test(url) ? 'image/webp' : 'image/jpeg');
71
+ const currentPlaylist = () => state.playlists[state.playlistIndex] || null;
72
+ const isStandalone = () => window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
73
+ const isIOS = () => /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
74
+
75
+ function coverEl(playlist, cls = '') {
76
+ const fallback = () => h('div', { class: `cover-fallback ${cls}`.trim(), 'aria-hidden': 'true', text: playlist.title.slice(0, 1) });
77
+ if (!playlist.cover) return fallback();
78
+ const img = h('img', { src: playlist.cover, alt: '', width: 640, height: 640, decoding: 'async', class: cls });
79
+ img.addEventListener('error', () => img.replaceWith(fallback()), { once: true });
80
+ return img;
81
+ }
82
+
83
+ let toastTimer;
84
+ function toast(message, { action, timeout = 4500 } = {}) {
85
+ const el = $('#toast');
86
+ el.replaceChildren(
87
+ h('span', { text: message }),
88
+ action ? h('button', { class: 'linkbtn', type: 'button', onclick: action.fn }, action.label) : null,
89
+ );
90
+ el.hidden = false;
91
+ clearTimeout(toastTimer);
92
+ if (timeout) toastTimer = setTimeout(() => { el.hidden = true; }, timeout);
93
+ }
94
+
95
+ function showStatus(message) {
96
+ const el = $('#status');
97
+ el.textContent = message;
98
+ el.hidden = false;
99
+ }
100
+
101
+ function applyTerms() {
102
+ $('#home').setAttribute('aria-label', term('playlists'));
103
+ $('#backLabel').textContent = state.queue === 'liked' ? 'Liked' : `All ${termLower('playlists')}`;
104
+ $('#sideTitle').textContent = state.queue === 'liked' ? 'Liked' : term('tracks');
105
+ $('#endLabel').textContent = `When the ${termLower('playlist')} ends`;
106
+ $('#errText').textContent = `This ${termLower('track')} won't load.`;
107
+ $('#prevBtn').setAttribute('aria-label', `Previous ${termLower('track')}`);
108
+ $('#nextBtn').setAttribute('aria-label', `Next ${termLower('track')}`);
109
+ }
110
+
111
+ /* ---------------------------------------------------------------- marks */
112
+
113
+ function loadMarks() {
114
+ try {
115
+ const raw = JSON.parse(store.get(LS.marks) || 'null');
116
+ const marks = emptyMarks();
117
+ if (!raw || typeof raw !== 'object') return marks;
118
+ for (const scope of ['playlists', 'tracks']) {
119
+ const bag = raw[scope];
120
+ if (!bag || typeof bag !== 'object') continue;
121
+ for (const [k, v] of Object.entries(bag)) {
122
+ if (v === 'liked' || v === 'disliked') marks[scope][k] = v;
123
+ }
124
+ }
125
+ return marks;
126
+ } catch { return emptyMarks(); }
127
+ }
128
+ function saveMarks() { store.set(LS.marks, JSON.stringify(state.marks)); }
129
+ function playlistMark(id) { return state.marks.playlists[id] || 'none'; }
130
+ function trackMark(playlistId, src) { return state.marks.tracks[trackMarkKey(playlistId, src)] || 'none'; }
131
+
132
+ function markButtons(scope, key) {
133
+ const mark = (state.marks[scope] && state.marks[scope][key]) || 'none';
134
+ const press = (action) => (e) => {
135
+ e.stopPropagation();
136
+ e.preventDefault();
137
+ state.marks = applyMark(state.marks, scope, key, action);
138
+ saveMarks();
139
+ refreshAfterMark();
140
+ };
141
+ return h('span', { class: 'marks' },
142
+ h('button', {
143
+ class: 'mark like', type: 'button',
144
+ 'aria-pressed': String(mark === 'liked'),
145
+ 'aria-label': mark === 'liked' ? 'Remove like' : 'Like',
146
+ onclick: press('like'),
147
+ }, 'Like'),
148
+ h('button', {
149
+ class: 'mark dislike', type: 'button',
150
+ 'aria-pressed': String(mark === 'disliked'),
151
+ 'aria-label': mark === 'disliked' ? 'Remove dislike' : 'Dislike',
152
+ onclick: press('dislike'),
153
+ }, 'Dislike'));
154
+ }
155
+
156
+ function refreshAfterMark() {
157
+ if (state.view === 'home') renderHome();
158
+ else if (state.view === 'liked') renderLiked();
159
+ else if (state.view === 'player') renderPlayer();
160
+ renderMini();
161
+ }
162
+
163
+ function trackIndexes(playlist, { showDisliked }) {
164
+ return visibleTrackIndexes(playlist.tracks, (_i, t) => trackMark(playlist.id, t.src), {
165
+ filter: state.trackFilter,
166
+ showDisliked,
167
+ });
168
+ }
169
+ function playableIndexes(playlist) {
170
+ return trackIndexes(playlist, { showDisliked: false });
171
+ }
172
+ function firstOpenIndex(playlist) {
173
+ const vis = visibleTrackIndexes(playlist.tracks, (_i, t) => trackMark(playlist.id, t.src), { filter: 'all' });
174
+ return vis.length ? vis[0] : -1;
175
+ }
176
+ function nothingHere() { return `Nothing left to play in this ${termLower('playlist')}.`; }
177
+ function nothingLiked() { return `No liked ${termLower('tracks')} to play.`; }
178
+
179
+ function filterBar(filter, showDisliked, setFilter, setShow) {
180
+ return h('div', { class: 'filters' },
181
+ h('div', { class: 'seg', role: 'group', 'aria-label': 'Filter' },
182
+ h('button', { type: 'button', 'aria-pressed': String(filter === 'all'), onclick: () => setFilter('all') }, 'All'),
183
+ h('button', { type: 'button', 'aria-pressed': String(filter === 'liked'), onclick: () => setFilter('liked') }, 'Liked')),
184
+ filter === 'all' ? h('button', {
185
+ class: 'linkbtn show-disliked', type: 'button',
186
+ 'aria-pressed': String(showDisliked),
187
+ onclick: () => setShow(!showDisliked),
188
+ }, showDisliked ? 'Hide disliked' : 'Show disliked') : null);
189
+ }
190
+
191
+ /* ---------------------------------------------------------------- views */
192
+
193
+ let navDepth = 0;
194
+
195
+ function setView(view, { push = true } = {}) {
196
+ const was = state.view;
197
+ state.view = view;
198
+ $('#home').hidden = view !== 'home';
199
+ $('#liked').hidden = view !== 'liked';
200
+ $('#player').hidden = view !== 'player';
201
+ if (view === 'home') {
202
+ if (state.app.name) document.title = state.app.name;
203
+ renderHome();
204
+ } else if (view === 'liked') {
205
+ document.title = `Liked · ${state.app.name || 'Pocket Videos'}`;
206
+ renderLiked();
207
+ }
208
+ if (push && was !== view && (view === 'player' || view === 'liked')) {
209
+ navDepth += 1;
210
+ history.pushState({ v: view, depth: navDepth }, '');
211
+ }
212
+ if (!push) navDepth = (history.state && history.state.depth) || 0;
213
+ renderMini();
214
+ if (was !== view) window.scrollTo(0, 0);
215
+ }
216
+
217
+ window.addEventListener('popstate', () => {
218
+ navDepth = (history.state && history.state.depth) || 0;
219
+ const v = history.state && history.state.v;
220
+ if (v === 'player' || v === 'liked') setView(v, { push: false });
221
+ else setView('home', { push: false });
222
+ });
223
+
224
+ function backOne() {
225
+ if (navDepth > 0) history.back();
226
+ else setView('home', { push: false });
227
+ }
228
+ function backHome() {
229
+ if (navDepth > 0) history.go(-navDepth);
230
+ else setView('home', { push: false });
231
+ }
232
+
233
+ /* ---------------------------------------------------------------- home */
234
+
235
+ function installTip() {
236
+ if (isStandalone() || store.get(LS.iosTip) === '1' || !isIOS()) return null;
237
+ const name = state.app.name || 'Pocket Videos';
238
+ const safari = !/CriOS|FxiOS|EdgiOS|OPiOS|DuckDuckGo/i.test(navigator.userAgent);
239
+ const message = safari
240
+ ? `To add ${name} to your Home Screen, tap Share, then Add to Home Screen.`
241
+ : `To add ${name} to your Home Screen, open this page in Safari, tap Share, then Add to Home Screen.`;
242
+ return h('div', { class: 'tip', role: 'note' },
243
+ h('p', { text: message }),
244
+ h('button', {
245
+ class: 'btn small', type: 'button',
246
+ onclick: (e) => { store.set(LS.iosTip, '1'); e.currentTarget.closest('.tip').remove(); },
247
+ }, 'Got it'));
248
+ }
249
+
250
+ function countLabel(n, singular, plural) {
251
+ return `${n} ${n === 1 ? singular : plural}`;
252
+ }
253
+
254
+ function renderHome() {
255
+ applyTerms();
256
+ const home = $('#home');
257
+ const now = new Date();
258
+ const fi = featuredIndex(state.app.featuredStartDate, now, state.playlists.length);
259
+ const feat = state.playlists[fi];
260
+ const metaLine = [
261
+ `${term('playlist')} of the week`,
262
+ feat.artist,
263
+ feat.year,
264
+ countLabel(feat.tracks.length, termLower('track'), termLower('tracks')),
265
+ ].filter(Boolean).join(' · ');
266
+ const play = () => startPlaylist(fi, firstOpenIndex(feat));
267
+
268
+ const resume = state.playlistIndex < 0 ? state.resume : null;
269
+ const resumeBtn = resume
270
+ ? h('button', {
271
+ class: 'btn quiet', type: 'button',
272
+ onclick: () => startPlaylist(resume.playlistIndex, resume.trackIndex, { startAt: resume.time }),
273
+ }, `Continue: ${state.playlists[resume.playlistIndex].tracks[resume.trackIndex].title}`)
274
+ : null;
275
+
276
+ const nodes = [
277
+ installTip(),
278
+ h('div', { class: 'featured' },
279
+ h('button', { class: 'tablet', type: 'button', 'aria-label': `Play ${feat.title}`, onclick: play },
280
+ coverEl(feat), h('span', { class: 'fab' }, svg(PATH.play, 34))),
281
+ h('div', {},
282
+ h('h1', { text: feat.title }),
283
+ h('p', { class: 'meta', text: metaLine }),
284
+ h('div', { class: 'actions' },
285
+ h('button', { class: 'btn', type: 'button', onclick: play }, svg(PATH.play, 22), `Play ${termLower('playlist')}`),
286
+ resumeBtn,
287
+ markButtons('playlists', feat.id)))),
288
+ ];
289
+
290
+ if (state.cards.length) {
291
+ const card = state.cards[dailyCardIndex(now, state.cards.length)];
292
+ const ai = card.playlist ? state.playlists.findIndex((a) => a.id === card.playlist) : -1;
293
+ nodes.push(h('section', { class: 'today', 'aria-label': 'Today' },
294
+ h('p', { class: 'when', text: 'Today' }),
295
+ h('p', { class: 'hook', text: card.hook }),
296
+ h('p', { class: 'pivot', text: card.pivot }),
297
+ ai >= 0 ? h('button', { class: 'linkbtn', type: 'button', onclick: () => startPlaylist(ai, firstOpenIndex(state.playlists[ai])) }, `Play ${state.playlists[ai].title}`) : null));
298
+ }
299
+
300
+ const shown = state.playlists
301
+ .map((p, i) => ({ p, i }))
302
+ .filter(({ p }) => playlistVisible(playlistMark(p.id), { filter: state.homeFilter, showDisliked: state.showDislikedHome }));
303
+ const empty = state.homeFilter === 'liked'
304
+ ? `No liked ${termLower('playlists')}.`
305
+ : `Every ${termLower('playlist')} is hidden. Show disliked to bring one back.`;
306
+
307
+ nodes.push(
308
+ h('h2', { class: 'h2', text: term('playlists') }),
309
+ filterBar(state.homeFilter, state.showDislikedHome,
310
+ (f) => { state.homeFilter = f; renderHome(); },
311
+ (s) => { state.showDislikedHome = s; renderHome(); }),
312
+ shown.length
313
+ ? h('ul', { class: 'grid' }, shown.map(({ p, i }) => {
314
+ const locked = p.access === 'locked';
315
+ const mark = playlistMark(p.id);
316
+ return h('li', {},
317
+ h('div', { class: `album${locked ? ' locked' : ''}${mark === 'disliked' ? ' is-disliked' : ''}` },
318
+ h('button', {
319
+ class: 'album-open', type: 'button',
320
+ 'aria-label': `${locked ? 'Locked. ' : 'Play '}${p.title}`,
321
+ 'aria-current': i === state.playlistIndex ? 'true' : null,
322
+ onclick: () => startPlaylist(i, firstOpenIndex(p)),
323
+ },
324
+ h('span', { class: 'cover' }, coverEl(p), locked ? h('span', { class: 'badge', text: 'Locked' }) : null),
325
+ h('span', { class: 't', text: p.title }),
326
+ h('span', { class: 'y', text: [p.artist, p.year].filter(Boolean).join(' · ') })),
327
+ markButtons('playlists', p.id)));
328
+ }))
329
+ : h('p', { class: 'empty', text: empty }),
330
+ );
331
+
332
+ home.replaceChildren(...nodes.filter(Boolean));
333
+ }
334
+
335
+ /* ---------------------------------------------------------------- liked, across playlists */
336
+
337
+ function renderLiked() {
338
+ applyTerms();
339
+ const items = likedQueue(state.playlists, (id, src) => trackMark(id, src));
340
+ const rows = items.map((item, n) => {
341
+ const playlist = state.playlists[item.playlistIndex];
342
+ const track = playlist.tracks[item.trackIndex];
343
+ const current = state.playlistIndex === item.playlistIndex && state.trackIndex === item.trackIndex;
344
+ return h('li', { class: 'track-row' },
345
+ h('button', {
346
+ class: 'track', type: 'button',
347
+ 'aria-current': current ? 'true' : null,
348
+ onclick: () => startPlaylist(item.playlistIndex, item.trackIndex, { queue: 'liked' }),
349
+ },
350
+ h('span', { class: 'n', text: String(n + 1) }),
351
+ h('span', { class: 'who' },
352
+ h('span', { text: track.title }),
353
+ h('span', { class: 'st', text: playlist.title }))),
354
+ markButtons('tracks', trackMarkKey(playlist.id, track.src)));
355
+ });
356
+ $('#liked').replaceChildren(
357
+ h('button', { class: 'back', type: 'button', onclick: backOne }, svg(PATH.back, 22), 'Home'),
358
+ h('h1', { text: 'Liked' }),
359
+ h('p', { class: 'meta', text: items.length
360
+ ? countLabel(items.length, termLower('track'), termLower('tracks'))
361
+ : `No liked ${termLower('tracks')} yet.` }),
362
+ items.length ? h('ol', { class: 'tracklist' }, rows) : null,
363
+ );
364
+ }
365
+
366
+ /* ---------------------------------------------------------------- mini player */
367
+
368
+ function renderMini() {
369
+ const mini = $('#mini');
370
+ const playlist = currentPlaylist();
371
+ if (!playlist || state.view === 'player') { mini.hidden = true; return; }
372
+ const track = playlist.tracks[state.trackIndex];
373
+ mini.hidden = false;
374
+ mini.replaceChildren(
375
+ h('button', { class: 'open', type: 'button', 'aria-label': 'Open the player', onclick: () => setView('player') },
376
+ coverEl(playlist, 'mini-cover'),
377
+ h('span', { class: 'txt' },
378
+ h('span', { class: 'tt', text: state.finished ? `${playlist.title} finished` : track.title }),
379
+ h('span', { class: 'st', text: state.finished ? 'Tap to choose what is next' : playlist.title }))),
380
+ h('button', {
381
+ class: 'ctl', type: 'button', 'aria-label': state.playing ? 'Pause' : 'Play',
382
+ onclick: () => (media.paused ? playAction() : media.pause()),
383
+ }, svg(state.playing ? PATH.pause : PATH.play, 26)),
384
+ );
385
+ }
386
+
387
+ /* ---------------------------------------------------------------- player UI */
388
+
389
+ function applyKind(kind) {
390
+ const video = kind === 'video';
391
+ $('#art').classList.toggle('is-video', video);
392
+ $('#art').classList.toggle('is-audio', !video);
393
+ $('#stage').classList.toggle('is-video', video);
394
+ }
395
+
396
+ function openTrack(playlist, index) {
397
+ const playlistIndex = state.playlists.indexOf(playlist);
398
+ if (state.queue === 'liked') {
399
+ if (state.finished) startPlaylist(playlistIndex, index, { queue: 'liked' });
400
+ else {
401
+ state.playlistIndex = playlistIndex;
402
+ setTrack(index);
403
+ play();
404
+ }
405
+ return;
406
+ }
407
+ jumpTo(index);
408
+ }
409
+
410
+ function trackRow(playlist, index, { subtitle } = {}) {
411
+ const track = playlist.tracks[index];
412
+ const mark = trackMark(playlist.id, track.src);
413
+ return h('li', { class: `track-row${mark === 'disliked' ? ' is-disliked' : ''}` },
414
+ h('button', {
415
+ class: 'track', type: 'button',
416
+ 'aria-current': index === state.trackIndex && playlist === currentPlaylist() && !state.finished ? 'true' : null,
417
+ onclick: () => openTrack(playlist, index),
418
+ },
419
+ h('span', { class: 'n', text: String(index + 1) }),
420
+ subtitle
421
+ ? h('span', { class: 'who' }, h('span', { text: track.title }), h('span', { class: 'st', text: subtitle }))
422
+ : h('span', { text: track.title })),
423
+ markButtons('tracks', trackMarkKey(playlist.id, track.src)));
424
+ }
425
+
426
+ function renderPlayer() {
427
+ const playlist = currentPlaylist();
428
+ if (!playlist) return;
429
+ applyTerms();
430
+ const track = playlist.tracks[state.trackIndex];
431
+ applyKind(track.kind);
432
+
433
+ const cover = $('#pCover');
434
+ const coverSrc = playlist.cover || 'icons/icon-512.png';
435
+ if (cover.getAttribute('src') !== coverSrc) cover.setAttribute('src', coverSrc);
436
+ $('#pTrack').textContent = track.title;
437
+ $('#pAlbum').textContent = [playlist.title, playlist.artist].filter(Boolean).join(' · ');
438
+ $('#nowMarks').replaceChildren(markButtons('tracks', trackMarkKey(playlist.id, track.src)));
439
+ document.title = `${track.title} · ${playlist.title} · ${state.app.name}`;
440
+
441
+ $('#deck').hidden = state.finished;
442
+ $('#upnext').hidden = !state.finished;
443
+ if (state.finished) renderUpNext();
444
+
445
+ if (state.listPlaylistId !== playlist.id) {
446
+ state.listPlaylistId = playlist.id;
447
+ state.lyricsOpen = false;
448
+ }
449
+ $('#albumLinks').replaceChildren(...playlist.links.map((l) => h('li', {},
450
+ h('a', { href: l.url, target: '_blank', rel: 'noopener noreferrer', text: l.label }))));
451
+
452
+ let rows;
453
+ if (state.queue === 'liked') {
454
+ $('#listTools').replaceChildren();
455
+ $('#sideTitle').textContent = 'Liked';
456
+ rows = likedQueue(state.playlists, (id, src) => trackMark(id, src)).map((item) => {
457
+ const owner = state.playlists[item.playlistIndex];
458
+ return trackRow(owner, item.trackIndex, { subtitle: owner.title });
459
+ });
460
+ } else {
461
+ $('#sideTitle').textContent = term('tracks');
462
+ $('#listTools').replaceChildren(filterBar(state.trackFilter, state.showDislikedTracks,
463
+ (f) => { state.trackFilter = f; renderPlayer(); },
464
+ (s) => { state.showDislikedTracks = s; renderPlayer(); }));
465
+ const indexes = trackIndexes(playlist, { showDisliked: state.showDislikedTracks });
466
+ rows = indexes.map((i) => trackRow(playlist, i));
467
+ }
468
+ const emptyList = state.queue === 'liked'
469
+ ? `No liked ${termLower('tracks')} yet.`
470
+ : state.trackFilter === 'liked'
471
+ ? `No liked ${termLower('tracks')} in this ${termLower('playlist')}.`
472
+ : `Every ${termLower('track')} here is disliked.`;
473
+ $('#tracklist').replaceChildren(...(rows.length ? rows : [h('li', { class: 'empty', text: emptyList })]));
474
+
475
+ const lyrics = track.lyrics;
476
+ $('#lyricsWrap').hidden = !lyrics;
477
+ $('#lyricsText').textContent = lyrics;
478
+ $('#lyricsText').hidden = !state.lyricsOpen || !lyrics;
479
+ const lb = $('#lyricsBtn');
480
+ lb.textContent = state.lyricsOpen ? 'Hide lyrics' : 'Show lyrics';
481
+ lb.setAttribute('aria-expanded', String(state.lyricsOpen && !!lyrics));
482
+
483
+ syncEndMode();
484
+ syncPlaying();
485
+ }
486
+
487
+ function suggestionIndexes(current) {
488
+ const out = [];
489
+ const count = state.playlists.length;
490
+ for (let step = 1; step < count && out.length < 3; step++) {
491
+ const i = (current + step) % count;
492
+ if (!playlistVisible(playlistMark(state.playlists[i].id), { filter: 'all', showDisliked: state.showDislikedHome })) continue;
493
+ out.push(i);
494
+ }
495
+ return out;
496
+ }
497
+
498
+ function renderUpNext() {
499
+ const playlist = currentPlaylist();
500
+ const picks = suggestionIndexes(state.playlistIndex);
501
+ const liked = state.queue === 'liked';
502
+ const box = $('#upnext');
503
+ box.replaceChildren(
504
+ h('h3', { tabindex: '-1', text: picks.length ? 'Up next' : (liked ? 'That was everything you liked' : `That was the ${termLower('playlist')}`) }),
505
+ h('p', { class: 'done', text: liked ? 'Those were the liked ones.' : `${playlist.title} has finished.` }),
506
+ picks.length ? h('ul', {}, picks.map((i) => {
507
+ const a = state.playlists[i];
508
+ const locked = a.access === 'locked';
509
+ return h('li', {},
510
+ h('button', {
511
+ class: 'un', type: 'button', disabled: locked,
512
+ 'aria-label': `${locked ? 'Locked. ' : 'Play '}${a.title}`,
513
+ onclick: () => startPlaylist(i, firstOpenIndex(a)),
514
+ },
515
+ coverEl(a),
516
+ h('span', {}, h('span', { class: 't', text: a.title }), h('span', { class: 'y', text: [a.artist, a.year].filter(Boolean).join(' · ') })),
517
+ h('span', { class: 'go' }, svg(PATH.play, 22))));
518
+ })) : null,
519
+ h('button', {
520
+ class: 'btn quiet again', type: 'button',
521
+ onclick: () => {
522
+ if (liked) {
523
+ const items = likedQueue(state.playlists, (id, src) => trackMark(id, src));
524
+ if (!items.length) { toast(nothingLiked()); return; }
525
+ startPlaylist(items[0].playlistIndex, items[0].trackIndex, { queue: 'liked' });
526
+ return;
527
+ }
528
+ startPlaylist(state.playlistIndex, firstOpenIndex(playlist));
529
+ },
530
+ }, liked ? `Play liked ${termLower('tracks')} again` : `Play ${playlist.title} again`),
531
+ );
532
+ }
533
+
534
+ function syncEndMode() {
535
+ document.querySelectorAll('.endmode .seg button').forEach((b) => {
536
+ b.setAttribute('aria-pressed', String(b.dataset.mode === state.endMode));
537
+ });
538
+ }
539
+
540
+ function syncPlaying() {
541
+ const playing = state.playing;
542
+ document.body.classList.toggle('is-playing', playing);
543
+ $('.i-play').toggleAttribute('hidden', playing);
544
+ $('.i-pause').toggleAttribute('hidden', !playing);
545
+ $('#playBtn').setAttribute('aria-label', playing ? 'Pause' : 'Play');
546
+ if ('mediaSession' in navigator) navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
547
+ renderMini();
548
+ }
549
+
550
+ function showError(show) {
551
+ $('#pErr').hidden = !show;
552
+ }
553
+
554
+ /* ---------------------------------------------------------------- playback */
555
+
556
+ async function startPlaylist(playlistIndex, trackIndex = 0, { startAt = 0, queue = null } = {}) {
557
+ const playlist = state.playlists[playlistIndex];
558
+ if (!playlist) return;
559
+ if (playlist.access === 'locked') { toast(`${playlist.title} is locked for now.`); return; }
560
+ if (trackIndex < 0) { toast(queue === 'liked' ? nothingLiked() : nothingHere()); return; }
561
+ state.queue = queue;
562
+ if (queue !== 'liked') {
563
+ state.trackFilter = 'all';
564
+ state.showDislikedTracks = false;
565
+ }
566
+ state.playlistIndex = playlistIndex;
567
+ setTrack(trackIndex, { startAt });
568
+ setView('player');
569
+ await play();
570
+ }
571
+
572
+ function setTrack(index, { startAt = 0 } = {}) {
573
+ const playlist = currentPlaylist();
574
+ state.trackIndex = index;
575
+ state.finished = false;
576
+ state.retried = false;
577
+ showError(false);
578
+ const track = playlist.tracks[index];
579
+ applyKind(track.kind);
580
+ media.src = track.src;
581
+ if (startAt > 0) {
582
+ media.addEventListener('loadedmetadata', () => { try { media.currentTime = startAt; } catch { /* ignore */ } }, { once: true });
583
+ }
584
+ resetSeek();
585
+ updateMediaSession();
586
+ renderPlayer();
587
+ saveLast();
588
+ warmCache(playlist, index);
589
+ }
590
+
591
+ async function play() {
592
+ try {
593
+ await media.play();
594
+ } catch (err) {
595
+ if (err && err.name === 'NotAllowedError') {
596
+ state.playing = false; syncPlaying();
597
+ toast('Tap play to start.');
598
+ } else if (err && err.name !== 'AbortError') {
599
+ console.warn('play() failed', err);
600
+ }
601
+ }
602
+ }
603
+
604
+ function playAction() {
605
+ if (!state.finished) { play(); return; }
606
+ if (state.queue === 'liked') {
607
+ const items = likedQueue(state.playlists, (id, src) => trackMark(id, src));
608
+ if (!items.length) { toast(nothingLiked()); return; }
609
+ state.playlistIndex = items[0].playlistIndex;
610
+ setTrack(items[0].trackIndex);
611
+ play();
612
+ return;
613
+ }
614
+ const playlist = currentPlaylist();
615
+ const vis = playlist ? playableIndexes(playlist) : [];
616
+ if (!vis.length) { toast(nothingHere()); return; }
617
+ setTrack(vis[0]);
618
+ play();
619
+ }
620
+
621
+ function jumpTo(index) {
622
+ if (state.finished) { startPlaylist(state.playlistIndex, index, { queue: state.queue }); return; }
623
+ setTrack(index);
624
+ play();
625
+ }
626
+
627
+ function follow(action, { ended = false } = {}) {
628
+ if (action.type === 'empty') { toast(state.queue === 'liked' ? nothingLiked() : nothingHere()); return; }
629
+ if (action.type === 'end') {
630
+ if (!ended) media.pause();
631
+ finishPlaylist();
632
+ return;
633
+ }
634
+ if (action.type === 'edge') { media.currentTime = 0; return; }
635
+ if (action.item) state.playlistIndex = action.item.playlistIndex;
636
+ setTrack(action.item ? action.item.trackIndex : action.index);
637
+ play();
638
+ }
639
+
640
+ function next() {
641
+ if (state.queue === 'liked') {
642
+ follow(stepQueue(likedQueue(state.playlists, (id, src) => trackMark(id, src)), { playlistIndex: state.playlistIndex, trackIndex: state.trackIndex }, 1, state.endMode));
643
+ return;
644
+ }
645
+ const playlist = currentPlaylist();
646
+ if (!playlist) return;
647
+ follow(stepVisible(state.trackIndex, playableIndexes(playlist), 1, state.endMode));
648
+ }
649
+
650
+ function previous() {
651
+ if (!currentPlaylist()) return;
652
+ if (media.currentTime > 3) { media.currentTime = 0; return; }
653
+ if (state.queue === 'liked') {
654
+ follow(stepQueue(likedQueue(state.playlists, (id, src) => trackMark(id, src)), { playlistIndex: state.playlistIndex, trackIndex: state.trackIndex }, -1, state.endMode));
655
+ return;
656
+ }
657
+ follow(stepVisible(state.trackIndex, playableIndexes(currentPlaylist()), -1, state.endMode));
658
+ }
659
+
660
+ function onEnded() {
661
+ if (state.queue === 'liked') {
662
+ follow(stepQueue(likedQueue(state.playlists, (id, src) => trackMark(id, src)), { playlistIndex: state.playlistIndex, trackIndex: state.trackIndex }, 1, state.endMode), { ended: true });
663
+ return;
664
+ }
665
+ const playlist = currentPlaylist();
666
+ if (!playlist) return;
667
+ follow(stepVisible(state.trackIndex, playableIndexes(playlist), 1, state.endMode), { ended: true });
668
+ }
669
+
670
+ function finishPlaylist() {
671
+ state.finished = true;
672
+ state.playing = false;
673
+ saveLast();
674
+ renderPlayer();
675
+ if (state.view === 'player') $('#upnext h3')?.focus({ preventScroll: true });
676
+ }
677
+
678
+ /* ---------------------------------------------------------------- media events */
679
+
680
+ let seeking = false;
681
+ let lastSave = 0;
682
+ let lastPos = 0;
683
+
684
+ function setProgress(fraction) {
685
+ const seek = $('#seek');
686
+ seek.value = String(Math.round(fraction * 1000));
687
+ seek.style.setProperty('--p', `${fraction * 100}%`);
688
+ }
689
+ function resetSeek() {
690
+ setProgress(0);
691
+ $('#tCur').textContent = '0:00';
692
+ $('#tDur').textContent = '0:00';
693
+ }
694
+
695
+ media.addEventListener('play', () => { state.playing = true; syncPlaying(); });
696
+ media.addEventListener('pause', () => {
697
+ if (media.ended) return;
698
+ state.playing = false; syncPlaying(); saveLast();
699
+ });
700
+ media.addEventListener('ended', onEnded);
701
+ media.addEventListener('loadedmetadata', () => { $('#tDur').textContent = formatTime(media.duration); });
702
+ media.addEventListener('timeupdate', () => {
703
+ const d = media.duration;
704
+ const t = media.currentTime;
705
+ if (!seeking && Number.isFinite(d) && d > 0) {
706
+ setProgress(Math.min(t / d, 1));
707
+ $('#tCur').textContent = formatTime(t);
708
+ $('#seek').setAttribute('aria-valuetext', `${formatTime(t)} of ${formatTime(d)}`);
709
+ }
710
+ const now = performance.now();
711
+ if (now - lastSave > 3000) { lastSave = now; saveLast(); }
712
+ if ('mediaSession' in navigator && navigator.mediaSession.setPositionState && Number.isFinite(d) && d > 0 && now - lastPos > 1000) {
713
+ lastPos = now;
714
+ try { navigator.mediaSession.setPositionState({ duration: d, playbackRate: media.playbackRate || 1, position: Math.min(t, d) }); } catch { /* ignore */ }
715
+ }
716
+ });
717
+ media.addEventListener('error', () => {
718
+ if (!media.error || !currentPlaylist()) return;
719
+ if (!state.retried) {
720
+ state.retried = true;
721
+ const at = media.currentTime || 0;
722
+ setTimeout(() => {
723
+ media.load();
724
+ if (at > 0) media.addEventListener('loadedmetadata', () => { try { media.currentTime = at; } catch { /* ignore */ } }, { once: true });
725
+ play();
726
+ }, 600);
727
+ return;
728
+ }
729
+ state.playing = false;
730
+ syncPlaying();
731
+ showError(true);
732
+ });
733
+
734
+ const seekEl = $('#seek');
735
+ seekEl.addEventListener('input', () => {
736
+ seeking = true;
737
+ const d = media.duration;
738
+ seekEl.style.setProperty('--p', `${seekEl.value / 10}%`);
739
+ if (Number.isFinite(d)) $('#tCur').textContent = formatTime((seekEl.value / 1000) * d);
740
+ });
741
+ seekEl.addEventListener('change', () => {
742
+ const d = media.duration;
743
+ if (Number.isFinite(d)) media.currentTime = (seekEl.value / 1000) * d;
744
+ seeking = false;
745
+ });
746
+
747
+ /* ---------------------------------------------------------------- resume */
748
+
749
+ function saveLast() {
750
+ const playlist = currentPlaylist();
751
+ if (!playlist) return;
752
+ store.set(LS.last, JSON.stringify({
753
+ id: playlist.id,
754
+ track: state.finished ? 0 : state.trackIndex,
755
+ time: state.finished ? 0 : Math.floor(media.currentTime || 0),
756
+ queue: state.queue,
757
+ }));
758
+ }
759
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') saveLast(); });
760
+ window.addEventListener('pagehide', saveLast);
761
+
762
+ function loadLast() {
763
+ try {
764
+ const last = JSON.parse(store.get(LS.last) || 'null');
765
+ if (!last || last.queue === 'liked') return null;
766
+ const playlistIndex = state.playlists.findIndex((a) => a.id === last.id);
767
+ if (playlistIndex < 0) return null;
768
+ const trackIndex = Number.isInteger(last.track) ? last.track : 0;
769
+ if (trackIndex < 0 || trackIndex >= state.playlists[playlistIndex].tracks.length) return null;
770
+ return { playlistIndex, trackIndex, time: Math.max(0, Number(last.time) || 0) };
771
+ } catch { return null; }
772
+ }
773
+
774
+ /* ---------------------------------------------------------------- lock screen + headphones */
775
+
776
+ function updateMediaSession() {
777
+ if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
778
+ const playlist = currentPlaylist();
779
+ if (!playlist) return;
780
+ const track = playlist.tracks[state.trackIndex];
781
+ navigator.mediaSession.metadata = new MediaMetadata({
782
+ title: track.title,
783
+ artist: playlist.artist || state.app.name,
784
+ album: playlist.title,
785
+ artwork: playlist.cover ? [{ src: abs(playlist.cover), sizes: '640x640', type: mimeFor(playlist.cover) }] : [],
786
+ });
787
+ }
788
+
789
+ function bindMediaSession() {
790
+ if (!('mediaSession' in navigator)) return;
791
+ const set = (action, fn) => { try { navigator.mediaSession.setActionHandler(action, fn); } catch { /* unsupported action */ } };
792
+ set('play', () => playAction());
793
+ set('pause', () => media.pause());
794
+ set('previoustrack', () => previous());
795
+ set('nexttrack', () => next());
796
+ set('seekto', (d) => {
797
+ if (typeof d.seekTime !== 'number') return;
798
+ if (d.fastSeek && 'fastSeek' in media) media.fastSeek(d.seekTime);
799
+ else media.currentTime = d.seekTime;
800
+ });
801
+ }
802
+
803
+ /* ---------------------------------------------------------------- offline media cache */
804
+ // The playlist in play is copied into the Cache API, next track first, so it keeps playing
805
+ // if the connection drops. Only the current playlist is kept; others are removed.
806
+
807
+ const MEDIA_CACHE = 'pv-media';
808
+ let warmToken = 0;
809
+
810
+ async function warmCache(playlist, current) {
811
+ const token = ++warmToken;
812
+ if (!('caches' in window)) return;
813
+ if (navigator.connection && navigator.connection.saveData) return;
814
+ try {
815
+ const cache = await caches.open(MEDIA_CACHE);
816
+ const urls = playlist.tracks.map((t) => abs(t.src));
817
+ const keep = new Set(urls);
818
+ for (const req of await cache.keys()) if (!keep.has(req.url)) await cache.delete(req);
819
+ const order = [...urls.slice(current + 1), ...urls.slice(0, current + 1)];
820
+ for (const url of order) {
821
+ if (token !== warmToken) return;
822
+ if (await cache.match(url)) continue;
823
+ const res = await fetch(url);
824
+ if (res.status === 200) await cache.put(url, res);
825
+ }
826
+ } catch { /* offline or storage full: streaming still works */ }
827
+ }
828
+
829
+ /* ---------------------------------------------------------------- install + updates */
830
+
831
+ let installPrompt = null;
832
+ function setupInstall() {
833
+ const btn = $('#installBtn');
834
+ window.addEventListener('beforeinstallprompt', (e) => {
835
+ e.preventDefault();
836
+ installPrompt = e;
837
+ btn.hidden = false;
838
+ });
839
+ btn.addEventListener('click', async () => {
840
+ if (!installPrompt) return;
841
+ installPrompt.prompt();
842
+ try { await installPrompt.userChoice; } catch { /* ignore */ }
843
+ installPrompt = null;
844
+ btn.hidden = true;
845
+ });
846
+ window.addEventListener('appinstalled', () => {
847
+ btn.hidden = true;
848
+ document.querySelector('.tip')?.remove();
849
+ });
850
+ }
851
+
852
+ function registerServiceWorker() {
853
+ if (!('serviceWorker' in navigator)) return;
854
+ const hadController = !!navigator.serviceWorker.controller;
855
+ window.addEventListener('load', async () => {
856
+ try {
857
+ const reg = await navigator.serviceWorker.register('sw.js');
858
+ const offer = (worker) => toast('A new version is ready.', {
859
+ timeout: 0,
860
+ action: { label: 'Reload', fn: () => worker.postMessage({ type: 'SKIP_WAITING' }) },
861
+ });
862
+ if (reg.waiting && navigator.serviceWorker.controller) offer(reg.waiting);
863
+ reg.addEventListener('updatefound', () => {
864
+ const worker = reg.installing;
865
+ if (!worker) return;
866
+ worker.addEventListener('statechange', () => {
867
+ if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker);
868
+ });
869
+ });
870
+ let reloaded = false;
871
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
872
+ if (!hadController || reloaded) return;
873
+ reloaded = true;
874
+ location.reload();
875
+ });
876
+ document.addEventListener('visibilitychange', () => {
877
+ if (document.visibilityState === 'visible') reg.update().catch(() => {});
878
+ });
879
+ } catch (err) {
880
+ console.warn('Service worker not registered:', err);
881
+ }
882
+ });
883
+ }
884
+
885
+ /* ---------------------------------------------------------------- wiring + start */
886
+
887
+ function wire() {
888
+ $('#brand').addEventListener('click', (e) => { e.preventDefault(); if (state.view !== 'home') backHome(); });
889
+ $('#likedBtn').addEventListener('click', () => setView('liked'));
890
+ $('#backBtn').addEventListener('click', backOne);
891
+ $('#playBtn').addEventListener('click', () => (media.paused ? playAction() : media.pause()));
892
+ $('#prevBtn').addEventListener('click', previous);
893
+ $('#nextBtn').addEventListener('click', next);
894
+ $('#retryBtn').addEventListener('click', () => {
895
+ state.retried = false;
896
+ showError(false);
897
+ media.load();
898
+ play();
899
+ });
900
+ document.querySelectorAll('.endmode .seg button').forEach((b) => b.addEventListener('click', () => {
901
+ state.endMode = b.dataset.mode;
902
+ store.set(LS.endMode, state.endMode);
903
+ syncEndMode();
904
+ }));
905
+ $('#lyricsBtn').addEventListener('click', () => {
906
+ state.lyricsOpen = !state.lyricsOpen;
907
+ renderPlayer();
908
+ });
909
+ document.addEventListener('keydown', (e) => {
910
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || state.playlistIndex < 0) return;
911
+ const t = e.target;
912
+ if (t instanceof Element && t.closest('input, textarea, select, [contenteditable]')) return;
913
+ if (e.code === 'Space' && !(t instanceof Element && t.closest('button, a'))) {
914
+ e.preventDefault();
915
+ if (media.paused) playAction(); else media.pause();
916
+ } else if (e.key === 'ArrowRight') next();
917
+ else if (e.key === 'ArrowLeft') previous();
918
+ });
919
+ }
920
+
921
+ async function init() {
922
+ registerServiceWorker();
923
+ setupInstall();
924
+ bindMediaSession();
925
+ wire();
926
+ state.marks = loadMarks();
927
+
928
+ let raw;
929
+ try {
930
+ const res = await fetch('catalog.json', { cache: 'no-cache' });
931
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
932
+ raw = await res.json();
933
+ } catch (err) {
934
+ console.error('catalog.json failed to load:', err);
935
+ showStatus("The playlist list didn't load. Check your connection and reload. If you edited catalog.json, check it for a missing comma or quote.");
936
+ return;
937
+ }
938
+
939
+ const catalog = normalizeCatalog(raw);
940
+ catalog.warnings.forEach((w) => console.warn('[catalog]', w));
941
+ state.app = catalog.app;
942
+ if (!catalog.playlists.length) {
943
+ showStatus(`catalog.json has no playable ${termLower('playlists')}. Each ${termLower('playlist')} needs a title and at least one ${termLower('track')} with a title and a media file.`);
944
+ return;
945
+ }
946
+
947
+ state.playlists = catalog.playlists;
948
+ state.cards = catalog.cards;
949
+ $('#brand').textContent = state.app.name;
950
+ document.title = state.app.name;
951
+ const savedMode = store.get(LS.endMode);
952
+ state.endMode = savedMode === 'pause' || savedMode === 'repeat' ? savedMode : catalog.app.defaultEndMode;
953
+ state.resume = loadLast();
954
+
955
+ $('#status').hidden = true;
956
+ setView('home', { push: false });
957
+ }
958
+
959
+ init();