@x12i/pocket-videos 1.0.0 → 1.1.1
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 +418 -139
- package/index.html +17 -8
- package/logic.js +159 -13
- package/manifest.webmanifest +1 -1
- package/package.json +2 -2
- package/styles.css +47 -5
- package/sw.js +2 -2
package/app.js
CHANGED
|
@@ -1,29 +1,41 @@
|
|
|
1
|
-
// Pocket Videos. One media element plays
|
|
1
|
+
// Pocket Videos. One media element plays a playlist, track after track.
|
|
2
2
|
import {
|
|
3
|
-
featuredIndex, dailyCardIndex,
|
|
3
|
+
featuredIndex, dailyCardIndex, formatTime, normalizeCatalog, lowerFirst,
|
|
4
|
+
emptyMarks, applyMark, trackMarkKey, playlistVisible, visibleTrackIndexes,
|
|
5
|
+
stepVisible, likedQueue, stepQueue,
|
|
4
6
|
} from './logic.js';
|
|
5
7
|
|
|
6
8
|
const $ = (sel, root = document) => root.querySelector(sel);
|
|
7
9
|
const media = $('#media');
|
|
8
10
|
|
|
9
|
-
const LS = { endMode: 'pv.endMode', last: 'pv.last', iosTip: 'pv.iosTipDismissed' };
|
|
11
|
+
const LS = { endMode: 'pv.endMode', last: 'pv.last', iosTip: 'pv.iosTipDismissed', marks: 'pv.marks' };
|
|
10
12
|
const store = {
|
|
11
13
|
get(key) { try { return localStorage.getItem(key); } catch { return null; } },
|
|
12
14
|
set(key, value) { try { localStorage.setItem(key, value); } catch { /* private mode: fine */ } },
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
const state = {
|
|
16
|
-
app: {},
|
|
17
|
-
|
|
18
|
+
app: {}, playlists: [], cards: [],
|
|
19
|
+
playlistIndex: -1, trackIndex: 0,
|
|
18
20
|
playing: false,
|
|
19
|
-
finished: false, // the
|
|
21
|
+
finished: false, // the playlist ended in Pause mode, so Up next is showing
|
|
20
22
|
endMode: 'repeat',
|
|
21
23
|
view: 'home',
|
|
22
24
|
resume: null, // last position from the previous visit, offered as "Continue"
|
|
23
25
|
retried: false,
|
|
24
|
-
|
|
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,
|
|
25
34
|
};
|
|
26
35
|
|
|
36
|
+
const term = (key) => (state.app.terms && state.app.terms[key]) || key;
|
|
37
|
+
const termLower = (key) => lowerFirst(term(key));
|
|
38
|
+
|
|
27
39
|
/* ---------------------------------------------------------------- tiny DOM helpers */
|
|
28
40
|
|
|
29
41
|
function h(tag, props, ...kids) {
|
|
@@ -46,6 +58,7 @@ function h(tag, props, ...kids) {
|
|
|
46
58
|
const PATH = {
|
|
47
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"/>',
|
|
48
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"/>',
|
|
49
62
|
};
|
|
50
63
|
function svg(path, size = 24) {
|
|
51
64
|
const t = document.createElement('template');
|
|
@@ -55,14 +68,14 @@ function svg(path, size = 24) {
|
|
|
55
68
|
|
|
56
69
|
const abs = (url) => new URL(url, document.baseURI).href;
|
|
57
70
|
const mimeFor = (url) => (/\.png(\?|$)/i.test(url) ? 'image/png' : /\.webp(\?|$)/i.test(url) ? 'image/webp' : 'image/jpeg');
|
|
58
|
-
const
|
|
71
|
+
const currentPlaylist = () => state.playlists[state.playlistIndex] || null;
|
|
59
72
|
const isStandalone = () => window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
|
|
60
73
|
const isIOS = () => /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
61
74
|
|
|
62
|
-
function coverEl(
|
|
63
|
-
const fallback = () => h('div', { class: `cover-fallback ${cls}`.trim(), 'aria-hidden': 'true', text:
|
|
64
|
-
if (!
|
|
65
|
-
const img = h('img', { src:
|
|
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 });
|
|
66
79
|
img.addEventListener('error', () => img.replaceWith(fallback()), { once: true });
|
|
67
80
|
return img;
|
|
68
81
|
}
|
|
@@ -85,35 +98,137 @@ function showStatus(message) {
|
|
|
85
98
|
el.hidden = false;
|
|
86
99
|
}
|
|
87
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
|
+
|
|
88
191
|
/* ---------------------------------------------------------------- views */
|
|
89
192
|
|
|
90
|
-
let
|
|
193
|
+
let navDepth = 0;
|
|
91
194
|
|
|
92
195
|
function setView(view, { push = true } = {}) {
|
|
93
196
|
const was = state.view;
|
|
94
197
|
state.view = view;
|
|
95
198
|
$('#home').hidden = view !== 'home';
|
|
199
|
+
$('#liked').hidden = view !== 'liked';
|
|
96
200
|
$('#player').hidden = view !== 'player';
|
|
97
201
|
if (view === 'home') {
|
|
98
202
|
if (state.app.name) document.title = state.app.name;
|
|
99
203
|
renderHome();
|
|
204
|
+
} else if (view === 'liked') {
|
|
205
|
+
document.title = `Liked · ${state.app.name || 'Pocket Videos'}`;
|
|
206
|
+
renderLiked();
|
|
207
|
+
} else if (view === 'player') {
|
|
208
|
+
renderPlayer();
|
|
100
209
|
}
|
|
101
|
-
if (view === 'player'
|
|
102
|
-
|
|
103
|
-
|
|
210
|
+
if (push && was !== view && (view === 'player' || view === 'liked')) {
|
|
211
|
+
navDepth += 1;
|
|
212
|
+
history.pushState({ v: view, depth: navDepth }, '');
|
|
104
213
|
}
|
|
214
|
+
if (!push) navDepth = (history.state && history.state.depth) || 0;
|
|
105
215
|
renderMini();
|
|
106
216
|
if (was !== view) window.scrollTo(0, 0);
|
|
107
217
|
}
|
|
108
218
|
|
|
109
219
|
window.addEventListener('popstate', () => {
|
|
110
|
-
|
|
111
|
-
|
|
220
|
+
navDepth = (history.state && history.state.depth) || 0;
|
|
221
|
+
const v = history.state && history.state.v;
|
|
222
|
+
if (v === 'player' || v === 'liked') setView(v, { push: false });
|
|
223
|
+
else setView('home', { push: false });
|
|
112
224
|
});
|
|
113
225
|
|
|
114
|
-
function
|
|
115
|
-
if (
|
|
116
|
-
|
|
226
|
+
function backOne() {
|
|
227
|
+
if (navDepth > 0) history.back();
|
|
228
|
+
else setView('home', { push: false });
|
|
229
|
+
}
|
|
230
|
+
function backHome() {
|
|
231
|
+
if (navDepth > 0) history.go(-navDepth);
|
|
117
232
|
else setView('home', { push: false });
|
|
118
233
|
}
|
|
119
234
|
|
|
@@ -134,21 +249,30 @@ function installTip() {
|
|
|
134
249
|
}, 'Got it'));
|
|
135
250
|
}
|
|
136
251
|
|
|
252
|
+
function countLabel(n, singular, plural) {
|
|
253
|
+
return `${n} ${n === 1 ? singular : plural}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
137
256
|
function renderHome() {
|
|
257
|
+
applyTerms();
|
|
138
258
|
const home = $('#home');
|
|
139
259
|
const now = new Date();
|
|
140
|
-
const fi = featuredIndex(state.app.featuredStartDate, now, state.
|
|
141
|
-
const feat = state.
|
|
142
|
-
const metaLine = [
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
260
|
+
const fi = featuredIndex(state.app.featuredStartDate, now, state.playlists.length);
|
|
261
|
+
const feat = state.playlists[fi];
|
|
262
|
+
const metaLine = [
|
|
263
|
+
`${term('playlist')} of the week`,
|
|
264
|
+
feat.artist,
|
|
265
|
+
feat.year,
|
|
266
|
+
countLabel(feat.tracks.length, termLower('track'), termLower('tracks')),
|
|
267
|
+
].filter(Boolean).join(' · ');
|
|
268
|
+
const play = () => startPlaylist(fi, firstOpenIndex(feat));
|
|
269
|
+
|
|
270
|
+
const resume = state.playlistIndex < 0 ? state.resume : null;
|
|
147
271
|
const resumeBtn = resume
|
|
148
272
|
? h('button', {
|
|
149
273
|
class: 'btn quiet', type: 'button',
|
|
150
|
-
onclick: () =>
|
|
151
|
-
}, `Continue: ${state.
|
|
274
|
+
onclick: () => startPlaylist(resume.playlistIndex, resume.trackIndex, { startAt: resume.time }),
|
|
275
|
+
}, `Continue: ${state.playlists[resume.playlistIndex].tracks[resume.trackIndex].title}`)
|
|
152
276
|
: null;
|
|
153
277
|
|
|
154
278
|
const nodes = [
|
|
@@ -160,54 +284,101 @@ function renderHome() {
|
|
|
160
284
|
h('h1', { text: feat.title }),
|
|
161
285
|
h('p', { class: 'meta', text: metaLine }),
|
|
162
286
|
h('div', { class: 'actions' },
|
|
163
|
-
h('button', { class: 'btn', type: 'button', onclick: play }, svg(PATH.play, 22),
|
|
164
|
-
resumeBtn
|
|
287
|
+
h('button', { class: 'btn', type: 'button', onclick: play }, svg(PATH.play, 22), `Play ${termLower('playlist')}`),
|
|
288
|
+
resumeBtn,
|
|
289
|
+
markButtons('playlists', feat.id)))),
|
|
165
290
|
];
|
|
166
291
|
|
|
167
292
|
if (state.cards.length) {
|
|
168
293
|
const card = state.cards[dailyCardIndex(now, state.cards.length)];
|
|
169
|
-
const ai = card.
|
|
170
|
-
nodes.push(h('section', { class: 'today', 'aria-label':
|
|
294
|
+
const ai = card.playlist ? state.playlists.findIndex((a) => a.id === card.playlist) : -1;
|
|
295
|
+
nodes.push(h('section', { class: 'today', 'aria-label': 'Today' },
|
|
171
296
|
h('p', { class: 'when', text: 'Today' }),
|
|
172
297
|
h('p', { class: 'hook', text: card.hook }),
|
|
173
298
|
h('p', { class: 'pivot', text: card.pivot }),
|
|
174
|
-
ai >= 0 ? h('button', { class: 'linkbtn', type: 'button', onclick: () =>
|
|
299
|
+
ai >= 0 ? h('button', { class: 'linkbtn', type: 'button', onclick: () => startPlaylist(ai, firstOpenIndex(state.playlists[ai])) }, `Play ${state.playlists[ai].title}`) : null));
|
|
175
300
|
}
|
|
176
301
|
|
|
302
|
+
const shown = state.playlists
|
|
303
|
+
.map((p, i) => ({ p, i }))
|
|
304
|
+
.filter(({ p }) => playlistVisible(playlistMark(p.id), { filter: state.homeFilter, showDisliked: state.showDislikedHome }));
|
|
305
|
+
const empty = state.homeFilter === 'liked'
|
|
306
|
+
? `No liked ${termLower('playlists')}.`
|
|
307
|
+
: `Every ${termLower('playlist')} is hidden. Show disliked to bring one back.`;
|
|
308
|
+
|
|
177
309
|
nodes.push(
|
|
178
|
-
h('h2', { class: 'h2', text: '
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
310
|
+
h('h2', { class: 'h2', text: term('playlists') }),
|
|
311
|
+
filterBar(state.homeFilter, state.showDislikedHome,
|
|
312
|
+
(f) => { state.homeFilter = f; renderHome(); },
|
|
313
|
+
(s) => { state.showDislikedHome = s; renderHome(); }),
|
|
314
|
+
shown.length
|
|
315
|
+
? h('ul', { class: 'grid' }, shown.map(({ p, i }) => {
|
|
316
|
+
const locked = p.access === 'locked';
|
|
317
|
+
const mark = playlistMark(p.id);
|
|
318
|
+
return h('li', {},
|
|
319
|
+
h('div', { class: `album${locked ? ' locked' : ''}${mark === 'disliked' ? ' is-disliked' : ''}` },
|
|
320
|
+
h('button', {
|
|
321
|
+
class: 'album-open', type: 'button',
|
|
322
|
+
'aria-label': `${locked ? 'Locked. ' : 'Play '}${p.title}`,
|
|
323
|
+
'aria-current': i === state.playlistIndex ? 'true' : null,
|
|
324
|
+
onclick: () => startPlaylist(i, firstOpenIndex(p)),
|
|
325
|
+
},
|
|
326
|
+
h('span', { class: 'cover' }, coverEl(p), locked ? h('span', { class: 'badge', text: 'Locked' }) : null),
|
|
327
|
+
h('span', { class: 't', text: p.title }),
|
|
328
|
+
h('span', { class: 'y', text: [p.artist, p.year].filter(Boolean).join(' · ') })),
|
|
329
|
+
markButtons('playlists', p.id)));
|
|
330
|
+
}))
|
|
331
|
+
: h('p', { class: 'empty', text: empty }),
|
|
192
332
|
);
|
|
193
333
|
|
|
194
334
|
home.replaceChildren(...nodes.filter(Boolean));
|
|
195
335
|
}
|
|
196
336
|
|
|
337
|
+
/* ---------------------------------------------------------------- liked, across playlists */
|
|
338
|
+
|
|
339
|
+
function renderLiked() {
|
|
340
|
+
applyTerms();
|
|
341
|
+
const items = likedQueue(state.playlists, (id, src) => trackMark(id, src));
|
|
342
|
+
const rows = items.map((item, n) => {
|
|
343
|
+
const playlist = state.playlists[item.playlistIndex];
|
|
344
|
+
const track = playlist.tracks[item.trackIndex];
|
|
345
|
+
const current = state.playlistIndex === item.playlistIndex && state.trackIndex === item.trackIndex;
|
|
346
|
+
return h('li', { class: 'track-row' },
|
|
347
|
+
h('button', {
|
|
348
|
+
class: 'track', type: 'button',
|
|
349
|
+
'aria-current': current ? 'true' : null,
|
|
350
|
+
onclick: () => startPlaylist(item.playlistIndex, item.trackIndex, { queue: 'liked' }),
|
|
351
|
+
},
|
|
352
|
+
h('span', { class: 'n', text: String(n + 1) }),
|
|
353
|
+
h('span', { class: 'who' },
|
|
354
|
+
h('span', { text: track.title }),
|
|
355
|
+
h('span', { class: 'st', text: playlist.title }))),
|
|
356
|
+
markButtons('tracks', trackMarkKey(playlist.id, track.src)));
|
|
357
|
+
});
|
|
358
|
+
$('#liked').replaceChildren(
|
|
359
|
+
h('button', { class: 'back', type: 'button', onclick: backHome }, svg(PATH.back, 22), 'Home'),
|
|
360
|
+
h('h1', { text: 'Liked' }),
|
|
361
|
+
h('p', { class: 'meta', text: items.length
|
|
362
|
+
? countLabel(items.length, termLower('track'), termLower('tracks'))
|
|
363
|
+
: `No liked ${termLower('tracks')} yet.` }),
|
|
364
|
+
items.length ? h('ol', { class: 'tracklist' }, rows) : null,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
197
368
|
/* ---------------------------------------------------------------- mini player */
|
|
198
369
|
|
|
199
370
|
function renderMini() {
|
|
200
371
|
const mini = $('#mini');
|
|
201
|
-
const
|
|
202
|
-
if (!
|
|
203
|
-
const track =
|
|
372
|
+
const playlist = currentPlaylist();
|
|
373
|
+
if (!playlist || state.view === 'player') { mini.hidden = true; return; }
|
|
374
|
+
const track = playlist.tracks[state.trackIndex];
|
|
204
375
|
mini.hidden = false;
|
|
205
376
|
mini.replaceChildren(
|
|
206
377
|
h('button', { class: 'open', type: 'button', 'aria-label': 'Open the player', onclick: () => setView('player') },
|
|
207
|
-
coverEl(
|
|
378
|
+
coverEl(playlist, 'mini-cover'),
|
|
208
379
|
h('span', { class: 'txt' },
|
|
209
|
-
h('span', { class: 'tt', text: state.finished ? `${
|
|
210
|
-
h('span', { class: 'st', text: state.finished ? 'Tap to choose what is next' :
|
|
380
|
+
h('span', { class: 'tt', text: state.finished ? `${playlist.title} finished` : track.title }),
|
|
381
|
+
h('span', { class: 'st', text: state.finished ? 'Tap to choose what is next' : playlist.title }))),
|
|
211
382
|
h('button', {
|
|
212
383
|
class: 'ctl', type: 'button', 'aria-label': state.playing ? 'Pause' : 'Play',
|
|
213
384
|
onclick: () => (media.paused ? playAction() : media.pause()),
|
|
@@ -224,37 +395,84 @@ function applyKind(kind) {
|
|
|
224
395
|
$('#stage').classList.toggle('is-video', video);
|
|
225
396
|
}
|
|
226
397
|
|
|
398
|
+
function openTrack(playlist, index) {
|
|
399
|
+
const playlistIndex = state.playlists.indexOf(playlist);
|
|
400
|
+
if (state.queue === 'liked') {
|
|
401
|
+
if (state.finished) startPlaylist(playlistIndex, index, { queue: 'liked' });
|
|
402
|
+
else {
|
|
403
|
+
state.playlistIndex = playlistIndex;
|
|
404
|
+
setTrack(index);
|
|
405
|
+
play();
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
jumpTo(index);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function trackRow(playlist, index, { subtitle } = {}) {
|
|
413
|
+
const track = playlist.tracks[index];
|
|
414
|
+
const mark = trackMark(playlist.id, track.src);
|
|
415
|
+
return h('li', { class: `track-row${mark === 'disliked' ? ' is-disliked' : ''}` },
|
|
416
|
+
h('button', {
|
|
417
|
+
class: 'track', type: 'button',
|
|
418
|
+
'aria-current': index === state.trackIndex && playlist === currentPlaylist() && !state.finished ? 'true' : null,
|
|
419
|
+
onclick: () => openTrack(playlist, index),
|
|
420
|
+
},
|
|
421
|
+
h('span', { class: 'n', text: String(index + 1) }),
|
|
422
|
+
subtitle
|
|
423
|
+
? h('span', { class: 'who' }, h('span', { text: track.title }), h('span', { class: 'st', text: subtitle }))
|
|
424
|
+
: h('span', { text: track.title })),
|
|
425
|
+
markButtons('tracks', trackMarkKey(playlist.id, track.src)));
|
|
426
|
+
}
|
|
427
|
+
|
|
227
428
|
function renderPlayer() {
|
|
228
|
-
const
|
|
229
|
-
if (!
|
|
230
|
-
|
|
429
|
+
const playlist = currentPlaylist();
|
|
430
|
+
if (!playlist) return;
|
|
431
|
+
applyTerms();
|
|
432
|
+
const track = playlist.tracks[state.trackIndex];
|
|
231
433
|
applyKind(track.kind);
|
|
232
434
|
|
|
233
435
|
const cover = $('#pCover');
|
|
234
|
-
const coverSrc =
|
|
436
|
+
const coverSrc = playlist.cover || 'icons/icon-512.png';
|
|
235
437
|
if (cover.getAttribute('src') !== coverSrc) cover.setAttribute('src', coverSrc);
|
|
236
438
|
$('#pTrack').textContent = track.title;
|
|
237
|
-
$('#pAlbum').textContent = [
|
|
238
|
-
|
|
439
|
+
$('#pAlbum').textContent = [playlist.title, playlist.artist].filter(Boolean).join(' · ');
|
|
440
|
+
$('#nowMarks').replaceChildren(markButtons('tracks', trackMarkKey(playlist.id, track.src)));
|
|
441
|
+
document.title = `${track.title} · ${playlist.title} · ${state.app.name}`;
|
|
239
442
|
|
|
240
443
|
$('#deck').hidden = state.finished;
|
|
241
444
|
$('#upnext').hidden = !state.finished;
|
|
242
445
|
if (state.finished) renderUpNext();
|
|
243
446
|
|
|
244
|
-
if (state.
|
|
245
|
-
state.
|
|
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 }))));
|
|
447
|
+
if (state.listPlaylistId !== playlist.id) {
|
|
448
|
+
state.listPlaylistId = playlist.id;
|
|
252
449
|
state.lyricsOpen = false;
|
|
253
450
|
}
|
|
254
|
-
$('#
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
451
|
+
$('#albumLinks').replaceChildren(...playlist.links.map((l) => h('li', {},
|
|
452
|
+
h('a', { href: l.url, target: '_blank', rel: 'noopener noreferrer', text: l.label }))));
|
|
453
|
+
|
|
454
|
+
let rows;
|
|
455
|
+
if (state.queue === 'liked') {
|
|
456
|
+
$('#listTools').replaceChildren();
|
|
457
|
+
$('#sideTitle').textContent = 'Liked';
|
|
458
|
+
rows = likedQueue(state.playlists, (id, src) => trackMark(id, src)).map((item) => {
|
|
459
|
+
const owner = state.playlists[item.playlistIndex];
|
|
460
|
+
return trackRow(owner, item.trackIndex, { subtitle: owner.title });
|
|
461
|
+
});
|
|
462
|
+
} else {
|
|
463
|
+
$('#sideTitle').textContent = term('tracks');
|
|
464
|
+
$('#listTools').replaceChildren(filterBar(state.trackFilter, state.showDislikedTracks,
|
|
465
|
+
(f) => { state.trackFilter = f; renderPlayer(); },
|
|
466
|
+
(s) => { state.showDislikedTracks = s; renderPlayer(); }));
|
|
467
|
+
const indexes = trackIndexes(playlist, { showDisliked: state.showDislikedTracks });
|
|
468
|
+
rows = indexes.map((i) => trackRow(playlist, i));
|
|
469
|
+
}
|
|
470
|
+
const emptyList = state.queue === 'liked'
|
|
471
|
+
? `No liked ${termLower('tracks')} yet.`
|
|
472
|
+
: state.trackFilter === 'liked'
|
|
473
|
+
? `No liked ${termLower('tracks')} in this ${termLower('playlist')}.`
|
|
474
|
+
: `Every ${termLower('track')} here is disliked.`;
|
|
475
|
+
$('#tracklist').replaceChildren(...(rows.length ? rows : [h('li', { class: 'empty', text: emptyList })]));
|
|
258
476
|
|
|
259
477
|
const lyrics = track.lyrics;
|
|
260
478
|
$('#lyricsWrap').hidden = !lyrics;
|
|
@@ -268,32 +486,55 @@ function renderPlayer() {
|
|
|
268
486
|
syncPlaying();
|
|
269
487
|
}
|
|
270
488
|
|
|
489
|
+
function suggestionIndexes(current) {
|
|
490
|
+
const out = [];
|
|
491
|
+
const count = state.playlists.length;
|
|
492
|
+
for (let step = 1; step < count && out.length < 3; step++) {
|
|
493
|
+
const i = (current + step) % count;
|
|
494
|
+
if (!playlistVisible(playlistMark(state.playlists[i].id), { filter: 'all', showDisliked: state.showDislikedHome })) continue;
|
|
495
|
+
out.push(i);
|
|
496
|
+
}
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
|
|
271
500
|
function renderUpNext() {
|
|
272
|
-
const
|
|
273
|
-
const picks =
|
|
501
|
+
const playlist = currentPlaylist();
|
|
502
|
+
const picks = suggestionIndexes(state.playlistIndex);
|
|
503
|
+
const liked = state.queue === 'liked';
|
|
274
504
|
const box = $('#upnext');
|
|
275
505
|
box.replaceChildren(
|
|
276
|
-
h('h3', { tabindex: '-1', text: picks.length ? 'Up next' : 'That was the
|
|
277
|
-
h('p', { class: 'done', text: `${
|
|
506
|
+
h('h3', { tabindex: '-1', text: picks.length ? 'Up next' : (liked ? 'That was everything you liked' : `That was the ${termLower('playlist')}`) }),
|
|
507
|
+
h('p', { class: 'done', text: liked ? 'Those were the liked ones.' : `${playlist.title} has finished.` }),
|
|
278
508
|
picks.length ? h('ul', {}, picks.map((i) => {
|
|
279
|
-
const a = state.
|
|
509
|
+
const a = state.playlists[i];
|
|
280
510
|
const locked = a.access === 'locked';
|
|
281
511
|
return h('li', {},
|
|
282
512
|
h('button', {
|
|
283
513
|
class: 'un', type: 'button', disabled: locked,
|
|
284
514
|
'aria-label': `${locked ? 'Locked. ' : 'Play '}${a.title}`,
|
|
285
|
-
onclick: () =>
|
|
515
|
+
onclick: () => startPlaylist(i, firstOpenIndex(a)),
|
|
286
516
|
},
|
|
287
517
|
coverEl(a),
|
|
288
518
|
h('span', {}, h('span', { class: 't', text: a.title }), h('span', { class: 'y', text: [a.artist, a.year].filter(Boolean).join(' · ') })),
|
|
289
519
|
h('span', { class: 'go' }, svg(PATH.play, 22))));
|
|
290
520
|
})) : null,
|
|
291
|
-
h('button', {
|
|
521
|
+
h('button', {
|
|
522
|
+
class: 'btn quiet again', type: 'button',
|
|
523
|
+
onclick: () => {
|
|
524
|
+
if (liked) {
|
|
525
|
+
const items = likedQueue(state.playlists, (id, src) => trackMark(id, src));
|
|
526
|
+
if (!items.length) { toast(nothingLiked()); return; }
|
|
527
|
+
startPlaylist(items[0].playlistIndex, items[0].trackIndex, { queue: 'liked' });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
startPlaylist(state.playlistIndex, firstOpenIndex(playlist));
|
|
531
|
+
},
|
|
532
|
+
}, liked ? `Play liked ${termLower('tracks')} again` : `Play ${playlist.title} again`),
|
|
292
533
|
);
|
|
293
534
|
}
|
|
294
535
|
|
|
295
536
|
function syncEndMode() {
|
|
296
|
-
document.querySelectorAll('.seg button').forEach((b) => {
|
|
537
|
+
document.querySelectorAll('.endmode .seg button').forEach((b) => {
|
|
297
538
|
b.setAttribute('aria-pressed', String(b.dataset.mode === state.endMode));
|
|
298
539
|
});
|
|
299
540
|
}
|
|
@@ -301,7 +542,6 @@ function syncEndMode() {
|
|
|
301
542
|
function syncPlaying() {
|
|
302
543
|
const playing = state.playing;
|
|
303
544
|
document.body.classList.toggle('is-playing', playing);
|
|
304
|
-
// SVG elements have no .hidden property, so toggle the attribute directly.
|
|
305
545
|
$('.i-play').toggleAttribute('hidden', playing);
|
|
306
546
|
$('.i-pause').toggleAttribute('hidden', !playing);
|
|
307
547
|
$('#playBtn').setAttribute('aria-label', playing ? 'Pause' : 'Play');
|
|
@@ -315,23 +555,29 @@ function showError(show) {
|
|
|
315
555
|
|
|
316
556
|
/* ---------------------------------------------------------------- playback */
|
|
317
557
|
|
|
318
|
-
async function
|
|
319
|
-
const
|
|
320
|
-
if (!
|
|
321
|
-
if (
|
|
322
|
-
|
|
558
|
+
async function startPlaylist(playlistIndex, trackIndex = 0, { startAt = 0, queue = null } = {}) {
|
|
559
|
+
const playlist = state.playlists[playlistIndex];
|
|
560
|
+
if (!playlist) return;
|
|
561
|
+
if (playlist.access === 'locked') { toast(`${playlist.title} is locked for now.`); return; }
|
|
562
|
+
if (trackIndex < 0) { toast(queue === 'liked' ? nothingLiked() : nothingHere()); return; }
|
|
563
|
+
state.queue = queue;
|
|
564
|
+
if (queue !== 'liked') {
|
|
565
|
+
state.trackFilter = 'all';
|
|
566
|
+
state.showDislikedTracks = false;
|
|
567
|
+
}
|
|
568
|
+
state.playlistIndex = playlistIndex;
|
|
323
569
|
setTrack(trackIndex, { startAt });
|
|
324
570
|
setView('player');
|
|
325
571
|
await play();
|
|
326
572
|
}
|
|
327
573
|
|
|
328
574
|
function setTrack(index, { startAt = 0 } = {}) {
|
|
329
|
-
const
|
|
575
|
+
const playlist = currentPlaylist();
|
|
330
576
|
state.trackIndex = index;
|
|
331
577
|
state.finished = false;
|
|
332
578
|
state.retried = false;
|
|
333
579
|
showError(false);
|
|
334
|
-
const track =
|
|
580
|
+
const track = playlist.tracks[index];
|
|
335
581
|
applyKind(track.kind);
|
|
336
582
|
media.src = track.src;
|
|
337
583
|
if (startAt > 0) {
|
|
@@ -341,7 +587,7 @@ function setTrack(index, { startAt = 0 } = {}) {
|
|
|
341
587
|
updateMediaSession();
|
|
342
588
|
renderPlayer();
|
|
343
589
|
saveLast();
|
|
344
|
-
warmCache(
|
|
590
|
+
warmCache(playlist, index);
|
|
345
591
|
}
|
|
346
592
|
|
|
347
593
|
async function play() {
|
|
@@ -358,42 +604,72 @@ async function play() {
|
|
|
358
604
|
}
|
|
359
605
|
|
|
360
606
|
function playAction() {
|
|
361
|
-
if (state.finished)
|
|
362
|
-
|
|
607
|
+
if (!state.finished) { play(); return; }
|
|
608
|
+
if (state.queue === 'liked') {
|
|
609
|
+
const items = likedQueue(state.playlists, (id, src) => trackMark(id, src));
|
|
610
|
+
if (!items.length) { toast(nothingLiked()); return; }
|
|
611
|
+
state.playlistIndex = items[0].playlistIndex;
|
|
612
|
+
setTrack(items[0].trackIndex);
|
|
613
|
+
play();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const playlist = currentPlaylist();
|
|
617
|
+
const vis = playlist ? playableIndexes(playlist) : [];
|
|
618
|
+
if (!vis.length) { toast(nothingHere()); return; }
|
|
619
|
+
setTrack(vis[0]);
|
|
620
|
+
play();
|
|
363
621
|
}
|
|
364
622
|
|
|
365
623
|
function jumpTo(index) {
|
|
366
|
-
if (state.finished) {
|
|
624
|
+
if (state.finished) { startPlaylist(state.playlistIndex, index, { queue: state.queue }); return; }
|
|
367
625
|
setTrack(index);
|
|
368
626
|
play();
|
|
369
627
|
}
|
|
370
628
|
|
|
371
|
-
function
|
|
372
|
-
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
629
|
+
function follow(action, { ended = false } = {}) {
|
|
630
|
+
if (action.type === 'empty') { toast(state.queue === 'liked' ? nothingLiked() : nothingHere()); return; }
|
|
631
|
+
if (action.type === 'end') {
|
|
632
|
+
if (!ended) media.pause();
|
|
633
|
+
finishPlaylist();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (action.type === 'edge') { media.currentTime = 0; return; }
|
|
637
|
+
if (action.item) state.playlistIndex = action.item.playlistIndex;
|
|
638
|
+
setTrack(action.item ? action.item.trackIndex : action.index);
|
|
377
639
|
play();
|
|
378
640
|
}
|
|
379
641
|
|
|
642
|
+
function next() {
|
|
643
|
+
if (state.queue === 'liked') {
|
|
644
|
+
follow(stepQueue(likedQueue(state.playlists, (id, src) => trackMark(id, src)), { playlistIndex: state.playlistIndex, trackIndex: state.trackIndex }, 1, state.endMode));
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const playlist = currentPlaylist();
|
|
648
|
+
if (!playlist) return;
|
|
649
|
+
follow(stepVisible(state.trackIndex, playableIndexes(playlist), 1, state.endMode));
|
|
650
|
+
}
|
|
651
|
+
|
|
380
652
|
function previous() {
|
|
381
|
-
if (!
|
|
382
|
-
if (media.currentTime > 3
|
|
383
|
-
|
|
384
|
-
|
|
653
|
+
if (!currentPlaylist()) return;
|
|
654
|
+
if (media.currentTime > 3) { media.currentTime = 0; return; }
|
|
655
|
+
if (state.queue === 'liked') {
|
|
656
|
+
follow(stepQueue(likedQueue(state.playlists, (id, src) => trackMark(id, src)), { playlistIndex: state.playlistIndex, trackIndex: state.trackIndex }, -1, state.endMode));
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
follow(stepVisible(state.trackIndex, playableIndexes(currentPlaylist()), -1, state.endMode));
|
|
385
660
|
}
|
|
386
661
|
|
|
387
662
|
function onEnded() {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
663
|
+
if (state.queue === 'liked') {
|
|
664
|
+
follow(stepQueue(likedQueue(state.playlists, (id, src) => trackMark(id, src)), { playlistIndex: state.playlistIndex, trackIndex: state.trackIndex }, 1, state.endMode), { ended: true });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const playlist = currentPlaylist();
|
|
668
|
+
if (!playlist) return;
|
|
669
|
+
follow(stepVisible(state.trackIndex, playableIndexes(playlist), 1, state.endMode), { ended: true });
|
|
394
670
|
}
|
|
395
671
|
|
|
396
|
-
function
|
|
672
|
+
function finishPlaylist() {
|
|
397
673
|
state.finished = true;
|
|
398
674
|
state.playing = false;
|
|
399
675
|
saveLast();
|
|
@@ -420,7 +696,7 @@ function resetSeek() {
|
|
|
420
696
|
|
|
421
697
|
media.addEventListener('play', () => { state.playing = true; syncPlaying(); });
|
|
422
698
|
media.addEventListener('pause', () => {
|
|
423
|
-
if (media.ended) return;
|
|
699
|
+
if (media.ended) return;
|
|
424
700
|
state.playing = false; syncPlaying(); saveLast();
|
|
425
701
|
});
|
|
426
702
|
media.addEventListener('ended', onEnded);
|
|
@@ -441,7 +717,7 @@ media.addEventListener('timeupdate', () => {
|
|
|
441
717
|
}
|
|
442
718
|
});
|
|
443
719
|
media.addEventListener('error', () => {
|
|
444
|
-
if (!media.error || !
|
|
720
|
+
if (!media.error || !currentPlaylist()) return;
|
|
445
721
|
if (!state.retried) {
|
|
446
722
|
state.retried = true;
|
|
447
723
|
const at = media.currentTime || 0;
|
|
@@ -473,12 +749,13 @@ seekEl.addEventListener('change', () => {
|
|
|
473
749
|
/* ---------------------------------------------------------------- resume */
|
|
474
750
|
|
|
475
751
|
function saveLast() {
|
|
476
|
-
const
|
|
477
|
-
if (!
|
|
752
|
+
const playlist = currentPlaylist();
|
|
753
|
+
if (!playlist) return;
|
|
478
754
|
store.set(LS.last, JSON.stringify({
|
|
479
|
-
id:
|
|
755
|
+
id: playlist.id,
|
|
480
756
|
track: state.finished ? 0 : state.trackIndex,
|
|
481
757
|
time: state.finished ? 0 : Math.floor(media.currentTime || 0),
|
|
758
|
+
queue: state.queue,
|
|
482
759
|
}));
|
|
483
760
|
}
|
|
484
761
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') saveLast(); });
|
|
@@ -487,12 +764,12 @@ window.addEventListener('pagehide', saveLast);
|
|
|
487
764
|
function loadLast() {
|
|
488
765
|
try {
|
|
489
766
|
const last = JSON.parse(store.get(LS.last) || 'null');
|
|
490
|
-
if (!last) return null;
|
|
491
|
-
const
|
|
492
|
-
if (
|
|
767
|
+
if (!last || last.queue === 'liked') return null;
|
|
768
|
+
const playlistIndex = state.playlists.findIndex((a) => a.id === last.id);
|
|
769
|
+
if (playlistIndex < 0) return null;
|
|
493
770
|
const trackIndex = Number.isInteger(last.track) ? last.track : 0;
|
|
494
|
-
if (trackIndex < 0 || trackIndex >= state.
|
|
495
|
-
return {
|
|
771
|
+
if (trackIndex < 0 || trackIndex >= state.playlists[playlistIndex].tracks.length) return null;
|
|
772
|
+
return { playlistIndex, trackIndex, time: Math.max(0, Number(last.time) || 0) };
|
|
496
773
|
} catch { return null; }
|
|
497
774
|
}
|
|
498
775
|
|
|
@@ -500,14 +777,14 @@ function loadLast() {
|
|
|
500
777
|
|
|
501
778
|
function updateMediaSession() {
|
|
502
779
|
if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
|
|
503
|
-
const
|
|
504
|
-
if (!
|
|
505
|
-
const track =
|
|
780
|
+
const playlist = currentPlaylist();
|
|
781
|
+
if (!playlist) return;
|
|
782
|
+
const track = playlist.tracks[state.trackIndex];
|
|
506
783
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
507
784
|
title: track.title,
|
|
508
|
-
artist:
|
|
509
|
-
album:
|
|
510
|
-
artwork:
|
|
785
|
+
artist: playlist.artist || state.app.name,
|
|
786
|
+
album: playlist.title,
|
|
787
|
+
artwork: playlist.cover ? [{ src: abs(playlist.cover), sizes: '640x640', type: mimeFor(playlist.cover) }] : [],
|
|
511
788
|
});
|
|
512
789
|
}
|
|
513
790
|
|
|
@@ -526,19 +803,19 @@ function bindMediaSession() {
|
|
|
526
803
|
}
|
|
527
804
|
|
|
528
805
|
/* ---------------------------------------------------------------- offline media cache */
|
|
529
|
-
// The
|
|
530
|
-
// if the connection drops. Only the current
|
|
806
|
+
// The playlist in play is copied into the Cache API, next track first, so it keeps playing
|
|
807
|
+
// if the connection drops. Only the current playlist is kept; others are removed.
|
|
531
808
|
|
|
532
809
|
const MEDIA_CACHE = 'pv-media';
|
|
533
810
|
let warmToken = 0;
|
|
534
811
|
|
|
535
|
-
async function warmCache(
|
|
812
|
+
async function warmCache(playlist, current) {
|
|
536
813
|
const token = ++warmToken;
|
|
537
814
|
if (!('caches' in window)) return;
|
|
538
815
|
if (navigator.connection && navigator.connection.saveData) return;
|
|
539
816
|
try {
|
|
540
817
|
const cache = await caches.open(MEDIA_CACHE);
|
|
541
|
-
const urls =
|
|
818
|
+
const urls = playlist.tracks.map((t) => abs(t.src));
|
|
542
819
|
const keep = new Set(urls);
|
|
543
820
|
for (const req of await cache.keys()) if (!keep.has(req.url)) await cache.delete(req);
|
|
544
821
|
const order = [...urls.slice(current + 1), ...urls.slice(0, current + 1)];
|
|
@@ -610,8 +887,9 @@ function registerServiceWorker() {
|
|
|
610
887
|
/* ---------------------------------------------------------------- wiring + start */
|
|
611
888
|
|
|
612
889
|
function wire() {
|
|
613
|
-
$('#brand').addEventListener('click', (e) => { e.preventDefault();
|
|
614
|
-
$('#
|
|
890
|
+
$('#brand').addEventListener('click', (e) => { e.preventDefault(); if (state.view !== 'home') backHome(); });
|
|
891
|
+
$('#likedBtn').addEventListener('click', () => setView('liked'));
|
|
892
|
+
$('#backBtn').addEventListener('click', backOne);
|
|
615
893
|
$('#playBtn').addEventListener('click', () => (media.paused ? playAction() : media.pause()));
|
|
616
894
|
$('#prevBtn').addEventListener('click', previous);
|
|
617
895
|
$('#nextBtn').addEventListener('click', next);
|
|
@@ -621,7 +899,7 @@ function wire() {
|
|
|
621
899
|
media.load();
|
|
622
900
|
play();
|
|
623
901
|
});
|
|
624
|
-
document.querySelectorAll('.seg button').forEach((b) => b.addEventListener('click', () => {
|
|
902
|
+
document.querySelectorAll('.endmode .seg button').forEach((b) => b.addEventListener('click', () => {
|
|
625
903
|
state.endMode = b.dataset.mode;
|
|
626
904
|
store.set(LS.endMode, state.endMode);
|
|
627
905
|
syncEndMode();
|
|
@@ -631,7 +909,7 @@ function wire() {
|
|
|
631
909
|
renderPlayer();
|
|
632
910
|
});
|
|
633
911
|
document.addEventListener('keydown', (e) => {
|
|
634
|
-
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || state.
|
|
912
|
+
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || state.playlistIndex < 0) return;
|
|
635
913
|
const t = e.target;
|
|
636
914
|
if (t instanceof Element && t.closest('input, textarea, select, [contenteditable]')) return;
|
|
637
915
|
if (e.code === 'Space' && !(t instanceof Element && t.closest('button, a'))) {
|
|
@@ -647,6 +925,7 @@ async function init() {
|
|
|
647
925
|
setupInstall();
|
|
648
926
|
bindMediaSession();
|
|
649
927
|
wire();
|
|
928
|
+
state.marks = loadMarks();
|
|
650
929
|
|
|
651
930
|
let raw;
|
|
652
931
|
try {
|
|
@@ -655,19 +934,19 @@ async function init() {
|
|
|
655
934
|
raw = await res.json();
|
|
656
935
|
} catch (err) {
|
|
657
936
|
console.error('catalog.json failed to load:', err);
|
|
658
|
-
showStatus("The
|
|
937
|
+
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.");
|
|
659
938
|
return;
|
|
660
939
|
}
|
|
661
940
|
|
|
662
941
|
const catalog = normalizeCatalog(raw);
|
|
663
942
|
catalog.warnings.forEach((w) => console.warn('[catalog]', w));
|
|
664
|
-
|
|
665
|
-
|
|
943
|
+
state.app = catalog.app;
|
|
944
|
+
if (!catalog.playlists.length) {
|
|
945
|
+
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.`);
|
|
666
946
|
return;
|
|
667
947
|
}
|
|
668
948
|
|
|
669
|
-
state.
|
|
670
|
-
state.albums = catalog.albums;
|
|
949
|
+
state.playlists = catalog.playlists;
|
|
671
950
|
state.cards = catalog.cards;
|
|
672
951
|
$('#brand').textContent = state.app.name;
|
|
673
952
|
document.title = state.app.name;
|
package/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
6
|
<title>Pocket Videos</title>
|
|
7
|
-
<meta name="description" content="Pick
|
|
7
|
+
<meta name="description" content="Pick a playlist and let it play, start to finish.">
|
|
8
8
|
<meta name="theme-color" content="#d6dadc" media="(prefers-color-scheme: light)">
|
|
9
9
|
<meta name="theme-color" content="#141a1c" media="(prefers-color-scheme: dark)">
|
|
10
10
|
<link rel="manifest" href="manifest.webmanifest">
|
|
@@ -24,18 +24,23 @@
|
|
|
24
24
|
<div class="app">
|
|
25
25
|
<header class="bar">
|
|
26
26
|
<a class="brand" href="./" id="brand">Pocket Videos</a>
|
|
27
|
-
<
|
|
27
|
+
<div class="bar-actions">
|
|
28
|
+
<button class="btn small" id="likedBtn" type="button">Liked</button>
|
|
29
|
+
<button class="btn small" id="installBtn" type="button" hidden>Install app</button>
|
|
30
|
+
</div>
|
|
28
31
|
</header>
|
|
29
32
|
|
|
30
33
|
<main id="main">
|
|
31
34
|
<p class="status" id="status" role="status">Loading the catalog…</p>
|
|
32
35
|
|
|
33
|
-
<section id="home" class="view" aria-label="
|
|
36
|
+
<section id="home" class="view" aria-label="Playlists" hidden></section>
|
|
37
|
+
|
|
38
|
+
<section id="liked" class="view" aria-label="Liked" hidden></section>
|
|
34
39
|
|
|
35
40
|
<section id="player" class="view player" aria-label="Player" hidden>
|
|
36
41
|
<button class="back" id="backBtn" type="button">
|
|
37
42
|
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
38
|
-
<span>All
|
|
43
|
+
<span id="backLabel">All playlists</span>
|
|
39
44
|
</button>
|
|
40
45
|
|
|
41
46
|
<div class="player-grid">
|
|
@@ -46,10 +51,13 @@
|
|
|
46
51
|
</div>
|
|
47
52
|
|
|
48
53
|
<div class="now">
|
|
49
|
-
<
|
|
54
|
+
<div class="now-head">
|
|
55
|
+
<h2 id="pTrack" class="now-title"></h2>
|
|
56
|
+
<div id="nowMarks" class="marks"></div>
|
|
57
|
+
</div>
|
|
50
58
|
<p id="pAlbum" class="now-album"></p>
|
|
51
59
|
<p id="pErr" class="err" role="alert" hidden>
|
|
52
|
-
<span>This track won't load.</span>
|
|
60
|
+
<span id="errText">This track won't load.</span>
|
|
53
61
|
<button type="button" id="retryBtn" class="linkbtn">Try again</button>
|
|
54
62
|
</p>
|
|
55
63
|
|
|
@@ -74,7 +82,7 @@
|
|
|
74
82
|
</div>
|
|
75
83
|
|
|
76
84
|
<div class="endmode" role="group" aria-labelledby="endLabel">
|
|
77
|
-
<span class="endmode-label" id="endLabel">When the
|
|
85
|
+
<span class="endmode-label" id="endLabel">When the playlist ends</span>
|
|
78
86
|
<div class="seg">
|
|
79
87
|
<button type="button" data-mode="repeat" aria-pressed="true">Repeat</button>
|
|
80
88
|
<button type="button" data-mode="pause" aria-pressed="false">Pause</button>
|
|
@@ -87,7 +95,8 @@
|
|
|
87
95
|
</div>
|
|
88
96
|
|
|
89
97
|
<aside class="side">
|
|
90
|
-
<
|
|
98
|
+
<div id="listTools"></div>
|
|
99
|
+
<h3 class="side-title" id="sideTitle">Tracks</h3>
|
|
91
100
|
<ol id="tracklist" class="tracklist"></ol>
|
|
92
101
|
|
|
93
102
|
<div class="lyrics-wrap" id="lyricsWrap" hidden>
|
package/logic.js
CHANGED
|
@@ -20,7 +20,7 @@ export function parseISODate(str) {
|
|
|
20
20
|
return real ? date : null;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/** Which
|
|
23
|
+
/** Which playlist is featured. Rotates every 7 days counted from startISO. */
|
|
24
24
|
export function featuredIndex(startISO, now, count) {
|
|
25
25
|
if (count < 1) return -1;
|
|
26
26
|
const start = parseISODate(startISO);
|
|
@@ -35,7 +35,7 @@ export function dailyCardIndex(now, count) {
|
|
|
35
35
|
return mod(localDayNumber(now), count);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** The next `n`
|
|
38
|
+
/** The next `n` playlists after `current` in catalog order, wrapping, never including `current`. */
|
|
39
39
|
export function upNextIndexes(current, count, n = 3) {
|
|
40
40
|
const out = [];
|
|
41
41
|
for (let step = 1; step < count && out.length < n; step++) out.push((current + step) % count);
|
|
@@ -58,7 +58,27 @@ export function formatTime(seconds) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const text = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
61
|
-
const slug = (s) => text(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || '
|
|
61
|
+
const slug = (s) => text(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'playlist';
|
|
62
|
+
|
|
63
|
+
export const DEFAULT_TERMS = {
|
|
64
|
+
playlist: 'Playlist',
|
|
65
|
+
playlists: 'Playlists',
|
|
66
|
+
track: 'Track',
|
|
67
|
+
tracks: 'Tracks',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** First letter down, for words that sit in the middle of a sentence. */
|
|
71
|
+
export function lowerFirst(word) {
|
|
72
|
+
const s = String(word || '');
|
|
73
|
+
return s ? s.charAt(0).toLowerCase() + s.slice(1) : s;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeTerms(raw) {
|
|
77
|
+
const src = raw && typeof raw === 'object' ? raw : {};
|
|
78
|
+
const terms = {};
|
|
79
|
+
for (const key of Object.keys(DEFAULT_TERMS)) terms[key] = text(src[key]) || DEFAULT_TERMS[key];
|
|
80
|
+
return terms;
|
|
81
|
+
}
|
|
62
82
|
const VIDEO_EXT = new Set(['mp4', 'webm', 'mov']);
|
|
63
83
|
|
|
64
84
|
/** 'video' or 'audio'. An explicit kind wins; otherwise the file extension decides. */
|
|
@@ -71,14 +91,20 @@ export function mediaKind(src, explicit) {
|
|
|
71
91
|
|
|
72
92
|
/**
|
|
73
93
|
* Validate and tidy the raw catalog.json. Never throws on bad input:
|
|
74
|
-
* bad
|
|
94
|
+
* bad playlists are skipped and reported in `warnings`.
|
|
95
|
+
* `playlists` is the list. `albums` is accepted when `playlists` is absent.
|
|
75
96
|
*/
|
|
76
97
|
export function normalizeCatalog(raw) {
|
|
77
98
|
const warnings = [];
|
|
78
|
-
const
|
|
79
|
-
app
|
|
99
|
+
const rawApp = (raw && raw.app) || {};
|
|
100
|
+
const app = {
|
|
101
|
+
name: text(rawApp.name) || 'Pocket Videos',
|
|
102
|
+
defaultEndMode: rawApp.defaultEndMode,
|
|
103
|
+
featuredStartDate: rawApp.featuredStartDate || DEFAULT_START,
|
|
104
|
+
terms: normalizeTerms(rawApp.terms),
|
|
105
|
+
};
|
|
80
106
|
if (app.defaultEndMode !== 'repeat' && app.defaultEndMode !== 'pause') {
|
|
81
|
-
warnings.push(`app.defaultEndMode must be "repeat" or "pause"; using "repeat".`);
|
|
107
|
+
if (rawApp.defaultEndMode != null) warnings.push(`app.defaultEndMode must be "repeat" or "pause"; using "repeat".`);
|
|
82
108
|
app.defaultEndMode = 'repeat';
|
|
83
109
|
}
|
|
84
110
|
if (!parseISODate(app.featuredStartDate)) {
|
|
@@ -86,11 +112,13 @@ export function normalizeCatalog(raw) {
|
|
|
86
112
|
app.featuredStartDate = DEFAULT_START;
|
|
87
113
|
}
|
|
88
114
|
|
|
89
|
-
const
|
|
115
|
+
const playlists = [];
|
|
90
116
|
const seen = new Set();
|
|
91
|
-
const
|
|
117
|
+
const usePlaylists = Array.isArray(raw && raw.playlists);
|
|
118
|
+
const list = usePlaylists ? raw.playlists : (Array.isArray(raw && raw.albums) ? raw.albums : []);
|
|
119
|
+
const listName = usePlaylists ? 'playlists' : 'albums';
|
|
92
120
|
list.forEach((a, i) => {
|
|
93
|
-
const where =
|
|
121
|
+
const where = `${listName}[${i}]`;
|
|
94
122
|
if (!a || !text(a.title)) { warnings.push(`${where}: no title, skipped.`); return; }
|
|
95
123
|
const id = text(a.id) || slug(a.title);
|
|
96
124
|
if (seen.has(id)) { warnings.push(`${where}: duplicate id "${id}", skipped.`); return; }
|
|
@@ -104,7 +132,7 @@ export function normalizeCatalog(raw) {
|
|
|
104
132
|
.filter(Boolean);
|
|
105
133
|
if (!tracks.length) { warnings.push(`${where} ("${text(a.title)}"): no tracks with a title and a media file, skipped.`); return; }
|
|
106
134
|
seen.add(id);
|
|
107
|
-
|
|
135
|
+
playlists.push({
|
|
108
136
|
id,
|
|
109
137
|
title: text(a.title),
|
|
110
138
|
artist: text(a.artist),
|
|
@@ -120,7 +148,125 @@ export function normalizeCatalog(raw) {
|
|
|
120
148
|
|
|
121
149
|
const cards = (Array.isArray(raw && raw.dailyCards) ? raw.dailyCards : [])
|
|
122
150
|
.filter((c) => c && text(c.hook) && text(c.pivot))
|
|
123
|
-
.map((c) => ({ hook: text(c.hook), pivot: text(c.pivot),
|
|
151
|
+
.map((c) => ({ hook: text(c.hook), pivot: text(c.pivot), playlist: text(c.playlist) || text(c.album) }));
|
|
152
|
+
|
|
153
|
+
return { app, playlists, cards, warnings };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 'liked' | 'disliked' | 'none'. Pressing the active one clears it; the other one switches. */
|
|
157
|
+
export function toggleMark(current, action) {
|
|
158
|
+
const cur = current === 'liked' || current === 'disliked' ? current : 'none';
|
|
159
|
+
if (action === 'like') return cur === 'liked' ? 'none' : 'liked';
|
|
160
|
+
if (action === 'dislike') return cur === 'disliked' ? 'none' : 'disliked';
|
|
161
|
+
return cur;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function emptyMarks() {
|
|
165
|
+
return { playlists: {}, tracks: {} };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function trackMarkKey(playlistId, src) {
|
|
169
|
+
return `${playlistId}\n${src}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Return a new marks object. A cleared mark is removed. */
|
|
173
|
+
export function applyMark(marks, scope, key, action) {
|
|
174
|
+
const next = {
|
|
175
|
+
playlists: { ...(marks && marks.playlists) },
|
|
176
|
+
tracks: { ...(marks && marks.tracks) },
|
|
177
|
+
};
|
|
178
|
+
const value = toggleMark(next[scope][key], action);
|
|
179
|
+
if (value === 'none') delete next[scope][key];
|
|
180
|
+
else next[scope][key] = value;
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function playlistVisible(mark, { filter = 'all', showDisliked = false } = {}) {
|
|
185
|
+
if (filter === 'liked') return mark === 'liked';
|
|
186
|
+
if (mark === 'disliked' && !showDisliked) return false;
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Track indexes the filter shows.
|
|
192
|
+
* 'all' hides disliked unless showDisliked. 'liked' keeps only likes.
|
|
193
|
+
* markAt(index, track) returns 'liked', 'disliked', or anything else.
|
|
194
|
+
*/
|
|
195
|
+
export function visibleTrackIndexes(tracks, markAt, { filter = 'all', showDisliked = false } = {}) {
|
|
196
|
+
const out = [];
|
|
197
|
+
tracks.forEach((track, i) => {
|
|
198
|
+
const mark = markAt(i, track);
|
|
199
|
+
if (filter === 'liked') {
|
|
200
|
+
if (mark === 'liked') out.push(i);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (mark === 'disliked' && !showDisliked) return;
|
|
204
|
+
out.push(i);
|
|
205
|
+
});
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Step along visible indexes. dir 1 moves forward, -1 backward.
|
|
211
|
+
* Forward off the end repeats the first visible track, or ends in pause mode.
|
|
212
|
+
* 'empty' means nothing is visible. 'edge' means there is no neighbor that way.
|
|
213
|
+
*/
|
|
214
|
+
export function stepVisible(currentIndex, visible, dir, endMode) {
|
|
215
|
+
if (!visible.length) return { type: 'empty' };
|
|
216
|
+
const pos = visible.indexOf(currentIndex);
|
|
217
|
+
if (dir === 1) {
|
|
218
|
+
if (pos >= 0 && pos < visible.length - 1) return { type: 'next', index: visible[pos + 1] };
|
|
219
|
+
if (pos < 0) {
|
|
220
|
+
const ahead = visible.find((i) => i > currentIndex);
|
|
221
|
+
if (ahead != null) return { type: 'next', index: ahead };
|
|
222
|
+
}
|
|
223
|
+
if (endMode === 'pause') return { type: 'end' };
|
|
224
|
+
return { type: 'repeat', index: visible[0] };
|
|
225
|
+
}
|
|
226
|
+
if (pos > 0) return { type: 'next', index: visible[pos - 1] };
|
|
227
|
+
if (pos < 0) {
|
|
228
|
+
let behind = -1;
|
|
229
|
+
for (const i of visible) if (i < currentIndex) behind = i;
|
|
230
|
+
if (behind >= 0) return { type: 'next', index: behind };
|
|
231
|
+
}
|
|
232
|
+
return { type: 'edge' };
|
|
233
|
+
}
|
|
124
234
|
|
|
125
|
-
|
|
235
|
+
/** Liked tracks in catalog order. trackMark(playlistId, src) returns the mark. */
|
|
236
|
+
export function likedQueue(playlists, trackMark) {
|
|
237
|
+
const items = [];
|
|
238
|
+
playlists.forEach((playlist, playlistIndex) => {
|
|
239
|
+
playlist.tracks.forEach((track, trackIndex) => {
|
|
240
|
+
if (trackMark(playlist.id, track.src) === 'liked') items.push({ playlistIndex, trackIndex });
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
return items;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Step through a liked queue. current is { playlistIndex, trackIndex } or null. */
|
|
247
|
+
export function stepQueue(items, current, dir, endMode) {
|
|
248
|
+
if (!items.length) return { type: 'empty' };
|
|
249
|
+
const pos = current
|
|
250
|
+
? items.findIndex((it) => it.playlistIndex === current.playlistIndex && it.trackIndex === current.trackIndex)
|
|
251
|
+
: -1;
|
|
252
|
+
const after = (it) => !current || it.playlistIndex > current.playlistIndex
|
|
253
|
+
|| (it.playlistIndex === current.playlistIndex && it.trackIndex > current.trackIndex);
|
|
254
|
+
const before = (it) => current && (it.playlistIndex < current.playlistIndex
|
|
255
|
+
|| (it.playlistIndex === current.playlistIndex && it.trackIndex < current.trackIndex));
|
|
256
|
+
if (dir === 1) {
|
|
257
|
+
if (pos >= 0 && pos < items.length - 1) return { type: 'next', item: items[pos + 1] };
|
|
258
|
+
if (pos < 0 && current) {
|
|
259
|
+
const ahead = items.find(after);
|
|
260
|
+
if (ahead) return { type: 'next', item: ahead };
|
|
261
|
+
}
|
|
262
|
+
if (endMode === 'pause') return { type: 'end' };
|
|
263
|
+
return { type: 'repeat', item: items[0] };
|
|
264
|
+
}
|
|
265
|
+
if (pos > 0) return { type: 'next', item: items[pos - 1] };
|
|
266
|
+
if (pos < 0 && current) {
|
|
267
|
+
let behind = null;
|
|
268
|
+
for (const it of items) if (before(it)) behind = it;
|
|
269
|
+
if (behind) return { type: 'next', item: behind };
|
|
270
|
+
}
|
|
271
|
+
return { type: 'edge' };
|
|
126
272
|
}
|
package/manifest.webmanifest
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "./",
|
|
3
3
|
"name": "Pocket Videos",
|
|
4
4
|
"short_name": "Pocket Videos",
|
|
5
|
-
"description": "Pick
|
|
5
|
+
"description": "Pick a playlist and let it play, start to finish.",
|
|
6
6
|
"start_url": "./",
|
|
7
7
|
"scope": "./",
|
|
8
8
|
"display": "standalone",
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x12i/pocket-videos",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "Pocket Videos: an installable web app that plays
|
|
6
|
+
"description": "Pocket Videos: an installable web app that plays a playlist of audio and video, start to finish.",
|
|
7
7
|
"exports": {
|
|
8
8
|
"./client-dir": "./client-dir.js",
|
|
9
9
|
"./logic.js": "./logic.js"
|
package/styles.css
CHANGED
|
@@ -133,8 +133,10 @@ body.is-playing .fog i:nth-child(3) { animation: drift 52s ease-in-out infinite
|
|
|
133
133
|
display: flex;
|
|
134
134
|
align-items: center;
|
|
135
135
|
justify-content: space-between;
|
|
136
|
+
gap: 12px;
|
|
136
137
|
min-height: 60px;
|
|
137
138
|
}
|
|
139
|
+
.bar-actions { display: flex; align-items: center; gap: 8px; flex: none; }
|
|
138
140
|
.brand {
|
|
139
141
|
font-family: var(--display);
|
|
140
142
|
font-weight: 650;
|
|
@@ -312,7 +314,9 @@ body.is-playing .fog i:nth-child(3) { animation: drift 52s ease-in-out infinite
|
|
|
312
314
|
@media (min-width: 720px) { .grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px 24px; } }
|
|
313
315
|
@media (min-width: 1000px) { .grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
|
314
316
|
|
|
315
|
-
.album {
|
|
317
|
+
.album { text-align: left; }
|
|
318
|
+
.album.is-disliked .album-open { opacity: 0.55; }
|
|
319
|
+
.album-open {
|
|
316
320
|
display: block;
|
|
317
321
|
width: 100%;
|
|
318
322
|
padding: 0;
|
|
@@ -320,6 +324,7 @@ body.is-playing .fog i:nth-child(3) { animation: drift 52s ease-in-out infinite
|
|
|
320
324
|
background: none;
|
|
321
325
|
text-align: left;
|
|
322
326
|
}
|
|
327
|
+
.album .marks { margin-top: 8px; }
|
|
323
328
|
.album .cover { position: relative; }
|
|
324
329
|
.album img {
|
|
325
330
|
width: 100%;
|
|
@@ -329,7 +334,7 @@ body.is-playing .fog i:nth-child(3) { animation: drift 52s ease-in-out infinite
|
|
|
329
334
|
box-shadow: 0 14px 26px -16px rgba(28, 36, 39, 0.6);
|
|
330
335
|
transition: transform 0.18s ease;
|
|
331
336
|
}
|
|
332
|
-
@media (hover: hover) { .album:hover img { transform: translateY(-3px); } }
|
|
337
|
+
@media (hover: hover) { .album-open:hover img { transform: translateY(-3px); } }
|
|
333
338
|
.album .t { display: block; margin-top: 12px; font-weight: 650; line-height: 1.25; }
|
|
334
339
|
.album .y { display: block; margin-top: 2px; font-size: 0.875rem; color: var(--ink-2); }
|
|
335
340
|
.album .badge {
|
|
@@ -343,7 +348,7 @@ body.is-playing .fog i:nth-child(3) { animation: drift 52s ease-in-out infinite
|
|
|
343
348
|
background: var(--marble);
|
|
344
349
|
color: var(--ink);
|
|
345
350
|
}
|
|
346
|
-
.album[aria-current="true"] .t { box-shadow: inset 3px 0 0 var(--accent); padding-left: 10px; margin-left: -10px; }
|
|
351
|
+
.album-open[aria-current="true"] .t { box-shadow: inset 3px 0 0 var(--accent); padding-left: 10px; margin-left: -10px; }
|
|
347
352
|
.album.locked img { filter: grayscale(0.7) brightness(0.9); }
|
|
348
353
|
|
|
349
354
|
.cover-fallback {
|
|
@@ -476,8 +481,12 @@ body.is-playing .fog i:nth-child(3) { animation: drift 52s ease-in-out infinite
|
|
|
476
481
|
.now { width: min(100%, 520px); min-width: 0; }
|
|
477
482
|
@media (min-width: 720px) { .now { width: 100%; } }
|
|
478
483
|
|
|
484
|
+
.now-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
|
485
|
+
.now-head .marks { margin-top: 6px; }
|
|
479
486
|
.now-title {
|
|
480
487
|
margin: 0 0 4px;
|
|
488
|
+
flex: 1;
|
|
489
|
+
min-width: 0;
|
|
481
490
|
font-family: var(--display);
|
|
482
491
|
font-weight: 650;
|
|
483
492
|
font-size: clamp(1.7rem, 5.4vw, 2.4rem);
|
|
@@ -592,6 +601,30 @@ input[type="range"]::-moz-range-thumb { width: 20px; height: 20px; border: 0; bo
|
|
|
592
601
|
@media (hover: hover) { .un:not([disabled]):hover { border-color: var(--ink-2); } }
|
|
593
602
|
.upnext .again { margin-top: 16px; }
|
|
594
603
|
|
|
604
|
+
.filters {
|
|
605
|
+
display: flex;
|
|
606
|
+
flex-wrap: wrap;
|
|
607
|
+
align-items: center;
|
|
608
|
+
gap: 12px 16px;
|
|
609
|
+
margin: 0 0 16px;
|
|
610
|
+
}
|
|
611
|
+
.filters .show-disliked { min-height: 44px; }
|
|
612
|
+
.empty { margin: 8px 0 16px; color: var(--ink-2); }
|
|
613
|
+
|
|
614
|
+
.marks { display: flex; flex: none; gap: 6px; }
|
|
615
|
+
.mark {
|
|
616
|
+
min-height: 36px;
|
|
617
|
+
padding: 0 12px;
|
|
618
|
+
border: 1.5px solid var(--line);
|
|
619
|
+
border-radius: 999px;
|
|
620
|
+
background: none;
|
|
621
|
+
color: var(--ink);
|
|
622
|
+
font-size: 0.8125rem;
|
|
623
|
+
font-weight: 650;
|
|
624
|
+
}
|
|
625
|
+
.mark[aria-pressed="true"].like { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
|
|
626
|
+
.mark[aria-pressed="true"].dislike { background: var(--ink); color: var(--fog); border-color: transparent; }
|
|
627
|
+
|
|
595
628
|
.side { min-width: 0; }
|
|
596
629
|
.side-title {
|
|
597
630
|
margin: 0 0 4px;
|
|
@@ -600,21 +633,30 @@ input[type="range"]::-moz-range-thumb { width: 20px; height: 20px; border: 0; bo
|
|
|
600
633
|
font-size: 1.25rem;
|
|
601
634
|
}
|
|
602
635
|
.tracklist { list-style: none; margin: 0; padding: 0; }
|
|
636
|
+
.track-row {
|
|
637
|
+
display: flex;
|
|
638
|
+
align-items: center;
|
|
639
|
+
gap: 4px;
|
|
640
|
+
border-bottom: 1px solid var(--line);
|
|
641
|
+
}
|
|
642
|
+
.track-row.is-disliked { opacity: 0.55; }
|
|
603
643
|
.track {
|
|
604
644
|
display: grid;
|
|
605
645
|
grid-template-columns: 28px 1fr;
|
|
606
646
|
gap: 12px;
|
|
607
|
-
align-items:
|
|
647
|
+
align-items: center;
|
|
608
648
|
width: 100%;
|
|
649
|
+
min-width: 0;
|
|
609
650
|
min-height: 48px;
|
|
610
651
|
padding: 11px 8px 11px 10px;
|
|
611
652
|
border: 0;
|
|
612
|
-
border-bottom: 1px solid var(--line);
|
|
613
653
|
background: none;
|
|
614
654
|
text-align: left;
|
|
615
655
|
font-weight: 500;
|
|
616
656
|
line-height: 1.3;
|
|
617
657
|
}
|
|
658
|
+
.track .who { display: flex; flex-direction: column; min-width: 0; }
|
|
659
|
+
.track .st { color: var(--ink-2); font-size: 0.8125rem; font-weight: 500; }
|
|
618
660
|
.track .n { text-align: right; color: var(--ink-2); font-variant-numeric: tabular-nums; }
|
|
619
661
|
.track[aria-current="true"] { font-weight: 750; box-shadow: inset 3px 0 0 var(--accent); }
|
|
620
662
|
@media (hover: hover) { .track:hover { background: color-mix(in srgb, var(--marble) 55%, transparent); } }
|
package/sw.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// - App files: cached on install, served cache-first. Bump VERSION when you change any of them.
|
|
3
3
|
// - catalog.json: network-first, so edits show up right away; the cached copy is used offline.
|
|
4
4
|
// - Covers: shown from cache instantly, refreshed in the background.
|
|
5
|
-
// - Media: served from the media cache when the
|
|
5
|
+
// - Media: served from the media cache when the playlist is saved there (with Range support,
|
|
6
6
|
// which phones need for seeking in MP3 and MP4); otherwise streamed from the network.
|
|
7
7
|
|
|
8
|
-
const VERSION = '
|
|
8
|
+
const VERSION = 'v3';
|
|
9
9
|
const SHELL = `pv-shell-${VERSION}`;
|
|
10
10
|
const DATA = 'pv-data';
|
|
11
11
|
const COVERS = 'pv-covers';
|