source: Klonkt/src/assets/js/audio-player.js@ 421046c

main
Last change on this file since 421046c was 421046c, checked in by roboburr <roboburr@…>, 2 months ago

fix(audio): gapless MSE playback - background auto-advance survives on Android

Root cause of "next track plays 1 second, then pauses and the media
notification closes" on backgrounded mobile Chrome/PWA: every track
change did pause() + audio.src=<new blob> + load() + play(), which the
browser treats as a NEW playback session - and Chrome's background media
policy pauses new sessions started in the background. Only a CONTINUING
session may keep playing.

The player now has two engines:

  • MSE chain (Chrome/Firefox/Android): one MediaSource + one 'audio/mpeg' SourceBuffer (every track is uniform transcoder mp3). The next track's bytes are appended into the same buffer, so the whole queue is one continuous playback session; auto-advance is just the timeline flowing past a segment boundary - no pause/src/load/play at all. Track chrome, per-track time display, seek, lock-screen position state and session save/restore all work in track coordinates via a segment table. Played data is pruned (~2 tracks buffered), ID3 tags are stripped between appends, QuotaExceeded evicts and retries.
  • Blob fallback (iOS Safari - no MSE; or runtime MSE failure): the previous per-track objectURL behaviour, now fed from shared byte fetches. An audio error while the MSE chain is active permanently falls back to blobs for the session and retries the same track.

Manual actions (play/next/prev/queue click/restore) start a fresh chain -
those happen in the foreground where a new session is allowed.

