@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 CHANGED
@@ -1,29 +1,41 @@
1
- // Pocket Videos. One media element plays the whole album, track after track.
1
+ // Pocket Videos. One media element plays a playlist, track after track.
2
2
  import {
3
- featuredIndex, dailyCardIndex, upNextIndexes, afterTrack, formatTime, normalizeCatalog,
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: {}, albums: [], cards: [],
17
- albumIndex: -1, trackIndex: 0,
18
+ app: {}, playlists: [], cards: [],
19
+ playlistIndex: -1, trackIndex: 0,
18
20
  playing: false,
19
- finished: false, // the album ended in Pause mode, so Up next is showing
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
- listAlbumId: null, // which album the track list was last built for
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 currentAlbum = () => state.albums[state.albumIndex] || null;
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(album, cls = '') {
63
- const fallback = () => h('div', { class: `cover-fallback ${cls}`.trim(), 'aria-hidden': 'true', text: album.title.slice(0, 1) });
64
- if (!album.cover) return fallback();
65
- const img = h('img', { src: album.cover, alt: '', width: 640, height: 640, decoding: 'async', class: cls });
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 pushedPlayer = false;
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' && was !== 'player' && push) {
102
- history.pushState({ v: 'player' }, '');
103
- pushedPlayer = true;
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
- pushedPlayer = false;
111
- if (state.view === 'player') setView('home', { push: false });
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 goHome() {
115
- if (state.view !== 'player') return;
116
- if (pushedPlayer) history.back();
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.albums.length);
141
- const feat = state.albums[fi];
142
- const metaLine = ['Album of the week', feat.artist, feat.year, `${feat.tracks.length} tracks`].filter(Boolean).join(' · ');
143
- const play = () => startAlbum(fi);
144
-
145
- // "Continue" is for a fresh visit; once something is loaded the mini player covers it.
146
- const resume = state.albumIndex < 0 ? state.resume : null;
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: () => startAlbum(resume.albumIndex, resume.trackIndex, { startAt: resume.time }),
151
- }, `Continue: ${state.albums[resume.albumIndex].tracks[resume.trackIndex].title}`)
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), 'Play album'),
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.album ? state.albums.findIndex((a) => a.id === card.album) : -1;
170
- nodes.push(h('section', { class: 'today', 'aria-label': "Today" },
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: () => startAlbum(ai) }, `Play ${state.albums[ai].title}`) : null));
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: 'All albums' }),
179
- h('ul', { class: 'grid' }, state.albums.map((a, i) => {
180
- const locked = a.access === 'locked';
181
- return h('li', {},
182
- h('button', {
183
- class: `album${locked ? ' locked' : ''}`, type: 'button',
184
- 'aria-label': `${locked ? 'Locked. ' : 'Play '}${a.title}`,
185
- 'aria-current': i === state.albumIndex ? 'true' : null,
186
- onclick: () => startAlbum(i),
187
- },
188
- h('span', { class: 'cover' }, coverEl(a), locked ? h('span', { class: 'badge', text: 'Locked' }) : null),
189
- h('span', { class: 't', text: a.title }),
190
- h('span', { class: 'y', text: [a.artist, a.year].filter(Boolean).join(' · ') })));
191
- })),
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 album = currentAlbum();
202
- if (!album || state.view !== 'home') { mini.hidden = true; return; }
203
- const track = album.tracks[state.trackIndex];
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(album, 'mini-cover'),
376
+ coverEl(playlist, 'mini-cover'),
208
377
  h('span', { class: 'txt' },
209
- h('span', { class: 'tt', text: state.finished ? `${album.title} finished` : track.title }),
210
- h('span', { class: 'st', text: state.finished ? 'Tap to choose what is next' : album.title }))),
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 album = currentAlbum();
229
- if (!album) return;
230
- const track = album.tracks[state.trackIndex];
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 = album.cover || 'icons/icon-512.png';
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 = [album.title, album.artist].filter(Boolean).join(' · ');
238
- document.title = `${track.title} · ${album.title} · ${state.app.name}`;
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.listAlbumId !== album.id) {
245
- state.listAlbumId = album.id;
246
- $('#tracklist').replaceChildren(...album.tracks.map((t, i) => h('li', {},
247
- h('button', { class: 'track', type: 'button', dataset: { i }, onclick: () => jumpTo(i) },
248
- h('span', { class: 'n', text: String(i + 1) }),
249
- h('span', { text: t.title })))));
250
- $('#albumLinks').replaceChildren(...album.links.map((l) => h('li', {},
251
- h('a', { href: l.url, target: '_blank', rel: 'noopener noreferrer', text: l.label }))));
445
+ if (state.listPlaylistId !== playlist.id) {
446
+ state.listPlaylistId = playlist.id;
252
447
  state.lyricsOpen = false;
253
448
  }
254
- $('#tracklist').querySelectorAll('.track').forEach((b, i) => {
255
- if (i === state.trackIndex && !state.finished) b.setAttribute('aria-current', 'true');
256
- else b.removeAttribute('aria-current');
257
- });
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 album = currentAlbum();
273
- const picks = upNextIndexes(state.albumIndex, state.albums.length, 3);
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 album' }),
277
- h('p', { class: 'done', text: `${album.title} has finished.` }),
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.albums[i];
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: () => startAlbum(i),
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', { class: 'btn quiet again', type: 'button', onclick: () => startAlbum(state.albumIndex, 0) }, `Play ${album.title} again`),
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 startAlbum(albumIndex, trackIndex = 0, { startAt = 0 } = {}) {
319
- const album = state.albums[albumIndex];
320
- if (!album) return;
321
- if (album.access === 'locked') { toast(`${album.title} is locked for now.`); return; }
322
- state.albumIndex = albumIndex;
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 album = currentAlbum();
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 = album.tracks[index];
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(album, index);
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) startAlbum(state.albumIndex, 0);
362
- else play();
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) { startAlbum(state.albumIndex, index); return; }
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 next() {
372
- const album = currentAlbum();
373
- if (!album) return;
374
- const action = afterTrack(state.trackIndex, album.tracks.length, state.endMode);
375
- if (action.type === 'end') { media.pause(); finishAlbum(); return; }
376
- setTrack(action.index);
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 (!currentAlbum()) return;
382
- if (media.currentTime > 3 || state.trackIndex === 0) { media.currentTime = 0; return; }
383
- setTrack(state.trackIndex - 1);
384
- play();
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
- const album = currentAlbum();
389
- if (!album) return;
390
- const action = afterTrack(state.trackIndex, album.tracks.length, state.endMode);
391
- if (action.type === 'end') { finishAlbum(); return; }
392
- setTrack(action.index); // 'next' or 'repeat'
393
- play();
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 finishAlbum() {
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; // the browser fires pause just before ended; ignore it
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 || !currentAlbum()) return;
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 album = currentAlbum();
477
- if (!album) return;
750
+ const playlist = currentPlaylist();
751
+ if (!playlist) return;
478
752
  store.set(LS.last, JSON.stringify({
479
- id: album.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 albumIndex = state.albums.findIndex((a) => a.id === last.id);
492
- if (albumIndex < 0) return null;
765
+ if (!last || last.queue === 'liked') return null;
766
+ const playlistIndex = state.playlists.findIndex((a) => a.id === last.id);
767
+ if (playlistIndex < 0) return null;
493
768
  const trackIndex = Number.isInteger(last.track) ? last.track : 0;
494
- if (trackIndex < 0 || trackIndex >= state.albums[albumIndex].tracks.length) return null;
495
- return { albumIndex, trackIndex, time: Math.max(0, Number(last.time) || 0) };
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 album = currentAlbum();
504
- if (!album) return;
505
- const track = album.tracks[state.trackIndex];
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: album.artist || state.app.name,
509
- album: album.title,
510
- artwork: album.cover ? [{ src: abs(album.cover), sizes: '640x640', type: mimeFor(album.cover) }] : [],
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 album in play is copied into the Cache API, next track first, so it keeps playing
530
- // if the connection drops. Only the current album is kept; others are removed.
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(album, current) {
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 = album.tracks.map((t) => abs(t.src));
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(); goHome(); });
614
- $('#backBtn').addEventListener('click', goHome);
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.albumIndex < 0) return;
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 album list didn't load. Check your connection and reload. If you edited catalog.json, check it for a missing comma or quote.");
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
- if (!catalog.albums.length) {
665
- showStatus('catalog.json has no playable albums. Each album needs a title and at least one track with a title and a media file.');
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.app = catalog.app;
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 an album and let it play, start to finish.">
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
- <button class="btn small" id="installBtn" type="button" hidden>Install app</button>
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="Albums" hidden></section>
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 albums</span>
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
- <h2 id="pTrack" class="now-title"></h2>
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 album ends</span>
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
- <h3 class="side-title">Tracks</h3>
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 album is "Album of the week". Rotates every 7 days counted from startISO. */
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` albums after `current` in catalog order, wrapping, never including `current`. */
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, '') || 'album';
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 albums are skipped and reported in `warnings`.
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 app = { name: 'Pocket Videos', defaultEndMode: 'repeat', featuredStartDate: DEFAULT_START, ...((raw && raw.app) || {}) };
79
- app.name = text(app.name) || 'Pocket Videos';
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 albums = [];
115
+ const playlists = [];
90
116
  const seen = new Set();
91
- const list = Array.isArray(raw && raw.albums) ? raw.albums : [];
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 = `albums[${i}]`;
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
- albums.push({
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), album: text(c.album) }));
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
- return { app, albums, cards, warnings };
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
  }
@@ -2,7 +2,7 @@
2
2
  "id": "./",
3
3
  "name": "Pocket Videos",
4
4
  "short_name": "Pocket Videos",
5
- "description": "Pick an album and let it play, start to finish.",
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.0.0",
3
+ "version": "1.1.0",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
- "description": "Pocket Videos: an installable web app that plays an album of audio and video, start to finish.",
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: baseline;
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 album is saved there (with Range support,
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 = 'v1';
8
+ const VERSION = 'v2';
9
9
  const SHELL = `pv-shell-${VERSION}`;
10
10
  const DATA = 'pv-data';
11
11
  const COVERS = 'pv-covers';