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

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

Speler-UI in bandmodus: spoelknoppen, eerlijke teller, lijst zonder keuze (shaer-tmyn)

Speelt er een bandje, dan hoort de speler onderin zich als een cassettedeck te
gedragen en niet als een playlist-speler. Drie van de vier stappen uit de bead;
de vierde (seek over de hele tijdlijn) staat er los van, want die raakt
loadTrack en de keten.

  • De tracklijst blijft staan maar is geen keuzelijst: renderQueue hangt in bandmodus geen klik meer op de items, en een CSS-klasse haalt de belofte weg dat het kan. Op een cassette spring je niet naar nummer zeven.
  • Vorige/volgende worden terugspoelen/vooruitspoelen: ander teken, ander woord, en vasthouden spoelt door. Een korte klik spoelt vijf seconden, voor toetsenbord en schermlezer.
  • De spoellus is VERHUISD naar de speler. Hij stond in mod/tape.js, en met spoelknoppen op twee plekken zouden twee kopieen van dezelfde versnelling uit elkaar lopen zodra iemand aan een getal draait. De cassette in de post bedient nu dezelfde lus en tekent alleen nog het jasje.

En de teller is eerlijk geworden. Die kwam uit chain[last].end, dus uit de
lengte van wat toevallig gebufferd was: 2:05 met een nummer geladen, 4:07 met
drie, terwijl de band 6:13 duurt. Erger nog, sprong je naar nummer drie dan
begon de keten daar opnieuw op nul en stond de teller weer vooraan. Nu telt hij
bandOffset(nummer) + positie in dat nummer, uit de trackduren -- die daarvoor
in de wachtrij-JSON moesten. Ontbreekt er een duur, dan valt hij terug: een som
met gaten is een verzonnen getal.

Nagemeten op dev: totaal 6:13 meteen bij de eerste seconde (klopt met de
PT373S op het AP-object), vier knoppen op Terug-/Vooruitspoelen, drie items in
de lijst met pointer-events none en het lopende nummer gemarkeerd, en spoelen
via de spelerknop loopt door de band heen.

audio-player v38, MOD_V 55, style.css v108.

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

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