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

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

Een bandje loopt niet rond: het stopt aan het eind, en aan het begin

Robins eis (22-8). Drie omlopen zaten in de weg, en de stilste was de keten:
na het laatste nummer hing die nummer een er weer achter, en omdat de wachtrij
EEN doorlopende tijdlijn is merk je dat niet eens als een trackwissel -- de
band gaat gewoon door alsof er meer op stond.

  • De keten haakt in bandmodus niets meer aan voorbij het laatste nummer.
  • next() pauzeert daar in plaats van om te lopen. ended roept next() aan, dus dit is wat de band echt laat stoppen.
  • prev() loopt aan het begin niet naar achteren om: terugspoelen voorbij de kop levert de kop op, niet het laatste nummer.
  • Vooruit prefetchen stopt ook aan het eind.

De drie modulo's blijven staan voor een gewone wachtrij; die hoort wel te
blijven rondlopen. Daar staat een test op, anders sneuvelt dat gedrag stil.

TERUGSPOELEN IS NU ECHT SPOELEN, ook over de nummergrenzen. Op de MSE-motor
ging dat al vanzelf (de band is een tijdlijn). Op de blob-motor landde het in
de KOP van het vorige nummer, en dan kom je nooit ergens in het midden uit; nu
zet hij pendingSeek zodat je aan het EIND van dat nummer uitkomt.

EN EEN FOUT DIE IK ER ZELF MEE INTRODUCEERDE, gevonden door het te draaien en
niet door te lezen: bij een MediaSource vuurt ended pas als de stream dicht
is, en die ging alleen dicht bij een wachtrij van een. Sinds de keten aan het
eind niets meer aanhaakt bleef de band dus hangen op de laatste seconde --
teller stil, isPlaying() waar, spoelen draaiend. Gemeten: 125,6 van 125,7.
De stream sluit nu ook aan het eind van een bandje.

Spoelen stopt vanzelf bij de kop en de staart: beweegt de teller twee rondes
niet, dan is er geen band meer. Anders bleven de spoelen draaien terwijl er
niets gebeurde.

Nagemeten op dev: band loopt af -> laatste nummer, gestopt, teller op 125,7 van
125,7, spoelen stil, knop terug op Afspelen. next() op het laatste nummer
blijft staan; prev() op het eerste spoelt naar de kop.

audio-player v37, MOD_V 54.

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

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