Verified in-browser (desktop Chrome, two 8s test tones): auto-advance
crosses the boundary with ZERO pause/play events (old engine: one pair
per track), per-track time display correct, manual next resets the
chain, in-track seek works, queue wrap-around is gapless, no console
errors.

  • src/assets/js/audio-player.js - the two-engine pipeline (fetchTrackBytes, MSE chain: chainStart/appendSegment/ensureNextAppended/pruneBuffer/ maybeCrossBoundary/displayTimes; applyBlobBytes fallback; updateTrackChrome extraction; MediaSession positionState)
  • src/views/shell.ejs - cache-buster v31 -> v32
  • CHANGELOG(.nl/.de).md - user-facing entry under Unreleased (3 languages)

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 53.3 KB
Line 
1/**
2 * Klonkt Audio Player — v9 mini-player + Spotify-style sheet.
3 *
4 * Two surfaces:
5 * 1. .audio-player — bottom strip: cover + meta + controls + progress + volume
6 * 2. .audio-sheet — full-height now-playing panel (slides up from bottom)
7 *
8 * Features:
9 * - Click cover/meta on mini-player → open sheet
10 * - Sheet handle (pill) or backdrop click → close
11 * - Touch swipe-down on the drag-zone → close (mobile only; desktop has the X)
12 * - Reads data-pcms-track-url + data-pcms-track + data-pcms-album from posts
13 * - body.has-audio-player adds bottom padding when player visible
14 * - body.audio-sheet-locked prevents body scroll when sheet open
15 * - Survives HTMX swaps + history-restores via event delegation on document.body
16 *
17 * Singleton — guards against double-init.
18 */
19(function() {
20 if (window.pcmsAudioPlayer) return;
21
22 const SVG = {
23 play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>',
24 pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4h4v16H7zM13 4h4v16h-4z" fill="currentColor"/></svg>',
25 prev: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5v14M20 5l-11 7 11 7V5z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
26 next: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 5v14M4 5l11 7-11 7V5z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
27 vol: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zM16 8a5 5 0 010 8M19 5a9 9 0 010 14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
28 mute: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zM17 9l5 5M22 9l-5 5" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
29 musicNote: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 17V5l12-2v12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="17" r="3" fill="currentColor"/><circle cx="18" cy="15" r="3" fill="currentColor"/></svg>',
30 };
31
32 // ============================================================
33 // 1. Build DOM
34 // ============================================================
35 const root = document.createElement('aside');
36 root.id = 'pcms-audio-player';
37 root.className = 'audio-player';
38 root.setAttribute('aria-label', 'Audio speler');
39 root.innerHTML = `
40 <div class="audio-player-inner">
41 <button type="button" class="audio-player-expand-trigger" id="audio-expand-trigger" aria-label="Vergroot speler">
42 <div class="audio-player-cover" id="audio-cover" aria-hidden="true">${SVG.musicNote}</div>
43 <div class="audio-player-meta">
44 <div class="audio-player-title-wrap"><span class="audio-player-title" id="audio-title">No track</span></div>
45 <div class="audio-player-artist" id="audio-artist"></div>
46 </div>
47 </button>
48 <div class="audio-player-controls">
49 <button type="button" class="audio-btn" id="audio-prev" aria-label="Vorige" title="Vorige">${SVG.prev}</button>
50 <button type="button" class="audio-btn audio-btn-play" id="audio-play" aria-label="Afspelen">
51 <span class="icon-play">${SVG.play}</span><span class="icon-pause">${SVG.pause}</span>
52 </button>
53 <button type="button" class="audio-btn" id="audio-next" aria-label="Volgende" title="Volgende">${SVG.next}</button>
54 </div>
55 <div class="audio-player-progress">
56 <span class="audio-time mono" id="audio-current">0:00</span>
57 <div class="audio-seek" id="audio-seek" role="slider" aria-label="Voortgang" tabindex="0">
58 <div class="audio-seek-bar"><div class="audio-seek-fill" id="audio-seek-fill"></div></div>
59 </div>
60 <span class="audio-time mono" id="audio-total">0:00</span>
61 </div>
62 <div class="audio-player-volume">
63 <button type="button" class="audio-btn" id="audio-mute" aria-label="Mute">
64 <span class="icon-vol">${SVG.vol}</span><span class="icon-mute">${SVG.mute}</span>
65 </button>
66 <div class="audio-volume-popup">
67 <input type="range" id="audio-volume" min="0" max="100" value="80" aria-label="Volume">
68 </div>
69 </div>
70 </div>
71 <audio id="audio-element" preload="none" playsinline webkit-playsinline controlsList="nodownload"></audio>
72
73 <div class="audio-sheet" id="audio-sheet" aria-hidden="true">
74 <div class="audio-sheet-backdrop" id="audio-sheet-backdrop"></div>
75 <div class="audio-sheet-panel" role="dialog" aria-label="Now playing">
76 <div class="audio-sheet-drag-zone" id="audio-sheet-drag-zone">
77 <button type="button" class="audio-sheet-handle" id="audio-sheet-close" aria-label="Speler verkleinen"></button>
78 <div class="audio-sheet-cover" id="audio-sheet-cover" aria-hidden="true">${SVG.musicNote}</div>
79 </div>
80 <div class="audio-sheet-info">
81 <div class="audio-sheet-title" id="audio-sheet-title">—</div>
82 <div class="audio-sheet-artist" id="audio-sheet-artist"></div>
83 <div class="audio-sheet-album" id="audio-sheet-album"></div>
84 </div>
85 <div class="audio-sheet-progress">
86 <span class="audio-time mono" id="audio-sheet-current">0:00</span>
87 <div class="audio-seek" id="audio-sheet-seek" role="slider" aria-label="Voortgang" tabindex="0">
88 <div class="audio-seek-bar"><div class="audio-seek-fill" id="audio-sheet-seek-fill"></div></div>
89 </div>
90 <span class="audio-time mono" id="audio-sheet-total">0:00</span>
91 </div>
92 <div class="audio-sheet-controls">
93 <button type="button" class="audio-btn audio-sheet-btn" id="audio-sheet-prev" aria-label="Vorige">${SVG.prev}</button>
94 <button type="button" class="audio-btn audio-sheet-play" id="audio-sheet-play" aria-label="Afspelen">
95 <span class="icon-play">${SVG.play}</span><span class="icon-pause">${SVG.pause}</span>
96 </button>
97 <button type="button" class="audio-btn audio-sheet-btn" id="audio-sheet-next" aria-label="Volgende">${SVG.next}</button>
98 </div>
99 <div class="audio-sheet-queue" id="audio-sheet-queue" hidden>
100 <div class="audio-sheet-queue-label">Queue</div>
101 <ol class="audio-sheet-queue-list" id="audio-sheet-queue-list"></ol>
102 </div>
103 </div>
104 </div>
105 `;
106 document.body.appendChild(root);
107
108 // FIX: Move .audio-sheet out of the .audio-player root so its
109 // position:fixed is anchored to the viewport, not to the mini-bar.
110 // The mini-bar has backdrop-filter, which makes it a containing
111 // block for fixed descendants — that broke the sheet's left/right/top/bottom.
112 const _detachedSheet = root.querySelector('.audio-sheet');
113 if (_detachedSheet) document.body.appendChild(_detachedSheet);
114
115 // ============================================================
116 // 2. Element references
117 // ============================================================
118 const $ = (id) => document.getElementById(id);
119 const audio = $('audio-element');
120 const cover = $('audio-cover');
121 const titleEl = $('audio-title');
122 const artistEl = $('audio-artist');
123 const seek = $('audio-seek');
124 const seekFill = $('audio-seek-fill');
125 const currentEl = $('audio-current');
126 const totalEl = $('audio-total');
127 const playBtn = $('audio-play');
128 const prevBtn = $('audio-prev');
129 const nextBtn = $('audio-next');
130 const muteBtn = $('audio-mute');
131 const volumeSlider = $('audio-volume');
132 const expandTrigger = $('audio-expand-trigger');
133
134 const sheet = $('audio-sheet');
135 const sheetBackdrop = $('audio-sheet-backdrop');
136 const sheetPanel = sheet.querySelector('.audio-sheet-panel');
137 const sheetClose = $('audio-sheet-close');
138 const sheetCover = $('audio-sheet-cover');
139 const sheetTitle = $('audio-sheet-title');
140 const sheetArtist = $('audio-sheet-artist');
141 const sheetAlbum = $('audio-sheet-album');
142 const sheetSeek = $('audio-sheet-seek');
143 const sheetSeekFill = $('audio-sheet-seek-fill');
144 const sheetCurrent = $('audio-sheet-current');
145 const sheetTotal = $('audio-sheet-total');
146 const sheetPlay = $('audio-sheet-play');
147 const sheetPrev = $('audio-sheet-prev');
148 const sheetNext = $('audio-sheet-next');
149 const sheetQueue = $('audio-sheet-queue');
150 const sheetQueueList = $('audio-sheet-queue-list');
151 const dragZone = $('audio-sheet-drag-zone');
152
153 // ============================================================
154 // 3. State
155 // ============================================================
156 let queue = [];
157 let currentIndex = 0;
158 let isPlaying = false;
159 let albumName = '';
160 // Playback pipeline. We fetch each track's bytes ourselves (X-Audio-Player
161 // gate; no plain media URL is ever exposed to the page) and feed them to the
162 // <audio> element through one of two engines:
163 //
164 // 1. MSE chain (Chrome/Firefox/Android): ONE MediaSource + SourceBuffer
165 // ('audio/mpeg', sequence mode — every track is uniform transcoder mp3).
166 // The next track's bytes are APPENDED into the same buffer, so the whole
167 // queue is one continuous playback session. That is what keeps a
168 // backgrounded tab/PWA playing across track changes: Chrome's background
169 // media policy pauses NEW playback sessions started in the background
170 // (the old per-track src-swap + load() + play()), but never interrupts a
171 // continuing one. A track change becomes a timeline position, not a swap.
172 // 2. Blob fallback (iOS Safari — no MSE; or MSE failed at runtime): one
173 // objectURL per track, the previous behaviour.
174 let currentObjectUrl = null;
175 // Monotonic load token: a fast prev/next can fire several loads before an
176 // earlier fetch resolves. Only the latest load may touch the audio pipeline.
177 let loadSeq = 0;
178 // Next-track prefetch (blob engine; the MSE engine appends ahead instead).
179 // Shape: { url, bytes } — bytes is null while the fetch is still in flight.
180 let preload = null;
181 // ── MSE chain state ──
182 const MSE_SUPPORTED = !!(window.MediaSource && MediaSource.isTypeSupported && MediaSource.isTypeSupported('audio/mpeg'));
183 let mseFailed = false; // runtime bail → blob engine for the rest of this session
184 const useMse = () => MSE_SUPPORTED && !mseFailed;
185 let ms = null; // MediaSource
186 let sb = null; // SourceBuffer
187 let chain = []; // appended segments: { qIndex, start, end } (timeline seconds)
188 let chainFetching = false; // a fetch+append for the NEXT track is in flight
189 let sbOps = Promise.resolve(); // serializes SourceBuffer operations
190
191 // Hide initially
192 root.classList.add('audio-player-hidden');
193
194 // ============================================================
195 // 4. Track / queue loading
196 // ============================================================
197 function setCoverImage(el, url) {
198 if (url) {
199 el.style.backgroundImage = `url("${url}")`;
200 el.classList.add('has-image');
201 el.innerHTML = '';
202 } else {
203 el.style.backgroundImage = '';
204 el.classList.remove('has-image');
205 el.innerHTML = SVG.musicNote;
206 }
207 }
208
209 // Fetch the track bytes (ArrayBuffer). The X-Audio-Player header +
210 // same-origin credentials get us past the stream route's access gate.
211 // Retries a few times with backoff: a single transient network blip used to
212 // bump the error counter and SKIP the song (auto-advance past it). Now one
213 // hiccup just costs a retry, and we only give up after genuinely failing.
214 async function fetchTrackBytes(url, attempts) {
215 attempts = attempts || 1;
216 let lastErr;
217 for (let i = 0; i < attempts; i++) {
218 try {
219 const r = await fetch(url, {
220 credentials: 'same-origin',
221 headers: { 'X-Audio-Player': '1' },
222 });
223 if (!r.ok) throw new Error('HTTP ' + r.status);
224 return await r.arrayBuffer();
225 } catch (e) {
226 lastErr = e;
227 if (i < attempts - 1) {
228 await new Promise((res) => setTimeout(res, 350 * (i + 1)));
229 }
230 }
231 }
232 throw lastErr;
233 }
234
235 // Blob fallback engine: wrap the bytes in an objectURL and swap audio.src.
236 function applyBlobBytes(bytes, autoplay, mySeq) {
237 if (mySeq !== loadSeq) return; // superseded
238 const objUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' }));
239 root.classList.remove('audio-loading');
240 // Free the previously-playing track's blob — otherwise each track leaks a
241 // copy. Never the same handle as objUrl (createObjectURL is unique), so this
242 // can't revoke the source we're about to play.
243 if (currentObjectUrl && currentObjectUrl !== objUrl) {
244 try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
245 }
246 currentObjectUrl = objUrl;
247 // Clean transition: pause + load forces a reset of internal state after
248 // multiple src changes (prevents state corruption of the audio element).
249 try { audio.pause(); } catch (e) {}
250 audio.src = objUrl;
251 try { audio.load(); } catch (e) {}
252 if (autoplay) play();
253 }
254
255 function onLoadError(err, mySeq) {
256 if (mySeq !== loadSeq) return; // superseded — ignore stale failure
257 root.classList.remove('audio-loading');
258 console.error('[pcms-audio] track load failed after retries', err);
259 // Genuine failure (after retries): bump the counter and auto-skip, but stop
260 // after 3 in a row so a fully-broken queue can't loop "next" forever.
261 consecutiveErrors++;
262 if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
263 }
264
265 // Discard any held/in-flight preload (plain bytes now — GC handles them).
266 function dropPreload() { preload = null; }
267
268 // Prefetch the *next* track's bytes in the background. Idempotent: re-calling
269 // while the same track is already cached / in flight is a no-op. Called from
270 // the `playing` event so the network is otherwise idle. Blob engine only —
271 // the MSE engine "preloads" by appending ahead (ensureNextAppended).
272 function preloadNext() {
273 if (queue.length < 2) return;
274 const ni = (currentIndex + 1) % queue.length;
275 const t = queue[ni];
276 if (!t || !t.url) return;
277 if (preload && preload.url === t.url) return; // already held or in flight
278 const marker = { url: t.url, bytes: null };
279 preload = marker;
280 fetchTrackBytes(t.url, 2).then((bytes) => {
281 // Only keep it if this is still the track we want next.
282 if (preload === marker) marker.bytes = bytes;
283 }).catch(() => { if (preload === marker) preload = null; });
284 }
285
286 // ============================================================
287 // 4a. MSE chain engine — one continuous playback session
288 // ============================================================
289 // All tracks are uniform transcoder mp3 (192kbps), so raw frames can be
290 // appended back-to-back into a single 'audio/mpeg' SourceBuffer (its
291 // byte-stream format generates continuous timestamps — sequence mode).
292 // Auto-advance = playback simply flowing into the next track's region.
293
294 // Strip ID3v2 (leading) / ID3v1 (trailing) tags: tag bytes between two
295 // appended tracks would glitch the MPEG frame parser.
296 function stripId3(buf) {
297 const u8 = new Uint8Array(buf);
298 let start = 0, end = u8.length;
299 if (end > 10 && u8[0] === 0x49 && u8[1] === 0x44 && u8[2] === 0x33) { // "ID3"
300 const size = ((u8[6] & 0x7f) << 21) | ((u8[7] & 0x7f) << 14) | ((u8[8] & 0x7f) << 7) | (u8[9] & 0x7f);
301 const skip = 10 + size + ((u8[5] & 0x10) ? 10 : 0); // +10 when a footer is flagged
302 if (skip < end) start = skip;
303 }
304 if (end - start > 128 && u8[end - 128] === 0x54 && u8[end - 127] === 0x41 && u8[end - 126] === 0x47) end -= 128; // "TAG"
305 return (start === 0 && end === u8.length) ? buf : buf.slice(start, end);
306 }
307
308 function teardownChain() {
309 chain = [];
310 chainFetching = false;
311 sbOps = Promise.resolve();
312 sb = null;
313 ms = null;
314 }
315
316 // Serialize a SourceBuffer operation (append/remove): they throw if issued
317 // while the buffer is still updating, so everything funnels through a queue.
318 function sbRun(fn) {
319 const run = () => new Promise((resolve, reject) => {
320 if (!sb || !ms || ms.readyState !== 'open') return resolve();
321 const ok = () => { cleanup(); resolve(); };
322 const err = (e) => { cleanup(); reject(e); };
323 function cleanup() { sb.removeEventListener('updateend', ok); sb.removeEventListener('error', err); }
324 sb.addEventListener('updateend', ok);
325 sb.addEventListener('error', err);
326 try { fn(); } catch (e) { cleanup(); reject(e); }
327 });
328 const p = sbOps.then(run, run);
329 sbOps = p.catch(() => {});
330 return p;
331 }
332
333 // Append one track's bytes as the next segment of the chain.
334 async function appendSegment(qIndex, bytes, mySeq) {
335 const clean = stripId3(bytes);
336 try {
337 await sbRun(() => sb.appendBuffer(clean));
338 } catch (e) {
339 if (e && e.name === 'QuotaExceededError' && chain.length > 1) {
340 // Evict already-played data and retry once.
341 const seg = currentSegment();
342 if (seg && seg.start > 1) {
343 await sbRun(() => sb.remove(0, seg.start - 0.5));
344 chain = chain.filter((s) => s.end > seg.start - 0.5);
345 }
346 await sbRun(() => sb.appendBuffer(clean));
347 } else {
348 throw e;
349 }
350 }
351 if (mySeq !== loadSeq || !sb) return;
352 const buffered = sb.buffered;
353 const chainEnd = buffered.length ? buffered.end(buffered.length - 1) : 0;
354 const start = chain.length ? chain[chain.length - 1].end : (buffered.length ? buffered.start(0) : 0);
355 chain.push({ qIndex, start, end: chainEnd });
356 if (queue.length === 1 && ms && ms.readyState === 'open') {
357 // Single-track queue: close the stream so `ended` fires (which replays
358 // it, matching the old engine's behaviour).
359 try { ms.endOfStream(); } catch (e) {}
360 }
361 }
362
363 // Keep exactly one full track appended ahead of the one playing.
364 function ensureNextAppended() {
365 if (!useMse() || !sb || !ms || ms.readyState !== 'open' || chainFetching) return;
366 if (queue.length < 2 || !chain.length) return;
367 const seg = currentSegment();
368 if (!seg || chain.length - 1 - chain.indexOf(seg) >= 1) return; // already one ahead
369 const nextIdx = (chain[chain.length - 1].qIndex + 1) % queue.length;
370 const t = queue[nextIdx];
371 if (!t || !t.url) return;
372 chainFetching = true;
373 const mySeq = loadSeq;
374 const bytesP = (preload && preload.url === t.url && preload.bytes)
375 ? Promise.resolve(preload.bytes)
376 : fetchTrackBytes(t.url, 2);
377 bytesP.then((bytes) => {
378 if (mySeq !== loadSeq) return;
379 if (preload && preload.url === t.url) preload = null;
380 return appendSegment(nextIdx, bytes, mySeq);
381 }).catch((e) => {
382 console.warn('[pcms-audio] next-track append failed', e);
383 }).finally(() => { chainFetching = false; });
384 }
385
386 // Drop played-out data so the buffer holds ~2 tracks at most.
387 function pruneBuffer(curSeg) {
388 if (!useMse() || !sb || !ms || ms.readyState !== 'open') return;
389 const cut = curSeg.start - 0.5;
390 if (cut <= 1) return;
391 sbRun(() => sb.remove(0, cut)).catch(() => {});
392 chain = chain.filter((s) => s.end > cut);
393 }
394
395 function currentSegment() {
396 const t = audio.currentTime || 0;
397 for (let i = 0; i < chain.length; i++) if (t < chain[i].end - 0.05) return chain[i];
398 return chain[chain.length - 1] || null;
399 }
400
401 // Playback flowed across a track boundary (the gapless auto-advance):
402 // update chrome/metadata, top the buffer up, evict what's been played.
403 function maybeCrossBoundary() {
404 const seg = currentSegment();
405 if (!seg || seg.qIndex === currentIndex) return;
406 currentIndex = seg.qIndex;
407 const t = queue[currentIndex];
408 if (t) {
409 console.log('[pcms-audio] gapless auto-advance →', t.title);
410 updateTrackChrome(t);
411 }
412 ensureNextAppended();
413 pruneBuffer(seg);
414 updatePositionState();
415 savePlayerState();
416 }
417
418 // Start a fresh chain at queue[index]. Manual actions only (start/jump/
419 // prev/next/restore) — those happen in the foreground, where starting a
420 // new playback session is allowed.
421 function chainStart(index, autoplay, mySeq, bytes) {
422 teardownChain();
423 ms = new MediaSource();
424 const msUrl = URL.createObjectURL(ms);
425 if (currentObjectUrl && currentObjectUrl !== msUrl) {
426 try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
427 }
428 currentObjectUrl = msUrl;
429 try { audio.pause(); } catch (e) {}
430 audio.src = msUrl;
431 try { audio.load(); } catch (e) {}
432 const bailToBlob = (e) => {
433 console.warn('[pcms-audio] MSE unavailable, using blob playback', e);
434 mseFailed = true;
435 teardownChain();
436 if (mySeq === loadSeq) applyBlobBytes(bytes, autoplay, mySeq);
437 };
438 ms.addEventListener('sourceopen', () => {
439 if (mySeq !== loadSeq || !ms) return;
440 try {
441 sb = ms.addSourceBuffer('audio/mpeg');
442 } catch (e) { return bailToBlob(e); }
443 appendSegment(index, bytes, mySeq).then(() => {
444 if (mySeq !== loadSeq) return;
445 // Session-restore: land at the saved in-track position.
446 if (pendingSeek > 0 && chain.length) {
447 const seg = chain[0];
448 try { audio.currentTime = Math.min(pendingSeek, (seg.end - seg.start) - 0.25); } catch (e) {}
449 pendingSeek = 0;
450 }
451 ensureNextAppended();
452 }).catch(bailToBlob);
453 }, { once: true });
454 if (autoplay) play();
455 }
456
457 // Current position/duration in TRACK coordinates (the MSE timeline is the
458 // whole chain; the UI always shows the single playing track).
459 function displayTimes() {
460 if (useMse() && chain.length) {
461 const seg = currentSegment();
462 if (seg) return { cur: Math.max(0, (audio.currentTime || 0) - seg.start), dur: seg.end - seg.start };
463 }
464 return { cur: audio.currentTime || 0, dur: audio.duration };
465 }
466
467 // metaOnly: show the track in the UI but DON'T download its bytes yet.
468 // Used by the site pre-seed so opening a page doesn't auto-download audio;
469 // the blob is fetched lazily on the first play().
470 // Persistently mark the current track (stays highlighted as long as it's active).
471 function markPlaying(trackId) {
472 document.querySelectorAll('.pat-playing').forEach((e) => e.classList.remove('pat-playing'));
473 if (!trackId) return;
474 const el = document.getElementById('track-' + trackId);
475 if (el) el.classList.add('pat-playing');
476 }
477 // After an htmx navigation the post DOM is replaced → reapply the highlight.
478 document.body.addEventListener('htmx:afterSettle', () => {
479 const t = queue[currentIndex];
480 if (t) markPlaying(t.id);
481 });
482
483 // Media Session metadata (lock-screen / notification info + artwork). Set per
484 // track; the action handlers are wired once below. Keeping a live media session
485 // is what lets iOS continue a programmatic auto-advance play() instead of
486 // pausing it immediately.
487 function updateMediaMetadata(t) {
488 if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
489 try {
490 const art = [];
491 if (t && t.cover) {
492 let u = t.cover; try { u = new URL(t.cover, location.href).href; } catch (e) {}
493 art.push({ src: u, sizes: '512x512', type: '' });
494 }
495 navigator.mediaSession.metadata = new MediaMetadata({
496 title: (t && t.title) || 'Untitled',
497 artist: (t && t.artist) || '',
498 album: albumName || '',
499 artwork: art,
500 });
501 } catch (e) { /* non-fatal */ }
502 }
503
504 // All the visible per-track chrome: titles, covers, queue highlight, media
505 // session metadata. Called from loadTrack AND from the gapless boundary-cross.
506 function updateTrackChrome(t) {
507 titleEl.textContent = t.title || 'Untitled';
508 artistEl.textContent = t.artist || '';
509 sheetTitle.textContent = t.title || 'Untitled';
510 sheetArtist.textContent = t.artist || '';
511 sheetAlbum.textContent = albumName || '';
512 setCoverImage(cover, t.cover);
513 setCoverImage(sheetCover, t.cover);
514 root.classList.remove('audio-player-hidden');
515 document.body.classList.add('has-audio-player');
516 renderQueue();
517 markPlaying(t.id);
518 updateMediaMetadata(t);
519 }
520
521 function loadTrack(index, autoplay, metaOnly) {
522 if (!queue[index]) {
523 console.warn('[pcms-audio] loadTrack: no track at index', index);
524 return;
525 }
526 currentIndex = index;
527 const t = queue[index];
528 if (!t.url) {
529 console.error('[pcms-audio] track has no url', t);
530 return;
531 }
532 console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : '');
533 // Metadata + chrome update synchronously so the UI reacts instantly while
534 // the bytes download.
535 updateTrackChrome(t);
536
537 if (metaOnly) return;
538
539 const mySeq = ++loadSeq;
540
541 // Fast path: the bytes for this exact track were already prefetched while
542 // the previous track played → no gap, no fetch window.
543 let bytesP;
544 if (preload && preload.url === t.url && preload.bytes) {
545 bytesP = Promise.resolve(preload.bytes);
546 preload = null;
547 } else {
548 // Not preloaded (or still in flight) → drop any stale preload and fetch
549 // fresh, retrying transient failures before giving up.
550 dropPreload();
551 root.classList.add('audio-loading');
552 bytesP = fetchTrackBytes(t.url, 3);
553 }
554 bytesP.then((bytes) => {
555 if (mySeq !== loadSeq) return;
556 root.classList.remove('audio-loading');
557 if (useMse()) chainStart(index, autoplay, mySeq, bytes);
558 else applyBlobBytes(bytes, autoplay, mySeq);
559 }).catch((err) => onLoadError(err, mySeq));
560 }
561
562 // True when the viewport is in the mobile sheet-layout — matches the CSS
563 // breakpoint where .audio-sheet slides up full-width from the bottom
564 // (@media max-width:719.98px). On wider/desktop widths the sheet is a centered
565 // panel, so we do NOT auto-open it there.
566 // Three layouts (Robin 2026-06-15): phone (<768) = fullscreen sheet;
567 // tablet/car (768–1199) = large landscape full-player (tablet + car-mode);
568 // desktop (≥1200) = mini-player only, NO full player. matchMedia so this
569 // exactly follows the CSS breakpoints.
570 function playerTier() {
571 if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop';
572 if (window.matchMedia('(min-width: 768px)').matches) return 'tablet';
573 return 'phone';
574 }
575 function hasFullPlayer() { return playerTier() !== 'desktop'; }
576 function isMobileView() { return playerTier() === 'phone'; }
577
578 function setQueue(tracks, startIdx, opts) {
579 queue = Array.isArray(tracks) ? tracks.slice() : [];
580 albumName = (opts && opts.albumName) || '';
581 if (!queue.length) return;
582 loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
583 // Mobile: a track press from an album/playlist auto-opens the full
584 // now-playing sheet (Spotify-style) instead of just the thin mini-strip.
585 // Only here (setQueue = a fresh, user-initiated queue) —
586 // not on next/prev or the site pre-seed — so a sheet the user
587 // deliberately closed doesn't reappear by itself.
588 if (hasFullPlayer()) openSheet();
589 }
590
591 function play() {
592 if (!audio.src) {
593 // Nothing fetched yet (pre-seed showed metadata only, or a load is still
594 // in flight). Kick off the blob load for the current track and autoplay.
595 if (queue[currentIndex]) loadTrack(currentIndex, true);
596 return;
597 }
598 const p = audio.play();
599 if (p && typeof p.catch === 'function') {
600 p.catch((err) => {
601 console.warn('[pcms-audio] play() rejected:', err.name, err.message);
602 // Browser autoplay policy blocked it (typically after 3-4
603 // auto-plays on iOS Safari, or when the tab was temporarily inactive).
604 // Show a visual hint for the user to tap play.
605 if (err && err.name === 'NotAllowedError') {
606 root.classList.add('audio-needs-tap');
607 isPlaying = false;
608 root.classList.remove('is-playing');
609 }
610 });
611 }
612 }
613 function pause() { audio.pause(); }
614 function togglePlay() { audio.paused ? play() : pause(); }
615 function next() {
616 if (!queue.length) return;
617 loadTrack((currentIndex + 1) % queue.length, true);
618 }
619 function prev() {
620 if (!queue.length) return;
621 loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
622 }
623 function close() {
624 pause();
625 mediaRegistry().release(registrySelf);
626 root.classList.add('audio-player-hidden');
627 document.body.classList.remove('has-audio-player');
628 closeSheet();
629 queue = [];
630 albumName = '';
631 loadSeq++; // cancel any in-flight load
632 dropPreload();
633 teardownChain();
634 if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
635 currentObjectUrl = null;
636 try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
637 }
638
639 function renderQueue() {
640 if (!queue.length) { sheetQueue.hidden = true; return; }
641 sheetQueueList.innerHTML = '';
642 queue.forEach((t, i) => {
643 const li = document.createElement('li');
644 li.className = 'audio-sheet-queue-item' + (i === currentIndex ? ' is-current' : '');
645 li.dataset.idx = String(i);
646 li.innerHTML = `<span class="aqi-num">${i + 1}.</span> <span class="aqi-title">${escapeHtml(t.title || 'Untitled')}</span>`
647 + (t.artist ? `<span class="aqi-artist">${escapeHtml(t.artist)}</span>` : '');
648 sheetQueueList.appendChild(li);
649 });
650 sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
651 li.addEventListener('click', () => {
652 const idx = parseInt(li.dataset.idx, 10);
653 if (!isNaN(idx) && idx !== currentIndex) {
654 loadTrack(idx, true);
655 }
656 });
657 });
658 sheetQueue.hidden = queue.length < 2;
659 }
660
661 function escapeHtml(s) {
662 return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
663 }
664
665 // ============================================================
666 // 4b. Mutual exclusion — shared media registry (see embed-player.js).
667 // ============================================================
668 // All players (this site player + YouTube/SoundCloud/Spotify embeds)
669 // register themselves in window.pcmsMediaRegistry. Starting one pauses
670 // the previous. This is the precise replacement for the old focus/blur
671 // heuristic for embeds with a real JS API. (The blur fallback below stays
672 // for iframe-only embeds without an API: Bandcamp/Apple Music/Vimeo.)
673 function mediaRegistry() {
674 if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
675 const r = {
676 _active: null,
677 setActive(player) {
678 if (this._active && this._active !== player && this._active.pause) {
679 try { this._active.pause(); } catch (e) {}
680 }
681 this._active = player;
682 },
683 release(player) { if (this._active === player) this._active = null; },
684 };
685 window.pcmsMediaRegistry = r;
686 return r;
687 }
688 const registrySelf = { pause() { try { audio.pause(); } catch (e) {} } };
689
690 // ============================================================
691 // 5. Audio element events → UI sync
692 // ============================================================
693 // Error counter prevents an infinite loop when ALL tracks are broken.
694 let consecutiveErrors = 0;
695
696 audio.addEventListener('play', () => {
697 isPlaying = true;
698 root.classList.add('is-playing');
699 root.classList.remove('audio-needs-tap'); // hide tap hint
700 mediaRegistry().setActive(registrySelf); // pause any currently playing embeds
701 if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'playing'; } catch (e) {} }
702 });
703
704 // Media Session action handlers (wired once): lock-screen / headset / car
705 // controls, and — crucially — an active session so iOS keeps a programmatic
706 // auto-advance playing instead of pausing it the instant it starts.
707 if ('mediaSession' in navigator) {
708 const ms = navigator.mediaSession;
709 const wire = (action, fn) => { try { ms.setActionHandler(action, fn); } catch (e) { /* unsupported action */ } };
710 wire('play', () => play());
711 wire('pause', () => pause());
712 wire('previoustrack', () => prev());
713 wire('nexttrack', () => next());
714 wire('seekto', (e) => {
715 if (!e || e.seekTime == null) return;
716 // Lock-screen scrubber works in TRACK coordinates (positionState below).
717 if (useMse() && chain.length) {
718 const seg = currentSegment();
719 if (seg) { try { audio.currentTime = seg.start + Math.min(e.seekTime, seg.end - seg.start - 0.1); } catch (er) {} }
720 return;
721 }
722 if (audio.duration) { try { audio.currentTime = e.seekTime; } catch (er) {} }
723 });
724 }
725 // Lock-screen / notification scrubber: report per-track position, not the
726 // whole-chain timeline.
727 function updatePositionState() {
728 if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
729 try {
730 const dt = displayTimes();
731 if (!isFinite(dt.dur) || !dt.dur) return;
732 navigator.mediaSession.setPositionState({
733 duration: dt.dur,
734 playbackRate: audio.playbackRate || 1,
735 position: Math.min(dt.cur, dt.dur),
736 });
737 } catch (e) { /* non-fatal */ }
738 }
739 // Reset the error counter only on a REAL playback start (`playing`), not the
740 // eager `play` event. `play` fires before any network/decode error, so resetting
741 // there would prevent the 3-strikes stop from ever triggering on a broken
742 // track → infinite "next" loop. `playing` only fires when audio is actually playing.
743 audio.addEventListener('playing', () => {
744 consecutiveErrors = 0;
745 if (useMse()) ensureNextAppended(); else preloadNext();
746 updatePositionState();
747 });
748 audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'paused'; } catch (e) {} } });
749 audio.addEventListener('ended', next);
750 audio.addEventListener('error', (e) => {
751 const code = audio.error ? audio.error.code : '?';
752 console.error('[pcms-audio] playback error', code, audio.src, e);
753 if (useMse() && ms) {
754 // The MSE pipeline failed (decode/append) → permanently fall back to the
755 // blob engine for this session and retry the SAME track.
756 console.warn('[pcms-audio] MSE failed, falling back to blob playback');
757 mseFailed = true;
758 teardownChain();
759 if (queue[currentIndex]) loadTrack(currentIndex, true);
760 return;
761 }
762 consecutiveErrors++;
763 // On network/decode error: skip to next track instead of stalling.
764 // Max 3 consecutive errors before giving up (otherwise infinite loop).
765 if (consecutiveErrors < 3 && queue.length > 1) {
766 console.warn('[pcms-audio] auto-skip to next after error', consecutiveErrors);
767 setTimeout(next, 400);
768 }
769 });
770 audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled at', audio.currentTime));
771 audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
772 audio.addEventListener('timeupdate', () => {
773 // Gapless boundary: in MSE mode a track change is just the timeline
774 // flowing past a segment edge — detect it here and update the chrome.
775 if (useMse() && chain.length) maybeCrossBoundary();
776 const dt = displayTimes();
777 if (!dt.dur || isNaN(dt.dur) || !isFinite(dt.dur)) return;
778 const pct = (dt.cur / dt.dur) * 100;
779 seekFill.style.width = pct + '%';
780 sheetSeekFill.style.width = pct + '%';
781 currentEl.textContent = formatTime(dt.cur);
782 totalEl.textContent = formatTime(dt.dur);
783 sheetCurrent.textContent = formatTime(dt.cur);
784 sheetTotal.textContent = formatTime(dt.dur);
785 });
786
787 function formatTime(s) {
788 if (!s || isNaN(s)) return '0:00';
789 const m = Math.floor(s / 60), sec = Math.floor(s % 60);
790 return m + ':' + (sec < 10 ? '0' : '') + sec;
791 }
792
793 function attachSeek(seekEl) {
794 seekEl.addEventListener('click', (e) => {
795 const rect = seekEl.getBoundingClientRect();
796 const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
797 if (useMse() && chain.length) {
798 // Seek within the CURRENT track's segment of the chain timeline.
799 const seg = currentSegment();
800 if (seg) {
801 try { audio.currentTime = seg.start + ratio * (seg.end - seg.start); } catch (er) {}
802 updatePositionState();
803 }
804 return;
805 }
806 if (!audio.duration) return;
807 audio.currentTime = ratio * audio.duration;
808 });
809 }
810 attachSeek(seek);
811 attachSeek(sheetSeek);
812
813 // Volume + mute persist across sessions/pages via localStorage.
814 const VOL_KEY = 'pcmsVolume', MUTE_KEY = 'pcmsMuted';
815 const saveVol = () => { try { localStorage.setItem(VOL_KEY, String(audio.volume)); localStorage.setItem(MUTE_KEY, audio.muted ? '1' : '0'); } catch (e) { /* private mode */ } };
816 let _initVol = parseFloat(localStorage.getItem(VOL_KEY));
817 if (!isFinite(_initVol) || _initVol < 0 || _initVol > 1) _initVol = 0.8;
818 audio.volume = _initVol;
819 volumeSlider.value = Math.round(_initVol * 100);
820 if (localStorage.getItem(MUTE_KEY) === '1') audio.muted = true;
821 root.classList.toggle('is-muted', audio.muted || audio.volume === 0);
822 volumeSlider.addEventListener('input', () => {
823 audio.volume = volumeSlider.value / 100;
824 if (volumeSlider.value > 0) audio.muted = false;
825 saveVol();
826 });
827 muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; saveVol(); });
828
829 // ============================================================
830 // 6. Control wiring
831 // ============================================================
832 playBtn.addEventListener('click', togglePlay);
833 prevBtn.addEventListener('click', prev);
834 nextBtn.addEventListener('click', next);
835 sheetPlay.addEventListener('click', togglePlay);
836 sheetPrev.addEventListener('click', prev);
837 sheetNext.addEventListener('click', next);
838
839 // ============================================================
840 // 7. Sheet expand/close + drag-down-to-close
841 // ============================================================
842 // Back button closes the sheet on mobile: on open we push a history entry
843 // so the phone back button (popstate) closes the sheet first instead of
844 // leaving the page. We balance it on a UI-initiated close via history.back().
845 let sheetHistoryPushed = false;
846
847 function openSheet() {
848 if (!hasFullPlayer()) return; // desktop (≥1200): no full player, mini-player only
849 if (sheet.classList.contains('is-open')) return;
850 sheet.classList.add('is-open');
851 sheet.setAttribute('aria-hidden', 'false');
852 document.body.classList.add('audio-sheet-locked');
853 // Phone + tablet: push a history entry so the back button closes the sheet first.
854 try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {}
855 }
856 function closeSheet(fromPopstate) {
857 if (!sheet.classList.contains('is-open')) return;
858 sheet.classList.remove('is-open');
859 sheet.setAttribute('aria-hidden', 'true');
860 document.body.classList.remove('audio-sheet-locked');
861 sheetPanel.style.removeProperty('--pcms-drag-y');
862 sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
863 // UI close (X / swipe / backdrop / Esc): pop our own history entry so the
864 // next back button navigates normally. On a popstate close (back button itself)
865 // the entry is already popped.
866 const wasPushed = sheetHistoryPushed;
867 sheetHistoryPushed = false;
868 if (wasPushed && !fromPopstate) { try { history.back(); } catch (e) {} }
869 }
870 window.addEventListener('popstate', () => {
871 if (sheet.classList.contains('is-open')) closeSheet(true);
872 });
873 // Clicking the mini-player track info:
874 // - DESKTOP (≥1200px): jump to the post the track came from (if known),
875 // via htmx so audio keeps playing. No post known → fall back to the sheet.
876 // - MOBILE/TABLET: always open the full now-playing sheet.
877 function scrollToTrack(trackId) {
878 if (!trackId) { window.scrollTo(0, 0); return; }
879 const el = document.getElementById('track-' + trackId);
880 if (!el) { window.scrollTo(0, 0); return; }
881 el.scrollIntoView({ block: 'center', behavior: 'smooth' });
882 el.classList.add('pat-flash');
883 setTimeout(() => el.classList.remove('pat-flash'), 1600);
884 }
885 function goToPost(url, trackId) {
886 const hash = trackId ? ('#track-' + trackId) : '';
887 if (window.htmx && url.charAt(0) === '/') {
888 try {
889 const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' });
890 history.pushState({}, '', url + hash);
891 // Scroll to the track after the swap (small delay so the global
892 // afterSwap scroll-to-top runs first); fall back to top if not found.
893 const go = () => setTimeout(() => scrollToTrack(trackId), 60);
894 if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150);
895 return;
896 } catch (e) { /* fall back to full navigation */ }
897 }
898 location.href = url + hash;
899 }
900 expandTrigger.addEventListener('click', () => {
901 const t = queue[currentIndex];
902 // Only on true desktop (≥1200, no full-player) do we jump to the post.
903 // Tablet + phone have a full-player → open it (same as mobile behaviour).
904 const jumpToPost = !hasFullPlayer();
905 if (jumpToPost && t) {
906 // 1) Track played from a post → we already know that URL.
907 if (t.postUrl) { goToPost(t.postUrl, t.id); return; }
908 // 2) Site-wide track (no postUrl) → look up the post via the track id.
909 if (t.id) {
910 fetch('/audio/track/' + encodeURIComponent(t.id) + '/post')
911 .then((r) => (r.ok ? r.json() : null))
912 .then((d) => { if (d && d.url) goToPost(d.url, t.id); else openSheet(); })
913 .catch(() => openSheet());
914 return;
915 }
916 }
917 openSheet();
918 });
919 sheetClose.addEventListener('click', () => closeSheet());
920 sheetBackdrop.addEventListener('click', () => closeSheet());
921 document.addEventListener('keydown', (e) => {
922 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
923 });
924 // Resized to desktop width (≥1200) while the full player is open? Close it —
925 // the full player doesn't exist on desktop.
926 window.addEventListener('resize', () => {
927 if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet();
928 });
929
930 // Fallback for mutual exclusion. For YouTube/SoundCloud/Spotify embeds the
931 // registry already handles this precisely (real play events). But for
932 // iframe-only embeds WITHOUT a JS API (Bandcamp/Apple/Vimeo) and for the
933 // iframe FALLBACK (when an ad-blocker blocks the player API) there is no
934 // play event: we catch those via focus. User clicks such an iframe →
935 // window 'blur' → pause our player. (For API embeds this is at worst a
936 // harmless double-pause.)
937 window.addEventListener('blur', () => {
938 setTimeout(() => {
939 const el = document.activeElement;
940 // Only embed iframes (inside .folio-embed) pause the player — not a
941 // random iframe (captcha/ad/map) that happens to receive focus.
942 if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
943 pause();
944 }
945 }, 0);
946 });
947
948 // Drag-down-to-close on touch devices.
949 //
950 // We set --pcms-drag-y as a CSS custom prop instead of writing
951 // sheetPanel.style.transform directly. The reason: on desktop the panel
952 // uses `transform: translate(-50%, 0)` for horizontal centering. Writing
953 // `style.transform = translateY(...)` would obliterate the -50% and the
954 // panel would jump rightward. With a custom prop, audio.css composes the
955 // final transform per breakpoint:
956 // mobile: transform: translateY(var(--pcms-drag-y, 0))
957 // desktop: transform: translate(-50%, var(--pcms-drag-y, 0))
958 let dragStartY = 0, dragLastY = 0, isDragging = false;
959 function onPointerDown(e) {
960 if (e.pointerType !== 'touch') return;
961 if (sheetPanel.scrollTop > 0) return; // queue is scrolled, don't drag
962 dragStartY = dragLastY = e.clientY;
963 isDragging = true;
964 sheetPanel.classList.add('is-dragging');
965 sheetBackdrop.classList.add('is-dragging');
966 }
967 function onPointerMove(e) {
968 if (!isDragging) return;
969 dragLastY = e.clientY;
970 const dy = Math.max(0, dragLastY - dragStartY);
971 sheetPanel.style.setProperty('--pcms-drag-y', dy + 'px');
972 const progress = Math.max(0, 1 - dy / sheetPanel.offsetHeight);
973 sheetBackdrop.style.setProperty('--pcms-sheet-progress', String(progress));
974 }
975 function onPointerUp() {
976 if (!isDragging) return;
977 isDragging = false;
978 sheetPanel.classList.remove('is-dragging');
979 sheetBackdrop.classList.remove('is-dragging');
980 const dy = dragLastY - dragStartY;
981 if (dy > 100) {
982 closeSheet();
983 } else {
984 sheetPanel.style.removeProperty('--pcms-drag-y');
985 sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
986 }
987 }
988 if (window.PointerEvent) {
989 dragZone.addEventListener('pointerdown', onPointerDown);
990 document.addEventListener('pointermove', onPointerMove);
991 document.addEventListener('pointerup', onPointerUp);
992 document.addEventListener('pointercancel', onPointerUp);
993 }
994
995 // ============================================================
996 // 8. Hook up post-audio-track + post-album-cover-btn + .pat-row + .post-album-playall
997 // ============================================================
998 // Four entry points fire the same play action:
999 // - .pat-play → single-track widget in a post
1000 // - .pat-row → track row inside an album/playlist tracklist
1001 // - .post-album-cover-btn → big cover button (plays album from track 0)
1002 // - .post-album-playall → "Speel album" / "Speel playlist" button
1003 //
1004 // For .pat-play the metadata lives on the surrounding .post-audio-track
1005 // wrapper. For the other three the data is on the button itself. The
1006 // handler reads from button-first, falls back to wrapper.
1007 // Event delegation on document.body instead of per-button listeners. This
1008 // survives HTMX history-restores: the mobile back button (popstate) lets HTMX
1009 // restore #pcms-main from its snapshot; a per-element `data-pcms-attached` flag
1010 // would leave dead buttons (flag baked into the snapshot, listener gone). One
1011 // delegated listener works regardless of how many times the DOM is (re)swapped.
1012 const PLAY_SELECTOR =
1013 '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall';
1014 document.body.addEventListener('click', (e) => {
1015 const btn = e.target.closest(PLAY_SELECTOR);
1016 if (!btn) return;
1017 e.preventDefault();
1018 e.stopPropagation();
1019 // The post you're playing FROM = the current page (embeds live in post content).
1020 // Store it on the track(s) so the desktop mini-player can jump back to it —
1021 // survives the sessionStorage resume as well.
1022 const postUrl = location.pathname + location.search;
1023 // Resolve metadata: button-first, then closest .post-audio-track wrapper
1024 // (only inline single-track widgets put the data on the wrapper).
1025 const wrapper = btn.closest('.post-audio-track');
1026 const albumId = btn.dataset.pcmsAlbumId || (wrapper && wrapper.dataset.pcmsAlbumId);
1027 const trackData = btn.dataset.pcmsTrack || (wrapper && wrapper.dataset.pcmsTrack);
1028 const trackUrl = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
1029 console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
1030
1031 if (albumId) {
1032 const album = document.getElementById(albumId);
1033 if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
1034 try {
1035 const tracks = JSON.parse(album.dataset.pcmsAlbum);
1036 tracks.forEach((t) => { t.postUrl = postUrl; });
1037 // Start at the clicked track if we know its URL, else start at 0
1038 // (cover-btn and playall both want to start from the beginning).
1039 const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
1040 setQueue(tracks, startIdx >= 0 ? startIdx : 0, { albumName: album.dataset.pcmsAlbumTitle || '' });
1041 } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
1042 } else if (trackData) {
1043 try {
1044 const t = JSON.parse(trackData);
1045 t.postUrl = postUrl;
1046 setQueue([t], 0);
1047 } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
1048 } else if (trackUrl) {
1049 // Fallback: at minimum we have the signed URL
1050 setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '', postUrl }], 0);
1051 } else {
1052 console.error('[pcms-audio] no track data or url on button or wrapper', btn);
1053 }
1054 });
1055
1056 // ============================================================
1057 // 8b. Admin playlist delete (event delegation)
1058 // ============================================================
1059 // The post-album embed renders a [data-pcms-playlist-delete] button
1060 // top-right when the viewer is admin (server decides; not client).
1061 // We delegate from document.body so HTMX-swapped content works too.
1062 document.body.addEventListener('click', async (e) => {
1063 const btn = e.target.closest('[data-pcms-playlist-delete]');
1064 if (!btn) return;
1065 e.preventDefault();
1066 e.stopPropagation();
1067 const id = btn.dataset.pcmsPlaylistDelete;
1068 const title = btn.dataset.pcmsPlaylistTitle || id;
1069 if (!id) return;
1070 if (!confirm(`Playlist "${title}" verwijderen? De embed in deze post toont vanaf nu een placeholder.`)) return;
1071 btn.disabled = true;
1072 try {
1073 const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
1074 method: 'POST',
1075 headers: { 'X-CSRF-Token': '' },
1076 credentials: 'same-origin',
1077 });
1078 const j = await r.json().catch(() => ({}));
1079 if (j && j.ok) {
1080 location.reload();
1081 } else {
1082 alert('Verwijderen mislukt: ' + ((j && j.error) || 'onbekende fout'));
1083 btn.disabled = false;
1084 }
1085 } catch (err) {
1086 alert('Verwijderen mislukt: ' + err.message);
1087 btn.disabled = false;
1088 }
1089 });
1090
1091 // ============================================================
1092 // 9. Public API
1093 // ============================================================
1094 window.pcmsAudioPlayer = {
1095 setQueue, play, pause, next, prev, close, openSheet, closeSheet,
1096 isPlaying: () => isPlaying,
1097 currentTrack: () => queue[currentIndex] || null,
1098 };
1099
1100 // ============================================================
1101 // 10. Session persistence — player "survives" across page navigations
1102 // ============================================================
1103 // An <audio> element doesn't survive a full page load (and cross-context
1104 // navigation — e.g. to the headerless hub overview — is intentionally a
1105 // full-nav). We save the session to sessionStorage and restore + resume it
1106 // on the next page: the player comes back with the same track at the same
1107 // position. In Chrome (high media engagement) it resumes immediately;
1108 // if the browser blocks autoplay it waits at that position (one tap = play).
1109 const PLAYER_STATE_KEY = 'pcms-player-state';
1110 let pendingSeek = 0;
1111 function savePlayerState() {
1112 try {
1113 if (!queue.length) { sessionStorage.removeItem(PLAYER_STATE_KEY); return; }
1114 sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
1115 queue, currentIndex, albumName,
1116 // In-TRACK position (the MSE timeline spans the whole chain; a restore
1117 // starts a fresh chain where this track begins at 0).
1118 time: displayTimes().cur || 0,
1119 playing: !!audio.src && !audio.paused,
1120 }));
1121 } catch (e) {}
1122 }
1123 window.addEventListener('pagehide', savePlayerState);
1124 window.addEventListener('beforeunload', savePlayerState);
1125 audio.addEventListener('play', savePlayerState);
1126 audio.addEventListener('pause', savePlayerState);
1127 audio.addEventListener('ended', savePlayerState);
1128 setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000);
1129 // Apply the restored position once track metadata is available.
1130 audio.addEventListener('loadedmetadata', () => {
1131 if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) {
1132 try { audio.currentTime = Math.min(pendingSeek, audio.duration - 0.25); } catch (e) {}
1133 pendingSeek = 0;
1134 }
1135 });
1136 function restorePlayerState() {
1137 let s = null;
1138 try { s = JSON.parse(sessionStorage.getItem(PLAYER_STATE_KEY) || 'null'); } catch (e) { return false; }
1139 if (!s || !Array.isArray(s.queue) || !s.queue.length) return false;
1140 queue = s.queue;
1141 albumName = s.albumName || '';
1142 pendingSeek = s.time || 0;
1143 const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
1144 // playing → fetch + (attempt to) resume; paused → meta-only.
1145 loadTrack(idx, !!s.playing, !s.playing);
1146 return true;
1147 }
1148
1149 // ============================================================
1150 // 11. Site-level pre-seed (window.PCMS_SITE_TRACKS)
1151 // ============================================================
1152 // An active session (restore) wins over the page seed, so music that is
1153 // already playing continues instead of being replaced by the new page's tracks.
1154 if (!restorePlayerState()) {
1155 if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
1156 queue = window.PCMS_SITE_TRACKS.map(t => ({
1157 id: t.id || null,
1158 url: t.media_url || t.url,
1159 title: t.title || 'Untitled',
1160 artist: t.artist || '',
1161 cover: t.cover_url || t.cover || '',
1162 }));
1163 // Only prime the queue — the player bar appears only on the first audio click
1164 // (.post-audio-track or the mini-player play button calls setQueue/loadTrack,
1165 // which shows the bar). No more pre-seed bar on page load.
1166 currentIndex = 0;
1167 }
1168 }
1169})();
Note: See TracBrowser for help on using the repository browser.