@x12i/pocket-videos 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app.js +416 -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,135 @@ 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();
|
|
100
207
|
}
|
|
101
|
-
if (view === 'player'
|
|
102
|
-
|
|
103
|
-
|
|
208
|
+
if (push && was !== view && (view === 'player' || view === 'liked')) {
|
|
209
|
+
navDepth += 1;
|
|
210
|
+
history.pushState({ v: view, depth: navDepth }, '');
|
|
104
211
|
}
|
|
212
|
+
if (!push) navDepth = (history.state && history.state.depth) || 0;
|
|
105
213
|
renderMini();
|
|
106
214
|
if (was !== view) window.scrollTo(0, 0);
|
|
107
215
|
}
|
|
108
216
|
|
|
109
217
|
window.addEventListener('popstate', () => {
|
|
110
|
-
|
|
111
|
-
|
|
218
|
+
navDepth = (history.state && history.state.depth) || 0;
|
|
219
|
+
const v = history.state && history.state.v;
|
|
220
|
+
if (v === 'player' || v === 'liked') setView(v, { push: false });
|
|
221
|
+
else setView('home', { push: false });
|
|
112
222
|
});
|
|
113
223
|
|
|
114
|
-
function
|
|
115
|
-
if (
|
|
116
|
-
|
|
224
|
+
function backOne() {
|
|
225
|
+
if (navDepth > 0) history.back();
|
|
226
|
+
else setView('home', { push: false });
|
|
227
|
+
}
|
|
228
|
+
function backHome() {
|
|
229
|
+
if (navDepth > 0) history.go(-navDepth);
|
|
117
230
|
else setView('home', { push: false });
|
|
118
231
|
}
|
|
119
232
|
|
|
@@ -134,21 +247,30 @@ function installTip() {
|
|
|
134
247
|
}, 'Got it'));
|
|
135
248
|
}
|
|
136
249
|
|
|
250
|
+
function countLabel(n, singular, plural) {
|
|
251
|
+
return `${n} ${n === 1 ? singular : plural}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
137
254
|
function renderHome() {
|
|
255
|
+
applyTerms();
|
|
138
256
|
const home = $('#home');
|
|
139
257
|
const now = new Date();
|
|
140
|
-
const fi = featuredIndex(state.app.featuredStartDate, now, state.
|
|
141
|
-
const feat = state.
|
|
142
|
-
const metaLine = [
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
258
|
+
const fi = featuredIndex(state.app.featuredStartDate, now, state.playlists.length);
|
|
259
|
+
const feat = state.playlists[fi];
|
|
260
|
+
const metaLine = [
|
|
261
|
+
`${term('playlist')} of the week`,
|
|
262
|
+
feat.artist,
|
|
263
|
+
feat.year,
|
|
264
|
+
countLabel(feat.tracks.length, termLower('track'), termLower('tracks')),
|
|
265
|
+
].filter(Boolean).join(' · ');
|
|
266
|
+
const play = () => startPlaylist(fi, firstOpenIndex(feat));
|
|
267
|
+
|
|
268
|
+
const resume = state.playlistIndex < 0 ? state.resume : null;
|
|
147
269
|
const resumeBtn = resume
|
|
148
270
|
? h('button', {
|
|
149
271
|
class: 'btn quiet', type: 'button',
|
|
150
|
-
onclick: () =>
|
|
151
|
-
}, `Continue: ${state.
|
|
272
|
+
onclick: () => startPlaylist(resume.playlistIndex, resume.trackIndex, { startAt: resume.time }),
|
|
273
|
+
}, `Continue: ${state.playlists[resume.playlistIndex].tracks[resume.trackIndex].title}`)
|
|
152
274
|
: null;
|
|
153
275
|
|
|
154
276
|
const nodes = [
|
|
@@ -160,54 +282,101 @@ function renderHome() {
|
|
|
160
282
|
h('h1', { text: feat.title }),
|
|
161
283
|
h('p', { class: 'meta', text: metaLine }),
|
|
162
284
|
h('div', { class: 'actions' },
|
|
163
|
-
h('button', { class: 'btn', type: 'button', onclick: play }, svg(PATH.play, 22),
|
|
164
|
-
resumeBtn
|
|
285
|
+
h('button', { class: 'btn', type: 'button', onclick: play }, svg(PATH.play, 22), `Play ${termLower('playlist')}`),
|
|
286
|
+
resumeBtn,
|
|
287
|
+
markButtons('playlists', feat.id)))),
|
|
165
288
|
];
|
|
166
289
|
|
|
167
290
|
if (state.cards.length) {
|
|
168
291
|
const card = state.cards[dailyCardIndex(now, state.cards.length)];
|
|
169
|
-
const ai = card.
|
|
170
|
-
nodes.push(h('section', { class: 'today', 'aria-label':
|
|
292
|
+
const ai = card.playlist ? state.playlists.findIndex((a) => a.id === card.playlist) : -1;
|
|
293
|
+
nodes.push(h('section', { class: 'today', 'aria-label': 'Today' },
|
|
171
294
|
h('p', { class: 'when', text: 'Today' }),
|
|
172
295
|
h('p', { class: 'hook', text: card.hook }),
|
|
173
296
|
h('p', { class: 'pivot', text: card.pivot }),
|
|
174
|
-
ai >= 0 ? h('button', { class: 'linkbtn', type: 'button', onclick: () =>
|
|
297
|
+
ai >= 0 ? h('button', { class: 'linkbtn', type: 'button', onclick: () => startPlaylist(ai, firstOpenIndex(state.playlists[ai])) }, `Play ${state.playlists[ai].title}`) : null));
|
|
175
298
|
}
|
|
176
299
|
|
|
300
|
+
const shown = state.playlists
|
|
301
|
+
.map((p, i) => ({ p, i }))
|
|
302
|
+
.filter(({ p }) => playlistVisible(playlistMark(p.id), { filter: state.homeFilter, showDisliked: state.showDislikedHome }));
|
|
303
|
+
const empty = state.homeFilter === 'liked'
|
|
304
|
+
? `No liked ${termLower('playlists')}.`
|
|
305
|
+
: `Every ${termLower('playlist')} is hidden. Show disliked to bring one back.`;
|
|
306
|
+
|
|
177
307
|
nodes.push(
|
|
178
|
-
h('h2', { class: 'h2', text: '
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
308
|
+
h('h2', { class: 'h2', text: term('playlists') }),
|
|
309
|
+
filterBar(state.homeFilter, state.showDislikedHome,
|
|
310
|
+
(f) => { state.homeFilter = f; renderHome(); },
|
|
311
|
+
(s) => { state.showDislikedHome = s; renderHome(); }),
|
|
312
|
+
shown.length
|
|
313
|
+
? h('ul', { class: 'grid' }, shown.map(({ p, i }) => {
|
|
314
|
+
const locked = p.access === 'locked';
|
|
315
|
+
const mark = playlistMark(p.id);
|
|
316
|
+
return h('li', {},
|
|
317
|
+
h('div', { class: `album${locked ? ' locked' : ''}${mark === 'disliked' ? ' is-disliked' : ''}` },
|
|
318
|
+
h('button', {
|
|
319
|
+
class: 'album-open', type: 'button',
|
|
320
|
+
'aria-label': `${locked ? 'Locked. ' : 'Play '}${p.title}`,
|
|
321
|
+
'aria-current': i === state.playlistIndex ? 'true' : null,
|
|
322
|
+
onclick: () => startPlaylist(i, firstOpenIndex(p)),
|
|
323
|
+
},
|
|
324
|
+
h('span', { class: 'cover' }, coverEl(p), locked ? h('span', { class: 'badge', text: 'Locked' }) : null),
|
|
325
|
+
h('span', { class: 't', text: p.title }),
|
|
326
|
+
h('span', { class: 'y', text: [p.artist, p.year].filter(Boolean).join(' · ') })),
|
|
327
|
+
markButtons('playlists', p.id)));
|
|
328
|
+
}))
|
|
329
|
+
: h('p', { class: 'empty', text: empty }),
|
|
192
330
|
);
|
|
193
331
|
|
|
194
332
|
home.replaceChildren(...nodes.filter(Boolean));
|
|
195
333
|
}
|
|
196
334
|
|
|
335
|
+
/* ---------------------------------------------------------------- liked, across playlists */
|
|
336
|
+
|
|
337
|
+
function renderLiked() {
|
|
338
|
+
applyTerms();
|
|
339
|
+
const items = likedQueue(state.playlists, (id, src) => trackMark(id, src));
|
|
340
|
+
const rows = items.map((item, n) => {
|
|
341
|
+
const playlist = state.playlists[item.playlistIndex];
|
|
342
|
+
const track = playlist.tracks[item.trackIndex];
|
|
343
|
+
const current = state.playlistIndex === item.playlistIndex && state.trackIndex === item.trackIndex;
|
|
344
|
+
return h('li', { class: 'track-row' },
|
|
345
|
+
h('button', {
|
|
346
|
+
class: 'track', type: 'button',
|
|
347
|
+
'aria-current': current ? 'true' : null,
|
|
348
|
+
onclick: () => startPlaylist(item.playlistIndex, item.trackIndex, { queue: 'liked' }),
|
|
349
|
+
},
|
|
350
|
+
h('span', { class: 'n', text: String(n + 1) }),
|
|
351
|
+
h('span', { class: 'who' },
|
|
352
|
+
h('span', { text: track.title }),
|
|
353
|
+
h('span', { class: 'st', text: playlist.title }))),
|
|
354
|
+
markButtons('tracks', trackMarkKey(playlist.id, track.src)));
|
|
355
|
+
});
|
|
356
|
+
$('#liked').replaceChildren(
|
|
357
|
+
h('button', { class: 'back', type: 'button', onclick: backOne }, svg(PATH.back, 22), 'Home'),
|
|
358
|
+
h('h1', { text: 'Liked' }),
|
|
359
|
+
h('p', { class: 'meta', text: items.length
|
|
360
|
+
? countLabel(items.length, termLower('track'), termLower('tracks'))
|
|
361
|
+
: `No liked ${termLower('tracks')} yet.` }),
|
|
362
|
+
items.length ? h('ol', { class: 'tracklist' }, rows) : null,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
197
366
|
/* ---------------------------------------------------------------- mini player */
|
|
198
367
|
|
|
199
368
|
function renderMini() {
|
|
200
369
|
const mini = $('#mini');
|
|
201
|
-
const
|
|
202
|
-
if (!
|
|
203
|
-
const track =
|
|
370
|
+
const playlist = currentPlaylist();
|
|
371
|
+
if (!playlist || state.view === 'player') { mini.hidden = true; return; }
|
|
372
|
+
const track = playlist.tracks[state.trackIndex];
|
|
204
373
|
mini.hidden = false;
|
|
205
374
|
mini.replaceChildren(
|
|
206
375
|
h('button', { class: 'open', type: 'button', 'aria-label': 'Open the player', onclick: () => setView('player') },
|
|
207
|
-
coverEl(
|
|
376
|
+
coverEl(playlist, 'mini-cover'),
|
|
208
377
|
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' :
|
|
378
|
+
h('span', { class: 'tt', text: state.finished ? `${playlist.title} finished` : track.title }),
|
|
379
|
+
h('span', { class: 'st', text: state.finished ? 'Tap to choose what is next' : playlist.title }))),
|
|
211
380
|
h('button', {
|
|
212
381
|
class: 'ctl', type: 'button', 'aria-label': state.playing ? 'Pause' : 'Play',
|
|
213
382
|
onclick: () => (media.paused ? playAction() : media.pause()),
|
|
@@ -224,37 +393,84 @@ function applyKind(kind) {
|
|
|
224
393
|
$('#stage').classList.toggle('is-video', video);
|
|
225
394
|
}
|
|
226
395
|
|
|
396
|
+
function openTrack(playlist, index) {
|
|
397
|
+
const playlistIndex = state.playlists.indexOf(playlist);
|
|
398
|
+
if (state.queue === 'liked') {
|
|
399
|
+
if (state.finished) startPlaylist(playlistIndex, index, { queue: 'liked' });
|
|
400
|
+
else {
|
|
401
|
+
state.playlistIndex = playlistIndex;
|
|
402
|
+
setTrack(index);
|
|
403
|
+
play();
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
jumpTo(index);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function trackRow(playlist, index, { subtitle } = {}) {
|
|
411
|
+
const track = playlist.tracks[index];
|
|
412
|
+
const mark = trackMark(playlist.id, track.src);
|
|
413
|
+
return h('li', { class: `track-row${mark === 'disliked' ? ' is-disliked' : ''}` },
|
|
414
|
+
h('button', {
|
|
415
|
+
class: 'track', type: 'button',
|
|
416
|
+
'aria-current': index === state.trackIndex && playlist === currentPlaylist() && !state.finished ? 'true' : null,
|
|
417
|
+
onclick: () => openTrack(playlist, index),
|
|
418
|
+
},
|
|
419
|
+
h('span', { class: 'n', text: String(index + 1) }),
|
|
420
|
+
subtitle
|
|
421
|
+
? h('span', { class: 'who' }, h('span', { text: track.title }), h('span', { class: 'st', text: subtitle }))
|
|
422
|
+
: h('span', { text: track.title })),
|
|
423
|
+
markButtons('tracks', trackMarkKey(playlist.id, track.src)));
|
|
424
|
+
}
|
|
425
|
+
|
|
227
426
|
function renderPlayer() {
|
|
228
|
-
const
|
|
229
|
-
if (!
|
|
230
|
-
|
|
427
|
+
const playlist = currentPlaylist();
|
|
428
|
+
if (!playlist) return;
|
|
429
|
+
applyTerms();
|
|
430
|
+
const track = playlist.tracks[state.trackIndex];
|
|
231
431
|
applyKind(track.kind);
|
|
232
432
|
|
|
233
433
|
const cover = $('#pCover');
|
|
234
|
-
const coverSrc =
|
|
434
|
+
const coverSrc = playlist.cover || 'icons/icon-512.png';
|
|
235
435
|
if (cover.getAttribute('src') !== coverSrc) cover.setAttribute('src', coverSrc);
|
|
236
436
|
$('#pTrack').textContent = track.title;
|
|
237
|
-
$('#pAlbum').textContent = [
|
|
238
|
-
|
|
437
|
+
$('#pAlbum').textContent = [playlist.title, playlist.artist].filter(Boolean).join(' · ');
|
|
438
|
+
$('#nowMarks').replaceChildren(markButtons('tracks', trackMarkKey(playlist.id, track.src)));
|
|
439
|
+
document.title = `${track.title} · ${playlist.title} · ${state.app.name}`;
|
|
239
440
|
|
|
240
441
|
$('#deck').hidden = state.finished;
|
|
241
442
|
$('#upnext').hidden = !state.finished;
|
|
242
443
|
if (state.finished) renderUpNext();
|
|
243
444
|
|
|
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 }))));
|
|
445
|
+
if (state.listPlaylistId !== playlist.id) {
|
|
446
|
+
state.listPlaylistId = playlist.id;
|
|
252
447
|
state.lyricsOpen = false;
|
|
253
448
|
}
|
|
254
|
-
$('#
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
449
|
+
$('#albumLinks').replaceChildren(...playlist.links.map((l) => h('li', {},
|
|
450
|
+
h('a', { href: l.url, target: '_blank', rel: 'noopener noreferrer', text: l.label }))));
|
|
451
|
+
|
|
452
|
+
let rows;
|
|
453
|
+
if (state.queue === 'liked') {
|
|
454
|
+
$('#listTools').replaceChildren();
|
|
455
|
+
$('#sideTitle').textContent = 'Liked';
|
|
456
|
+
rows = likedQueue(state.playlists, (id, src) => trackMark(id, src)).map((item) => {
|
|
457
|
+
const owner = state.playlists[item.playlistIndex];
|
|
458
|
+
return trackRow(owner, item.trackIndex, { subtitle: owner.title });
|
|
459
|
+
});
|
|
460
|
+
} else {
|
|
461
|
+
$('#sideTitle').textContent = term('tracks');
|
|
462
|
+
$('#listTools').replaceChildren(filterBar(state.trackFilter, state.showDislikedTracks,
|
|
463
|
+
(f) => { state.trackFilter = f; renderPlayer(); },
|
|
464
|
+
(s) => { state.showDislikedTracks = s; renderPlayer(); }));
|
|
465
|
+
const indexes = trackIndexes(playlist, { showDisliked: state.showDislikedTracks });
|
|
466
|
+
rows = indexes.map((i) => trackRow(playlist, i));
|
|
467
|
+
}
|
|
468
|
+
const emptyList = state.queue === 'liked'
|
|
469
|
+
? `No liked ${termLower('tracks')} yet.`
|
|
470
|
+
: state.trackFilter === 'liked'
|
|
471
|
+
? `No liked ${termLower('tracks')} in this ${termLower('playlist')}.`
|
|
472
|
+
: `Every ${termLower('track')} here is disliked.`;
|
|
473
|
+
$('#tracklist').replaceChildren(...(rows.length ? rows : [h('li', { class: 'empty', text: emptyList })]));
|
|
258
474
|
|
|
259
475
|
const lyrics = track.lyrics;
|
|
260
476
|
$('#lyricsWrap').hidden = !lyrics;
|
|
@@ -268,32 +484,55 @@ function renderPlayer() {
|
|
|
268
484
|
syncPlaying();
|
|
269
485
|
}
|
|
270
486
|
|
|
487
|
+
function suggestionIndexes(current) {
|
|
488
|
+
const out = [];
|
|
489
|
+
const count = state.playlists.length;
|
|
490
|
+
for (let step = 1; step < count && out.length < 3; step++) {
|
|
491
|
+
const i = (current + step) % count;
|
|
492
|
+
if (!playlistVisible(playlistMark(state.playlists[i].id), { filter: 'all', showDisliked: state.showDislikedHome })) continue;
|
|
493
|
+
out.push(i);
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
|
|
271
498
|
function renderUpNext() {
|
|
272
|
-
const
|
|
273
|
-
const picks =
|
|
499
|
+
const playlist = currentPlaylist();
|
|
500
|
+
const picks = suggestionIndexes(state.playlistIndex);
|
|
501
|
+
const liked = state.queue === 'liked';
|
|
274
502
|
const box = $('#upnext');
|
|
275
503
|
box.replaceChildren(
|
|
276
|
-
h('h3', { tabindex: '-1', text: picks.length ? 'Up next' : 'That was the
|
|
277
|
-
h('p', { class: 'done', text: `${
|
|
504
|
+
h('h3', { tabindex: '-1', text: picks.length ? 'Up next' : (liked ? 'That was everything you liked' : `That was the ${termLower('playlist')}`) }),
|
|
505
|
+
h('p', { class: 'done', text: liked ? 'Those were the liked ones.' : `${playlist.title} has finished.` }),
|
|
278
506
|
picks.length ? h('ul', {}, picks.map((i) => {
|
|
279
|
-
const a = state.
|
|
507
|
+
const a = state.playlists[i];
|
|
280
508
|
const locked = a.access === 'locked';
|
|
281
509
|
return h('li', {},
|
|
282
510
|
h('button', {
|
|
283
511
|
class: 'un', type: 'button', disabled: locked,
|
|
284
512
|
'aria-label': `${locked ? 'Locked. ' : 'Play '}${a.title}`,
|
|
285
|
-
onclick: () =>
|
|
513
|
+
onclick: () => startPlaylist(i, firstOpenIndex(a)),
|
|
286
514
|
},
|
|
287
515
|
coverEl(a),
|
|
288
516
|
h('span', {}, h('span', { class: 't', text: a.title }), h('span', { class: 'y', text: [a.artist, a.year].filter(Boolean).join(' · ') })),
|
|
289
517
|
h('span', { class: 'go' }, svg(PATH.play, 22))));
|
|
290
518
|
})) : null,
|
|
291
|
-
h('button', {
|
|
519
|
+
h('button', {
|
|
520
|
+
class: 'btn quiet again', type: 'button',
|
|
521
|
+
onclick: () => {
|
|
522
|
+
if (liked) {
|
|
523
|
+
const items = likedQueue(state.playlists, (id, src) => trackMark(id, src));
|
|
524
|
+
if (!items.length) { toast(nothingLiked()); return; }
|
|
525
|
+
startPlaylist(items[0].playlistIndex, items[0].trackIndex, { queue: 'liked' });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
startPlaylist(state.playlistIndex, firstOpenIndex(playlist));
|
|
529
|
+
},
|
|
530
|
+
}, liked ? `Play liked ${termLower('tracks')} again` : `Play ${playlist.title} again`),
|
|
292
531
|
);
|
|
293
532
|
}
|
|
294
533
|
|
|
295
534
|
function syncEndMode() {
|
|
296
|
-
document.querySelectorAll('.seg button').forEach((b) => {
|
|
535
|
+
document.querySelectorAll('.endmode .seg button').forEach((b) => {
|
|
297
536
|
b.setAttribute('aria-pressed', String(b.dataset.mode === state.endMode));
|
|
298
537
|
});
|
|
299
538
|
}
|
|
@@ -301,7 +540,6 @@ function syncEndMode() {
|
|
|
301
540
|
function syncPlaying() {
|
|
302
541
|
const playing = state.playing;
|
|
303
542
|
document.body.classList.toggle('is-playing', playing);
|
|
304
|
-
// SVG elements have no .hidden property, so toggle the attribute directly.
|
|
305
543
|
$('.i-play').toggleAttribute('hidden', playing);
|
|
306
544
|
$('.i-pause').toggleAttribute('hidden', !playing);
|
|
307
545
|
$('#playBtn').setAttribute('aria-label', playing ? 'Pause' : 'Play');
|
|
@@ -315,23 +553,29 @@ function showError(show) {
|
|
|
315
553
|
|
|
316
554
|
/* ---------------------------------------------------------------- playback */
|
|
317
555
|
|
|
318
|
-
async function
|
|
319
|
-
const
|
|
320
|
-
if (!
|
|
321
|
-
if (
|
|
322
|
-
|
|
556
|
+
async function startPlaylist(playlistIndex, trackIndex = 0, { startAt = 0, queue = null } = {}) {
|
|
557
|
+
const playlist = state.playlists[playlistIndex];
|
|
558
|
+
if (!playlist) return;
|
|
559
|
+
if (playlist.access === 'locked') { toast(`${playlist.title} is locked for now.`); return; }
|
|
560
|
+
if (trackIndex < 0) { toast(queue === 'liked' ? nothingLiked() : nothingHere()); return; }
|
|
561
|
+
state.queue = queue;
|
|
562
|
+
if (queue !== 'liked') {
|
|
563
|
+
state.trackFilter = 'all';
|
|
564
|
+
state.showDislikedTracks = false;
|
|
565
|
+
}
|
|
566
|
+
state.playlistIndex = playlistIndex;
|
|
323
567
|
setTrack(trackIndex, { startAt });
|
|
324
568
|
setView('player');
|
|
325
569
|
await play();
|
|
326
570
|
}
|
|
327
571
|
|
|
328
572
|
function setTrack(index, { startAt = 0 } = {}) {
|
|
329
|
-
const
|
|
573
|
+
const playlist = currentPlaylist();
|
|
330
574
|
state.trackIndex = index;
|
|
331
575
|
state.finished = false;
|
|
332
576
|
state.retried = false;
|
|
333
577
|
showError(false);
|
|
334
|
-
const track =
|
|
578
|
+
const track = playlist.tracks[index];
|
|
335
579
|
applyKind(track.kind);
|
|
336
580
|
media.src = track.src;
|
|
337
581
|
if (startAt > 0) {
|
|
@@ -341,7 +585,7 @@ function setTrack(index, { startAt = 0 } = {}) {
|
|
|
341
585
|
updateMediaSession();
|
|
342
586
|
renderPlayer();
|
|
343
587
|
saveLast();
|
|
344
|
-
warmCache(
|
|
588
|
+
warmCache(playlist, index);
|
|
345
589
|
}
|
|
346
590
|
|
|
347
591
|
async function play() {
|
|
@@ -358,42 +602,72 @@ async function play() {
|
|
|
358
602
|
}
|
|
359
603
|
|
|
360
604
|
function playAction() {
|
|
361
|
-
if (state.finished)
|
|
362
|
-
|
|
605
|
+
if (!state.finished) { play(); return; }
|
|
606
|
+
if (state.queue === 'liked') {
|
|
607
|
+
const items = likedQueue(state.playlists, (id, src) => trackMark(id, src));
|
|
608
|
+
if (!items.length) { toast(nothingLiked()); return; }
|
|
609
|
+
state.playlistIndex = items[0].playlistIndex;
|
|
610
|
+
setTrack(items[0].trackIndex);
|
|
611
|
+
play();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const playlist = currentPlaylist();
|
|
615
|
+
const vis = playlist ? playableIndexes(playlist) : [];
|
|
616
|
+
if (!vis.length) { toast(nothingHere()); return; }
|
|
617
|
+
setTrack(vis[0]);
|
|
618
|
+
play();
|
|
363
619
|
}
|
|
364
620
|
|
|
365
621
|
function jumpTo(index) {
|
|
366
|
-
if (state.finished) {
|
|
622
|
+
if (state.finished) { startPlaylist(state.playlistIndex, index, { queue: state.queue }); return; }
|
|
367
623
|
setTrack(index);
|
|
368
624
|
play();
|
|
369
625
|
}
|
|
370
626
|
|
|
371
|
-
function
|
|
372
|
-
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
627
|
+
function follow(action, { ended = false } = {}) {
|
|
628
|
+
if (action.type === 'empty') { toast(state.queue === 'liked' ? nothingLiked() : nothingHere()); return; }
|
|
629
|
+
if (action.type === 'end') {
|
|
630
|
+
if (!ended) media.pause();
|
|
631
|
+
finishPlaylist();
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (action.type === 'edge') { media.currentTime = 0; return; }
|
|
635
|
+
if (action.item) state.playlistIndex = action.item.playlistIndex;
|
|
636
|
+
setTrack(action.item ? action.item.trackIndex : action.index);
|
|
377
637
|
play();
|
|
378
638
|
}
|
|
379
639
|
|
|
640
|
+
function next() {
|
|
641
|
+
if (state.queue === 'liked') {
|
|
642
|
+
follow(stepQueue(likedQueue(state.playlists, (id, src) => trackMark(id, src)), { playlistIndex: state.playlistIndex, trackIndex: state.trackIndex }, 1, state.endMode));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const playlist = currentPlaylist();
|
|
646
|
+
if (!playlist) return;
|
|
647
|
+
follow(stepVisible(state.trackIndex, playableIndexes(playlist), 1, state.endMode));
|
|
648
|
+
}
|
|
649
|
+
|
|
380
650
|
function previous() {
|
|
381
|
-
if (!
|
|
382
|
-
if (media.currentTime > 3
|
|
383
|
-
|
|
384
|
-
|
|
651
|
+
if (!currentPlaylist()) return;
|
|
652
|
+
if (media.currentTime > 3) { media.currentTime = 0; return; }
|
|
653
|
+
if (state.queue === 'liked') {
|
|
654
|
+
follow(stepQueue(likedQueue(state.playlists, (id, src) => trackMark(id, src)), { playlistIndex: state.playlistIndex, trackIndex: state.trackIndex }, -1, state.endMode));
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
follow(stepVisible(state.trackIndex, playableIndexes(currentPlaylist()), -1, state.endMode));
|
|
385
658
|
}
|
|
386
659
|
|
|
387
660
|
function onEnded() {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
661
|
+
if (state.queue === 'liked') {
|
|
662
|
+
follow(stepQueue(likedQueue(state.playlists, (id, src) => trackMark(id, src)), { playlistIndex: state.playlistIndex, trackIndex: state.trackIndex }, 1, state.endMode), { ended: true });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const playlist = currentPlaylist();
|
|
666
|
+
if (!playlist) return;
|
|
667
|
+
follow(stepVisible(state.trackIndex, playableIndexes(playlist), 1, state.endMode), { ended: true });
|
|
394
668
|
}
|
|
395
669
|
|
|
396
|
-
function
|
|
670
|
+
function finishPlaylist() {
|
|
397
671
|
state.finished = true;
|
|
398
672
|
state.playing = false;
|
|
399
673
|
saveLast();
|
|
@@ -420,7 +694,7 @@ function resetSeek() {
|
|
|
420
694
|
|
|
421
695
|
media.addEventListener('play', () => { state.playing = true; syncPlaying(); });
|
|
422
696
|
media.addEventListener('pause', () => {
|
|
423
|
-
if (media.ended) return;
|
|
697
|
+
if (media.ended) return;
|
|
424
698
|
state.playing = false; syncPlaying(); saveLast();
|
|
425
699
|
});
|
|
426
700
|
media.addEventListener('ended', onEnded);
|
|
@@ -441,7 +715,7 @@ media.addEventListener('timeupdate', () => {
|
|
|
441
715
|
}
|
|
442
716
|
});
|
|
443
717
|
media.addEventListener('error', () => {
|
|
444
|
-
if (!media.error || !
|
|
718
|
+
if (!media.error || !currentPlaylist()) return;
|
|
445
719
|
if (!state.retried) {
|
|
446
720
|
state.retried = true;
|
|
447
721
|
const at = media.currentTime || 0;
|
|
@@ -473,12 +747,13 @@ seekEl.addEventListener('change', () => {
|
|
|
473
747
|
/* ---------------------------------------------------------------- resume */
|
|
474
748
|
|
|
475
749
|
function saveLast() {
|
|
476
|
-
const
|
|
477
|
-
if (!
|
|
750
|
+
const playlist = currentPlaylist();
|
|
751
|
+
if (!playlist) return;
|
|
478
752
|
store.set(LS.last, JSON.stringify({
|
|
479
|
-
id:
|
|
753
|
+
id: playlist.id,
|
|
480
754
|
track: state.finished ? 0 : state.trackIndex,
|
|
481
755
|
time: state.finished ? 0 : Math.floor(media.currentTime || 0),
|
|
756
|
+
queue: state.queue,
|
|
482
757
|
}));
|
|
483
758
|
}
|
|
484
759
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') saveLast(); });
|
|
@@ -487,12 +762,12 @@ window.addEventListener('pagehide', saveLast);
|
|
|
487
762
|
function loadLast() {
|
|
488
763
|
try {
|
|
489
764
|
const last = JSON.parse(store.get(LS.last) || 'null');
|
|
490
|
-
if (!last) return null;
|
|
491
|
-
const
|
|
492
|
-
if (
|
|
765
|
+
if (!last || last.queue === 'liked') return null;
|
|
766
|
+
const playlistIndex = state.playlists.findIndex((a) => a.id === last.id);
|
|
767
|
+
if (playlistIndex < 0) return null;
|
|
493
768
|
const trackIndex = Number.isInteger(last.track) ? last.track : 0;
|
|
494
|
-
if (trackIndex < 0 || trackIndex >= state.
|
|
495
|
-
return {
|
|
769
|
+
if (trackIndex < 0 || trackIndex >= state.playlists[playlistIndex].tracks.length) return null;
|
|
770
|
+
return { playlistIndex, trackIndex, time: Math.max(0, Number(last.time) || 0) };
|
|
496
771
|
} catch { return null; }
|
|
497
772
|
}
|
|
498
773
|
|
|
@@ -500,14 +775,14 @@ function loadLast() {
|
|
|
500
775
|
|
|
501
776
|
function updateMediaSession() {
|
|
502
777
|
if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
|
|
503
|
-
const
|
|
504
|
-
if (!
|
|
505
|
-
const track =
|
|
778
|
+
const playlist = currentPlaylist();
|
|
779
|
+
if (!playlist) return;
|
|
780
|
+
const track = playlist.tracks[state.trackIndex];
|
|
506
781
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
507
782
|
title: track.title,
|
|
508
|
-
artist:
|
|
509
|
-
album:
|
|
510
|
-
artwork:
|
|
783
|
+
artist: playlist.artist || state.app.name,
|
|
784
|
+
album: playlist.title,
|
|
785
|
+
artwork: playlist.cover ? [{ src: abs(playlist.cover), sizes: '640x640', type: mimeFor(playlist.cover) }] : [],
|
|
511
786
|
});
|
|
512
787
|
}
|
|
513
788
|
|
|
@@ -526,19 +801,19 @@ function bindMediaSession() {
|
|
|
526
801
|
}
|
|
527
802
|
|
|
528
803
|
/* ---------------------------------------------------------------- offline media cache */
|
|
529
|
-
// The
|
|
530
|
-
// if the connection drops. Only the current
|
|
804
|
+
// The playlist in play is copied into the Cache API, next track first, so it keeps playing
|
|
805
|
+
// if the connection drops. Only the current playlist is kept; others are removed.
|
|
531
806
|
|
|
532
807
|
const MEDIA_CACHE = 'pv-media';
|
|
533
808
|
let warmToken = 0;
|
|
534
809
|
|
|
535
|
-
async function warmCache(
|
|
810
|
+
async function warmCache(playlist, current) {
|
|
536
811
|
const token = ++warmToken;
|
|
537
812
|
if (!('caches' in window)) return;
|
|
538
813
|
if (navigator.connection && navigator.connection.saveData) return;
|
|
539
814
|
try {
|
|
540
815
|
const cache = await caches.open(MEDIA_CACHE);
|
|
541
|
-
const urls =
|
|
816
|
+
const urls = playlist.tracks.map((t) => abs(t.src));
|
|
542
817
|
const keep = new Set(urls);
|
|
543
818
|
for (const req of await cache.keys()) if (!keep.has(req.url)) await cache.delete(req);
|
|
544
819
|
const order = [...urls.slice(current + 1), ...urls.slice(0, current + 1)];
|
|
@@ -610,8 +885,9 @@ function registerServiceWorker() {
|
|
|
610
885
|
/* ---------------------------------------------------------------- wiring + start */
|
|
611
886
|
|
|
612
887
|
function wire() {
|
|
613
|
-
$('#brand').addEventListener('click', (e) => { e.preventDefault();
|
|
614
|
-
$('#
|
|
888
|
+
$('#brand').addEventListener('click', (e) => { e.preventDefault(); if (state.view !== 'home') backHome(); });
|
|
889
|
+
$('#likedBtn').addEventListener('click', () => setView('liked'));
|
|
890
|
+
$('#backBtn').addEventListener('click', backOne);
|
|
615
891
|
$('#playBtn').addEventListener('click', () => (media.paused ? playAction() : media.pause()));
|
|
616
892
|
$('#prevBtn').addEventListener('click', previous);
|
|
617
893
|
$('#nextBtn').addEventListener('click', next);
|
|
@@ -621,7 +897,7 @@ function wire() {
|
|
|
621
897
|
media.load();
|
|
622
898
|
play();
|
|
623
899
|
});
|
|
624
|
-
document.querySelectorAll('.seg button').forEach((b) => b.addEventListener('click', () => {
|
|
900
|
+
document.querySelectorAll('.endmode .seg button').forEach((b) => b.addEventListener('click', () => {
|
|
625
901
|
state.endMode = b.dataset.mode;
|
|
626
902
|
store.set(LS.endMode, state.endMode);
|
|
627
903
|
syncEndMode();
|
|
@@ -631,7 +907,7 @@ function wire() {
|
|
|
631
907
|
renderPlayer();
|
|
632
908
|
});
|
|
633
909
|
document.addEventListener('keydown', (e) => {
|
|
634
|
-
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || state.
|
|
910
|
+
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || state.playlistIndex < 0) return;
|
|
635
911
|
const t = e.target;
|
|
636
912
|
if (t instanceof Element && t.closest('input, textarea, select, [contenteditable]')) return;
|
|
637
913
|
if (e.code === 'Space' && !(t instanceof Element && t.closest('button, a'))) {
|
|
@@ -647,6 +923,7 @@ async function init() {
|
|
|
647
923
|
setupInstall();
|
|
648
924
|
bindMediaSession();
|
|
649
925
|
wire();
|
|
926
|
+
state.marks = loadMarks();
|
|
650
927
|
|
|
651
928
|
let raw;
|
|
652
929
|
try {
|
|
@@ -655,19 +932,19 @@ async function init() {
|
|
|
655
932
|
raw = await res.json();
|
|
656
933
|
} catch (err) {
|
|
657
934
|
console.error('catalog.json failed to load:', err);
|
|
658
|
-
showStatus("The
|
|
935
|
+
showStatus("The playlist list didn't load. Check your connection and reload. If you edited catalog.json, check it for a missing comma or quote.");
|
|
659
936
|
return;
|
|
660
937
|
}
|
|
661
938
|
|
|
662
939
|
const catalog = normalizeCatalog(raw);
|
|
663
940
|
catalog.warnings.forEach((w) => console.warn('[catalog]', w));
|
|
664
|
-
|
|
665
|
-
|
|
941
|
+
state.app = catalog.app;
|
|
942
|
+
if (!catalog.playlists.length) {
|
|
943
|
+
showStatus(`catalog.json has no playable ${termLower('playlists')}. Each ${termLower('playlist')} needs a title and at least one ${termLower('track')} with a title and a media file.`);
|
|
666
944
|
return;
|
|
667
945
|
}
|
|
668
946
|
|
|
669
|
-
state.
|
|
670
|
-
state.albums = catalog.albums;
|
|
947
|
+
state.playlists = catalog.playlists;
|
|
671
948
|
state.cards = catalog.cards;
|
|
672
949
|
$('#brand').textContent = state.app.name;
|
|
673
950
|
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.0",
|
|
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 = 'v2';
|
|
9
9
|
const SHELL = `pv-shell-${VERSION}`;
|
|
10
10
|
const DATA = 'pv-data';
|
|
11
11
|
const COVERS = 'pv-covers';
|