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

main
Last change on this file since e679bce was e679bce, checked in by Robin <roboburr@…>, 3 weeks ago

Het bandje speelt als bandje: een cassette, een object, spoelen in seconden

Robins correctie (21-8): vooruit en achteruit deden hier next()/prev(), en dat
is een playlistgebaar. Dan is het een lijst met een cassetteplaatje erboven.
Een cassette kent geen "volgend nummer" -- je houdt de knop ingedrukt, de band
loopt door, en je laat los waar je bent.

Dat kan omdat audio-player.js een wachtrij al in EEN doorlopende
MediaSource-tijdlijn giet: "een trackwissel is een positie, geen omschakeling".
Een bandje IS die tijdlijn. Dus:

  • seekBy(seconden) op de speler, over de nummergrenzen heen. Op de blob-motor (iOS, geen MSE) bestaat die tijdlijn niet; daar loopt spoelen tot de rand van het nummer en stapt dan naar de buur. Grover, maar eerlijker dan doen alsof.
  • Bandmodus: de speler toont het BANDJE als titel met het lopende nummer eronder, en de teller loopt over de hele band in plaats van per nummer. Dat is wat "een object, geen losse tracks" betekent op het scherm.
  • Vasthouden versnelt (4x tot 16x), loslaten stopt -- ook als je buiten de knop loslaat. Een korte tik spoelt vijf seconden, want vasthouden is met een spatiebalk geen gebaar.

Een echte cassette, als SVG. Verhouding 100,4 x 63,8 mm, de maat van een
compact cassette, dus viewBox 314x200: behuizing, labelvlak met de titel erop,
venster, twee spoelen met tandjes die meedraaien, de bandpakketten, vier
schroefjes en de openingen voor de kop en de capstans. De vorige versie waren
twee CSS-schijfjes.

Twee dingen die alleen door het echt te draaien boven kwamen:

  • audio-player.js kiest zijn knoppen met een lijst KLASSENAMEN (PLAY_SELECTOR), niet met een regel over data-attributen. De cassetteknop droeg alle juiste data-pcms-* en deed niets. Er staat nu een test op.
  • De sessie sloeg displayTimes().cur op als trackpositie. In bandmodus is dat de bandteller, dus een hersteld bandje sprong naar een plek die in dat nummer niet bestaat. Daarvoor is trackTijd() afgesplitst.

MOD_V 53, style.css v107, audio-player v35.

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

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