@x12i/pocket-videos 1.0.0 → 1.1.1

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