@x12i/pocket-videos 0.0.0-stage → 1.0.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 +682 -0
- package/client-dir.js +5 -0
- package/fonts/OFL-Figtree.txt +93 -0
- package/fonts/OFL-Fraunces.txt +93 -0
- package/fonts/figtree-latin-wght-normal.woff2 +0 -0
- package/fonts/fraunces-latin-wght-italic.woff2 +0 -0
- package/fonts/fraunces-latin-wght-normal.woff2 +0 -0
- package/icons/apple-touch-icon.png +0 -0
- package/icons/favicon-32.png +0 -0
- package/icons/icon-192.png +0 -0
- package/icons/icon-512.png +0 -0
- package/icons/icon-maskable-512.png +0 -0
- package/index.html +111 -0
- package/logic.js +126 -0
- package/manifest.webmanifest +18 -0
- package/package.json +23 -4
- package/styles.css +662 -0
- package/sw.js +147 -0
- package/README.md +0 -3
package/app.js
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
// Pocket Videos. One media element plays the whole album, track after track.
|
|
2
|
+
import {
|
|
3
|
+
featuredIndex, dailyCardIndex, upNextIndexes, afterTrack, formatTime, normalizeCatalog,
|
|
4
|
+
} from './logic.js';
|
|
5
|
+
|
|
6
|
+
const $ = (sel, root = document) => root.querySelector(sel);
|
|
7
|
+
const media = $('#media');
|
|
8
|
+
|
|
9
|
+
const LS = { endMode: 'pv.endMode', last: 'pv.last', iosTip: 'pv.iosTipDismissed' };
|
|
10
|
+
const store = {
|
|
11
|
+
get(key) { try { return localStorage.getItem(key); } catch { return null; } },
|
|
12
|
+
set(key, value) { try { localStorage.setItem(key, value); } catch { /* private mode: fine */ } },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const state = {
|
|
16
|
+
app: {}, albums: [], cards: [],
|
|
17
|
+
albumIndex: -1, trackIndex: 0,
|
|
18
|
+
playing: false,
|
|
19
|
+
finished: false, // the album ended in Pause mode, so Up next is showing
|
|
20
|
+
endMode: 'repeat',
|
|
21
|
+
view: 'home',
|
|
22
|
+
resume: null, // last position from the previous visit, offered as "Continue"
|
|
23
|
+
retried: false,
|
|
24
|
+
listAlbumId: null, // which album the track list was last built for
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/* ---------------------------------------------------------------- tiny DOM helpers */
|
|
28
|
+
|
|
29
|
+
function h(tag, props, ...kids) {
|
|
30
|
+
const el = document.createElement(tag);
|
|
31
|
+
for (const [k, v] of Object.entries(props || {})) {
|
|
32
|
+
if (v == null || v === false) continue;
|
|
33
|
+
if (k === 'class') el.className = v;
|
|
34
|
+
else if (k === 'text') el.textContent = v;
|
|
35
|
+
else if (k === 'dataset') Object.assign(el.dataset, v);
|
|
36
|
+
else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
|
37
|
+
else el.setAttribute(k, v === true ? '' : v);
|
|
38
|
+
}
|
|
39
|
+
for (const kid of kids.flat()) {
|
|
40
|
+
if (kid == null || kid === false) continue;
|
|
41
|
+
el.append(kid.nodeType ? kid : document.createTextNode(kid));
|
|
42
|
+
}
|
|
43
|
+
return el;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const PATH = {
|
|
47
|
+
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"/>',
|
|
48
|
+
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"/>',
|
|
49
|
+
};
|
|
50
|
+
function svg(path, size = 24) {
|
|
51
|
+
const t = document.createElement('template');
|
|
52
|
+
t.innerHTML = `<svg viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true">${path}</svg>`;
|
|
53
|
+
return t.content.firstChild;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const abs = (url) => new URL(url, document.baseURI).href;
|
|
57
|
+
const mimeFor = (url) => (/\.png(\?|$)/i.test(url) ? 'image/png' : /\.webp(\?|$)/i.test(url) ? 'image/webp' : 'image/jpeg');
|
|
58
|
+
const currentAlbum = () => state.albums[state.albumIndex] || null;
|
|
59
|
+
const isStandalone = () => window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
|
|
60
|
+
const isIOS = () => /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
61
|
+
|
|
62
|
+
function coverEl(album, cls = '') {
|
|
63
|
+
const fallback = () => h('div', { class: `cover-fallback ${cls}`.trim(), 'aria-hidden': 'true', text: album.title.slice(0, 1) });
|
|
64
|
+
if (!album.cover) return fallback();
|
|
65
|
+
const img = h('img', { src: album.cover, alt: '', width: 640, height: 640, decoding: 'async', class: cls });
|
|
66
|
+
img.addEventListener('error', () => img.replaceWith(fallback()), { once: true });
|
|
67
|
+
return img;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let toastTimer;
|
|
71
|
+
function toast(message, { action, timeout = 4500 } = {}) {
|
|
72
|
+
const el = $('#toast');
|
|
73
|
+
el.replaceChildren(
|
|
74
|
+
h('span', { text: message }),
|
|
75
|
+
action ? h('button', { class: 'linkbtn', type: 'button', onclick: action.fn }, action.label) : null,
|
|
76
|
+
);
|
|
77
|
+
el.hidden = false;
|
|
78
|
+
clearTimeout(toastTimer);
|
|
79
|
+
if (timeout) toastTimer = setTimeout(() => { el.hidden = true; }, timeout);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function showStatus(message) {
|
|
83
|
+
const el = $('#status');
|
|
84
|
+
el.textContent = message;
|
|
85
|
+
el.hidden = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ---------------------------------------------------------------- views */
|
|
89
|
+
|
|
90
|
+
let pushedPlayer = false;
|
|
91
|
+
|
|
92
|
+
function setView(view, { push = true } = {}) {
|
|
93
|
+
const was = state.view;
|
|
94
|
+
state.view = view;
|
|
95
|
+
$('#home').hidden = view !== 'home';
|
|
96
|
+
$('#player').hidden = view !== 'player';
|
|
97
|
+
if (view === 'home') {
|
|
98
|
+
if (state.app.name) document.title = state.app.name;
|
|
99
|
+
renderHome();
|
|
100
|
+
}
|
|
101
|
+
if (view === 'player' && was !== 'player' && push) {
|
|
102
|
+
history.pushState({ v: 'player' }, '');
|
|
103
|
+
pushedPlayer = true;
|
|
104
|
+
}
|
|
105
|
+
renderMini();
|
|
106
|
+
if (was !== view) window.scrollTo(0, 0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
window.addEventListener('popstate', () => {
|
|
110
|
+
pushedPlayer = false;
|
|
111
|
+
if (state.view === 'player') setView('home', { push: false });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function goHome() {
|
|
115
|
+
if (state.view !== 'player') return;
|
|
116
|
+
if (pushedPlayer) history.back();
|
|
117
|
+
else setView('home', { push: false });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ---------------------------------------------------------------- home */
|
|
121
|
+
|
|
122
|
+
function installTip() {
|
|
123
|
+
if (isStandalone() || store.get(LS.iosTip) === '1' || !isIOS()) return null;
|
|
124
|
+
const name = state.app.name || 'Pocket Videos';
|
|
125
|
+
const safari = !/CriOS|FxiOS|EdgiOS|OPiOS|DuckDuckGo/i.test(navigator.userAgent);
|
|
126
|
+
const message = safari
|
|
127
|
+
? `To add ${name} to your Home Screen, tap Share, then Add to Home Screen.`
|
|
128
|
+
: `To add ${name} to your Home Screen, open this page in Safari, tap Share, then Add to Home Screen.`;
|
|
129
|
+
return h('div', { class: 'tip', role: 'note' },
|
|
130
|
+
h('p', { text: message }),
|
|
131
|
+
h('button', {
|
|
132
|
+
class: 'btn small', type: 'button',
|
|
133
|
+
onclick: (e) => { store.set(LS.iosTip, '1'); e.currentTarget.closest('.tip').remove(); },
|
|
134
|
+
}, 'Got it'));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderHome() {
|
|
138
|
+
const home = $('#home');
|
|
139
|
+
const now = new Date();
|
|
140
|
+
const fi = featuredIndex(state.app.featuredStartDate, now, state.albums.length);
|
|
141
|
+
const feat = state.albums[fi];
|
|
142
|
+
const metaLine = ['Album of the week', feat.artist, feat.year, `${feat.tracks.length} tracks`].filter(Boolean).join(' · ');
|
|
143
|
+
const play = () => startAlbum(fi);
|
|
144
|
+
|
|
145
|
+
// "Continue" is for a fresh visit; once something is loaded the mini player covers it.
|
|
146
|
+
const resume = state.albumIndex < 0 ? state.resume : null;
|
|
147
|
+
const resumeBtn = resume
|
|
148
|
+
? h('button', {
|
|
149
|
+
class: 'btn quiet', type: 'button',
|
|
150
|
+
onclick: () => startAlbum(resume.albumIndex, resume.trackIndex, { startAt: resume.time }),
|
|
151
|
+
}, `Continue: ${state.albums[resume.albumIndex].tracks[resume.trackIndex].title}`)
|
|
152
|
+
: null;
|
|
153
|
+
|
|
154
|
+
const nodes = [
|
|
155
|
+
installTip(),
|
|
156
|
+
h('div', { class: 'featured' },
|
|
157
|
+
h('button', { class: 'tablet', type: 'button', 'aria-label': `Play ${feat.title}`, onclick: play },
|
|
158
|
+
coverEl(feat), h('span', { class: 'fab' }, svg(PATH.play, 34))),
|
|
159
|
+
h('div', {},
|
|
160
|
+
h('h1', { text: feat.title }),
|
|
161
|
+
h('p', { class: 'meta', text: metaLine }),
|
|
162
|
+
h('div', { class: 'actions' },
|
|
163
|
+
h('button', { class: 'btn', type: 'button', onclick: play }, svg(PATH.play, 22), 'Play album'),
|
|
164
|
+
resumeBtn))),
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
if (state.cards.length) {
|
|
168
|
+
const card = state.cards[dailyCardIndex(now, state.cards.length)];
|
|
169
|
+
const ai = card.album ? state.albums.findIndex((a) => a.id === card.album) : -1;
|
|
170
|
+
nodes.push(h('section', { class: 'today', 'aria-label': "Today" },
|
|
171
|
+
h('p', { class: 'when', text: 'Today' }),
|
|
172
|
+
h('p', { class: 'hook', text: card.hook }),
|
|
173
|
+
h('p', { class: 'pivot', text: card.pivot }),
|
|
174
|
+
ai >= 0 ? h('button', { class: 'linkbtn', type: 'button', onclick: () => startAlbum(ai) }, `Play ${state.albums[ai].title}`) : null));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
nodes.push(
|
|
178
|
+
h('h2', { class: 'h2', text: 'All albums' }),
|
|
179
|
+
h('ul', { class: 'grid' }, state.albums.map((a, i) => {
|
|
180
|
+
const locked = a.access === 'locked';
|
|
181
|
+
return h('li', {},
|
|
182
|
+
h('button', {
|
|
183
|
+
class: `album${locked ? ' locked' : ''}`, type: 'button',
|
|
184
|
+
'aria-label': `${locked ? 'Locked. ' : 'Play '}${a.title}`,
|
|
185
|
+
'aria-current': i === state.albumIndex ? 'true' : null,
|
|
186
|
+
onclick: () => startAlbum(i),
|
|
187
|
+
},
|
|
188
|
+
h('span', { class: 'cover' }, coverEl(a), locked ? h('span', { class: 'badge', text: 'Locked' }) : null),
|
|
189
|
+
h('span', { class: 't', text: a.title }),
|
|
190
|
+
h('span', { class: 'y', text: [a.artist, a.year].filter(Boolean).join(' · ') })));
|
|
191
|
+
})),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
home.replaceChildren(...nodes.filter(Boolean));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ---------------------------------------------------------------- mini player */
|
|
198
|
+
|
|
199
|
+
function renderMini() {
|
|
200
|
+
const mini = $('#mini');
|
|
201
|
+
const album = currentAlbum();
|
|
202
|
+
if (!album || state.view !== 'home') { mini.hidden = true; return; }
|
|
203
|
+
const track = album.tracks[state.trackIndex];
|
|
204
|
+
mini.hidden = false;
|
|
205
|
+
mini.replaceChildren(
|
|
206
|
+
h('button', { class: 'open', type: 'button', 'aria-label': 'Open the player', onclick: () => setView('player') },
|
|
207
|
+
coverEl(album, 'mini-cover'),
|
|
208
|
+
h('span', { class: 'txt' },
|
|
209
|
+
h('span', { class: 'tt', text: state.finished ? `${album.title} finished` : track.title }),
|
|
210
|
+
h('span', { class: 'st', text: state.finished ? 'Tap to choose what is next' : album.title }))),
|
|
211
|
+
h('button', {
|
|
212
|
+
class: 'ctl', type: 'button', 'aria-label': state.playing ? 'Pause' : 'Play',
|
|
213
|
+
onclick: () => (media.paused ? playAction() : media.pause()),
|
|
214
|
+
}, svg(state.playing ? PATH.pause : PATH.play, 26)),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ---------------------------------------------------------------- player UI */
|
|
219
|
+
|
|
220
|
+
function applyKind(kind) {
|
|
221
|
+
const video = kind === 'video';
|
|
222
|
+
$('#art').classList.toggle('is-video', video);
|
|
223
|
+
$('#art').classList.toggle('is-audio', !video);
|
|
224
|
+
$('#stage').classList.toggle('is-video', video);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function renderPlayer() {
|
|
228
|
+
const album = currentAlbum();
|
|
229
|
+
if (!album) return;
|
|
230
|
+
const track = album.tracks[state.trackIndex];
|
|
231
|
+
applyKind(track.kind);
|
|
232
|
+
|
|
233
|
+
const cover = $('#pCover');
|
|
234
|
+
const coverSrc = album.cover || 'icons/icon-512.png';
|
|
235
|
+
if (cover.getAttribute('src') !== coverSrc) cover.setAttribute('src', coverSrc);
|
|
236
|
+
$('#pTrack').textContent = track.title;
|
|
237
|
+
$('#pAlbum').textContent = [album.title, album.artist].filter(Boolean).join(' · ');
|
|
238
|
+
document.title = `${track.title} · ${album.title} · ${state.app.name}`;
|
|
239
|
+
|
|
240
|
+
$('#deck').hidden = state.finished;
|
|
241
|
+
$('#upnext').hidden = !state.finished;
|
|
242
|
+
if (state.finished) renderUpNext();
|
|
243
|
+
|
|
244
|
+
if (state.listAlbumId !== album.id) {
|
|
245
|
+
state.listAlbumId = album.id;
|
|
246
|
+
$('#tracklist').replaceChildren(...album.tracks.map((t, i) => h('li', {},
|
|
247
|
+
h('button', { class: 'track', type: 'button', dataset: { i }, onclick: () => jumpTo(i) },
|
|
248
|
+
h('span', { class: 'n', text: String(i + 1) }),
|
|
249
|
+
h('span', { text: t.title })))));
|
|
250
|
+
$('#albumLinks').replaceChildren(...album.links.map((l) => h('li', {},
|
|
251
|
+
h('a', { href: l.url, target: '_blank', rel: 'noopener noreferrer', text: l.label }))));
|
|
252
|
+
state.lyricsOpen = false;
|
|
253
|
+
}
|
|
254
|
+
$('#tracklist').querySelectorAll('.track').forEach((b, i) => {
|
|
255
|
+
if (i === state.trackIndex && !state.finished) b.setAttribute('aria-current', 'true');
|
|
256
|
+
else b.removeAttribute('aria-current');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const lyrics = track.lyrics;
|
|
260
|
+
$('#lyricsWrap').hidden = !lyrics;
|
|
261
|
+
$('#lyricsText').textContent = lyrics;
|
|
262
|
+
$('#lyricsText').hidden = !state.lyricsOpen || !lyrics;
|
|
263
|
+
const lb = $('#lyricsBtn');
|
|
264
|
+
lb.textContent = state.lyricsOpen ? 'Hide lyrics' : 'Show lyrics';
|
|
265
|
+
lb.setAttribute('aria-expanded', String(state.lyricsOpen && !!lyrics));
|
|
266
|
+
|
|
267
|
+
syncEndMode();
|
|
268
|
+
syncPlaying();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function renderUpNext() {
|
|
272
|
+
const album = currentAlbum();
|
|
273
|
+
const picks = upNextIndexes(state.albumIndex, state.albums.length, 3);
|
|
274
|
+
const box = $('#upnext');
|
|
275
|
+
box.replaceChildren(
|
|
276
|
+
h('h3', { tabindex: '-1', text: picks.length ? 'Up next' : 'That was the album' }),
|
|
277
|
+
h('p', { class: 'done', text: `${album.title} has finished.` }),
|
|
278
|
+
picks.length ? h('ul', {}, picks.map((i) => {
|
|
279
|
+
const a = state.albums[i];
|
|
280
|
+
const locked = a.access === 'locked';
|
|
281
|
+
return h('li', {},
|
|
282
|
+
h('button', {
|
|
283
|
+
class: 'un', type: 'button', disabled: locked,
|
|
284
|
+
'aria-label': `${locked ? 'Locked. ' : 'Play '}${a.title}`,
|
|
285
|
+
onclick: () => startAlbum(i),
|
|
286
|
+
},
|
|
287
|
+
coverEl(a),
|
|
288
|
+
h('span', {}, h('span', { class: 't', text: a.title }), h('span', { class: 'y', text: [a.artist, a.year].filter(Boolean).join(' · ') })),
|
|
289
|
+
h('span', { class: 'go' }, svg(PATH.play, 22))));
|
|
290
|
+
})) : null,
|
|
291
|
+
h('button', { class: 'btn quiet again', type: 'button', onclick: () => startAlbum(state.albumIndex, 0) }, `Play ${album.title} again`),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function syncEndMode() {
|
|
296
|
+
document.querySelectorAll('.seg button').forEach((b) => {
|
|
297
|
+
b.setAttribute('aria-pressed', String(b.dataset.mode === state.endMode));
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function syncPlaying() {
|
|
302
|
+
const playing = state.playing;
|
|
303
|
+
document.body.classList.toggle('is-playing', playing);
|
|
304
|
+
// SVG elements have no .hidden property, so toggle the attribute directly.
|
|
305
|
+
$('.i-play').toggleAttribute('hidden', playing);
|
|
306
|
+
$('.i-pause').toggleAttribute('hidden', !playing);
|
|
307
|
+
$('#playBtn').setAttribute('aria-label', playing ? 'Pause' : 'Play');
|
|
308
|
+
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
|
|
309
|
+
renderMini();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function showError(show) {
|
|
313
|
+
$('#pErr').hidden = !show;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* ---------------------------------------------------------------- playback */
|
|
317
|
+
|
|
318
|
+
async function startAlbum(albumIndex, trackIndex = 0, { startAt = 0 } = {}) {
|
|
319
|
+
const album = state.albums[albumIndex];
|
|
320
|
+
if (!album) return;
|
|
321
|
+
if (album.access === 'locked') { toast(`${album.title} is locked for now.`); return; }
|
|
322
|
+
state.albumIndex = albumIndex;
|
|
323
|
+
setTrack(trackIndex, { startAt });
|
|
324
|
+
setView('player');
|
|
325
|
+
await play();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function setTrack(index, { startAt = 0 } = {}) {
|
|
329
|
+
const album = currentAlbum();
|
|
330
|
+
state.trackIndex = index;
|
|
331
|
+
state.finished = false;
|
|
332
|
+
state.retried = false;
|
|
333
|
+
showError(false);
|
|
334
|
+
const track = album.tracks[index];
|
|
335
|
+
applyKind(track.kind);
|
|
336
|
+
media.src = track.src;
|
|
337
|
+
if (startAt > 0) {
|
|
338
|
+
media.addEventListener('loadedmetadata', () => { try { media.currentTime = startAt; } catch { /* ignore */ } }, { once: true });
|
|
339
|
+
}
|
|
340
|
+
resetSeek();
|
|
341
|
+
updateMediaSession();
|
|
342
|
+
renderPlayer();
|
|
343
|
+
saveLast();
|
|
344
|
+
warmCache(album, index);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function play() {
|
|
348
|
+
try {
|
|
349
|
+
await media.play();
|
|
350
|
+
} catch (err) {
|
|
351
|
+
if (err && err.name === 'NotAllowedError') {
|
|
352
|
+
state.playing = false; syncPlaying();
|
|
353
|
+
toast('Tap play to start.');
|
|
354
|
+
} else if (err && err.name !== 'AbortError') {
|
|
355
|
+
console.warn('play() failed', err);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function playAction() {
|
|
361
|
+
if (state.finished) startAlbum(state.albumIndex, 0);
|
|
362
|
+
else play();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function jumpTo(index) {
|
|
366
|
+
if (state.finished) { startAlbum(state.albumIndex, index); return; }
|
|
367
|
+
setTrack(index);
|
|
368
|
+
play();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function next() {
|
|
372
|
+
const album = currentAlbum();
|
|
373
|
+
if (!album) return;
|
|
374
|
+
const action = afterTrack(state.trackIndex, album.tracks.length, state.endMode);
|
|
375
|
+
if (action.type === 'end') { media.pause(); finishAlbum(); return; }
|
|
376
|
+
setTrack(action.index);
|
|
377
|
+
play();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function previous() {
|
|
381
|
+
if (!currentAlbum()) return;
|
|
382
|
+
if (media.currentTime > 3 || state.trackIndex === 0) { media.currentTime = 0; return; }
|
|
383
|
+
setTrack(state.trackIndex - 1);
|
|
384
|
+
play();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function onEnded() {
|
|
388
|
+
const album = currentAlbum();
|
|
389
|
+
if (!album) return;
|
|
390
|
+
const action = afterTrack(state.trackIndex, album.tracks.length, state.endMode);
|
|
391
|
+
if (action.type === 'end') { finishAlbum(); return; }
|
|
392
|
+
setTrack(action.index); // 'next' or 'repeat'
|
|
393
|
+
play();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function finishAlbum() {
|
|
397
|
+
state.finished = true;
|
|
398
|
+
state.playing = false;
|
|
399
|
+
saveLast();
|
|
400
|
+
renderPlayer();
|
|
401
|
+
if (state.view === 'player') $('#upnext h3')?.focus({ preventScroll: true });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* ---------------------------------------------------------------- media events */
|
|
405
|
+
|
|
406
|
+
let seeking = false;
|
|
407
|
+
let lastSave = 0;
|
|
408
|
+
let lastPos = 0;
|
|
409
|
+
|
|
410
|
+
function setProgress(fraction) {
|
|
411
|
+
const seek = $('#seek');
|
|
412
|
+
seek.value = String(Math.round(fraction * 1000));
|
|
413
|
+
seek.style.setProperty('--p', `${fraction * 100}%`);
|
|
414
|
+
}
|
|
415
|
+
function resetSeek() {
|
|
416
|
+
setProgress(0);
|
|
417
|
+
$('#tCur').textContent = '0:00';
|
|
418
|
+
$('#tDur').textContent = '0:00';
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
media.addEventListener('play', () => { state.playing = true; syncPlaying(); });
|
|
422
|
+
media.addEventListener('pause', () => {
|
|
423
|
+
if (media.ended) return; // the browser fires pause just before ended; ignore it
|
|
424
|
+
state.playing = false; syncPlaying(); saveLast();
|
|
425
|
+
});
|
|
426
|
+
media.addEventListener('ended', onEnded);
|
|
427
|
+
media.addEventListener('loadedmetadata', () => { $('#tDur').textContent = formatTime(media.duration); });
|
|
428
|
+
media.addEventListener('timeupdate', () => {
|
|
429
|
+
const d = media.duration;
|
|
430
|
+
const t = media.currentTime;
|
|
431
|
+
if (!seeking && Number.isFinite(d) && d > 0) {
|
|
432
|
+
setProgress(Math.min(t / d, 1));
|
|
433
|
+
$('#tCur').textContent = formatTime(t);
|
|
434
|
+
$('#seek').setAttribute('aria-valuetext', `${formatTime(t)} of ${formatTime(d)}`);
|
|
435
|
+
}
|
|
436
|
+
const now = performance.now();
|
|
437
|
+
if (now - lastSave > 3000) { lastSave = now; saveLast(); }
|
|
438
|
+
if ('mediaSession' in navigator && navigator.mediaSession.setPositionState && Number.isFinite(d) && d > 0 && now - lastPos > 1000) {
|
|
439
|
+
lastPos = now;
|
|
440
|
+
try { navigator.mediaSession.setPositionState({ duration: d, playbackRate: media.playbackRate || 1, position: Math.min(t, d) }); } catch { /* ignore */ }
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
media.addEventListener('error', () => {
|
|
444
|
+
if (!media.error || !currentAlbum()) return;
|
|
445
|
+
if (!state.retried) {
|
|
446
|
+
state.retried = true;
|
|
447
|
+
const at = media.currentTime || 0;
|
|
448
|
+
setTimeout(() => {
|
|
449
|
+
media.load();
|
|
450
|
+
if (at > 0) media.addEventListener('loadedmetadata', () => { try { media.currentTime = at; } catch { /* ignore */ } }, { once: true });
|
|
451
|
+
play();
|
|
452
|
+
}, 600);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
state.playing = false;
|
|
456
|
+
syncPlaying();
|
|
457
|
+
showError(true);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const seekEl = $('#seek');
|
|
461
|
+
seekEl.addEventListener('input', () => {
|
|
462
|
+
seeking = true;
|
|
463
|
+
const d = media.duration;
|
|
464
|
+
seekEl.style.setProperty('--p', `${seekEl.value / 10}%`);
|
|
465
|
+
if (Number.isFinite(d)) $('#tCur').textContent = formatTime((seekEl.value / 1000) * d);
|
|
466
|
+
});
|
|
467
|
+
seekEl.addEventListener('change', () => {
|
|
468
|
+
const d = media.duration;
|
|
469
|
+
if (Number.isFinite(d)) media.currentTime = (seekEl.value / 1000) * d;
|
|
470
|
+
seeking = false;
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
/* ---------------------------------------------------------------- resume */
|
|
474
|
+
|
|
475
|
+
function saveLast() {
|
|
476
|
+
const album = currentAlbum();
|
|
477
|
+
if (!album) return;
|
|
478
|
+
store.set(LS.last, JSON.stringify({
|
|
479
|
+
id: album.id,
|
|
480
|
+
track: state.finished ? 0 : state.trackIndex,
|
|
481
|
+
time: state.finished ? 0 : Math.floor(media.currentTime || 0),
|
|
482
|
+
}));
|
|
483
|
+
}
|
|
484
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') saveLast(); });
|
|
485
|
+
window.addEventListener('pagehide', saveLast);
|
|
486
|
+
|
|
487
|
+
function loadLast() {
|
|
488
|
+
try {
|
|
489
|
+
const last = JSON.parse(store.get(LS.last) || 'null');
|
|
490
|
+
if (!last) return null;
|
|
491
|
+
const albumIndex = state.albums.findIndex((a) => a.id === last.id);
|
|
492
|
+
if (albumIndex < 0) return null;
|
|
493
|
+
const trackIndex = Number.isInteger(last.track) ? last.track : 0;
|
|
494
|
+
if (trackIndex < 0 || trackIndex >= state.albums[albumIndex].tracks.length) return null;
|
|
495
|
+
return { albumIndex, trackIndex, time: Math.max(0, Number(last.time) || 0) };
|
|
496
|
+
} catch { return null; }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/* ---------------------------------------------------------------- lock screen + headphones */
|
|
500
|
+
|
|
501
|
+
function updateMediaSession() {
|
|
502
|
+
if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
|
|
503
|
+
const album = currentAlbum();
|
|
504
|
+
if (!album) return;
|
|
505
|
+
const track = album.tracks[state.trackIndex];
|
|
506
|
+
navigator.mediaSession.metadata = new MediaMetadata({
|
|
507
|
+
title: track.title,
|
|
508
|
+
artist: album.artist || state.app.name,
|
|
509
|
+
album: album.title,
|
|
510
|
+
artwork: album.cover ? [{ src: abs(album.cover), sizes: '640x640', type: mimeFor(album.cover) }] : [],
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function bindMediaSession() {
|
|
515
|
+
if (!('mediaSession' in navigator)) return;
|
|
516
|
+
const set = (action, fn) => { try { navigator.mediaSession.setActionHandler(action, fn); } catch { /* unsupported action */ } };
|
|
517
|
+
set('play', () => playAction());
|
|
518
|
+
set('pause', () => media.pause());
|
|
519
|
+
set('previoustrack', () => previous());
|
|
520
|
+
set('nexttrack', () => next());
|
|
521
|
+
set('seekto', (d) => {
|
|
522
|
+
if (typeof d.seekTime !== 'number') return;
|
|
523
|
+
if (d.fastSeek && 'fastSeek' in media) media.fastSeek(d.seekTime);
|
|
524
|
+
else media.currentTime = d.seekTime;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/* ---------------------------------------------------------------- offline media cache */
|
|
529
|
+
// The album in play is copied into the Cache API, next track first, so it keeps playing
|
|
530
|
+
// if the connection drops. Only the current album is kept; others are removed.
|
|
531
|
+
|
|
532
|
+
const MEDIA_CACHE = 'pv-media';
|
|
533
|
+
let warmToken = 0;
|
|
534
|
+
|
|
535
|
+
async function warmCache(album, current) {
|
|
536
|
+
const token = ++warmToken;
|
|
537
|
+
if (!('caches' in window)) return;
|
|
538
|
+
if (navigator.connection && navigator.connection.saveData) return;
|
|
539
|
+
try {
|
|
540
|
+
const cache = await caches.open(MEDIA_CACHE);
|
|
541
|
+
const urls = album.tracks.map((t) => abs(t.src));
|
|
542
|
+
const keep = new Set(urls);
|
|
543
|
+
for (const req of await cache.keys()) if (!keep.has(req.url)) await cache.delete(req);
|
|
544
|
+
const order = [...urls.slice(current + 1), ...urls.slice(0, current + 1)];
|
|
545
|
+
for (const url of order) {
|
|
546
|
+
if (token !== warmToken) return;
|
|
547
|
+
if (await cache.match(url)) continue;
|
|
548
|
+
const res = await fetch(url);
|
|
549
|
+
if (res.status === 200) await cache.put(url, res);
|
|
550
|
+
}
|
|
551
|
+
} catch { /* offline or storage full: streaming still works */ }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/* ---------------------------------------------------------------- install + updates */
|
|
555
|
+
|
|
556
|
+
let installPrompt = null;
|
|
557
|
+
function setupInstall() {
|
|
558
|
+
const btn = $('#installBtn');
|
|
559
|
+
window.addEventListener('beforeinstallprompt', (e) => {
|
|
560
|
+
e.preventDefault();
|
|
561
|
+
installPrompt = e;
|
|
562
|
+
btn.hidden = false;
|
|
563
|
+
});
|
|
564
|
+
btn.addEventListener('click', async () => {
|
|
565
|
+
if (!installPrompt) return;
|
|
566
|
+
installPrompt.prompt();
|
|
567
|
+
try { await installPrompt.userChoice; } catch { /* ignore */ }
|
|
568
|
+
installPrompt = null;
|
|
569
|
+
btn.hidden = true;
|
|
570
|
+
});
|
|
571
|
+
window.addEventListener('appinstalled', () => {
|
|
572
|
+
btn.hidden = true;
|
|
573
|
+
document.querySelector('.tip')?.remove();
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function registerServiceWorker() {
|
|
578
|
+
if (!('serviceWorker' in navigator)) return;
|
|
579
|
+
const hadController = !!navigator.serviceWorker.controller;
|
|
580
|
+
window.addEventListener('load', async () => {
|
|
581
|
+
try {
|
|
582
|
+
const reg = await navigator.serviceWorker.register('sw.js');
|
|
583
|
+
const offer = (worker) => toast('A new version is ready.', {
|
|
584
|
+
timeout: 0,
|
|
585
|
+
action: { label: 'Reload', fn: () => worker.postMessage({ type: 'SKIP_WAITING' }) },
|
|
586
|
+
});
|
|
587
|
+
if (reg.waiting && navigator.serviceWorker.controller) offer(reg.waiting);
|
|
588
|
+
reg.addEventListener('updatefound', () => {
|
|
589
|
+
const worker = reg.installing;
|
|
590
|
+
if (!worker) return;
|
|
591
|
+
worker.addEventListener('statechange', () => {
|
|
592
|
+
if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
let reloaded = false;
|
|
596
|
+
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
597
|
+
if (!hadController || reloaded) return;
|
|
598
|
+
reloaded = true;
|
|
599
|
+
location.reload();
|
|
600
|
+
});
|
|
601
|
+
document.addEventListener('visibilitychange', () => {
|
|
602
|
+
if (document.visibilityState === 'visible') reg.update().catch(() => {});
|
|
603
|
+
});
|
|
604
|
+
} catch (err) {
|
|
605
|
+
console.warn('Service worker not registered:', err);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/* ---------------------------------------------------------------- wiring + start */
|
|
611
|
+
|
|
612
|
+
function wire() {
|
|
613
|
+
$('#brand').addEventListener('click', (e) => { e.preventDefault(); goHome(); });
|
|
614
|
+
$('#backBtn').addEventListener('click', goHome);
|
|
615
|
+
$('#playBtn').addEventListener('click', () => (media.paused ? playAction() : media.pause()));
|
|
616
|
+
$('#prevBtn').addEventListener('click', previous);
|
|
617
|
+
$('#nextBtn').addEventListener('click', next);
|
|
618
|
+
$('#retryBtn').addEventListener('click', () => {
|
|
619
|
+
state.retried = false;
|
|
620
|
+
showError(false);
|
|
621
|
+
media.load();
|
|
622
|
+
play();
|
|
623
|
+
});
|
|
624
|
+
document.querySelectorAll('.seg button').forEach((b) => b.addEventListener('click', () => {
|
|
625
|
+
state.endMode = b.dataset.mode;
|
|
626
|
+
store.set(LS.endMode, state.endMode);
|
|
627
|
+
syncEndMode();
|
|
628
|
+
}));
|
|
629
|
+
$('#lyricsBtn').addEventListener('click', () => {
|
|
630
|
+
state.lyricsOpen = !state.lyricsOpen;
|
|
631
|
+
renderPlayer();
|
|
632
|
+
});
|
|
633
|
+
document.addEventListener('keydown', (e) => {
|
|
634
|
+
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || state.albumIndex < 0) return;
|
|
635
|
+
const t = e.target;
|
|
636
|
+
if (t instanceof Element && t.closest('input, textarea, select, [contenteditable]')) return;
|
|
637
|
+
if (e.code === 'Space' && !(t instanceof Element && t.closest('button, a'))) {
|
|
638
|
+
e.preventDefault();
|
|
639
|
+
if (media.paused) playAction(); else media.pause();
|
|
640
|
+
} else if (e.key === 'ArrowRight') next();
|
|
641
|
+
else if (e.key === 'ArrowLeft') previous();
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function init() {
|
|
646
|
+
registerServiceWorker();
|
|
647
|
+
setupInstall();
|
|
648
|
+
bindMediaSession();
|
|
649
|
+
wire();
|
|
650
|
+
|
|
651
|
+
let raw;
|
|
652
|
+
try {
|
|
653
|
+
const res = await fetch('catalog.json', { cache: 'no-cache' });
|
|
654
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
655
|
+
raw = await res.json();
|
|
656
|
+
} catch (err) {
|
|
657
|
+
console.error('catalog.json failed to load:', err);
|
|
658
|
+
showStatus("The album list didn't load. Check your connection and reload. If you edited catalog.json, check it for a missing comma or quote.");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const catalog = normalizeCatalog(raw);
|
|
663
|
+
catalog.warnings.forEach((w) => console.warn('[catalog]', w));
|
|
664
|
+
if (!catalog.albums.length) {
|
|
665
|
+
showStatus('catalog.json has no playable albums. Each album needs a title and at least one track with a title and a media file.');
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
state.app = catalog.app;
|
|
670
|
+
state.albums = catalog.albums;
|
|
671
|
+
state.cards = catalog.cards;
|
|
672
|
+
$('#brand').textContent = state.app.name;
|
|
673
|
+
document.title = state.app.name;
|
|
674
|
+
const savedMode = store.get(LS.endMode);
|
|
675
|
+
state.endMode = savedMode === 'pause' || savedMode === 'repeat' ? savedMode : catalog.app.defaultEndMode;
|
|
676
|
+
state.resume = loadLast();
|
|
677
|
+
|
|
678
|
+
$('#status').hidden = true;
|
|
679
|
+
setView('home', { push: false });
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
init();
|