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

main
Last change on this file since 46f23fd was 8681239, checked in by Robin <robin@…>, 4 months ago

audio v3: robust continuous playback — pause+load reset, error auto-skip, autoplay policy hint

  • loadTrack: pause + audio.load() for a clean reset (prevents state corruption after multiple src changes, root cause of 'stop after 3-4 tracks')
  • play() rejection NotAllowedError -> visual pulse hint on the play button so the user knows 'tap to continue' (iOS autoplay policy)
  • audio.error event: auto-skip to next track, max 3 errors in a row before giving up (anti-infinite-loop)
  • Reset error counter on successful play
  • Cache buster ?v=3 on shell.ejs
  • Property mode set to 100644
File size: 24.1 KB
Line 
1/**
2 * PrutCMS v10 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 via htmx:afterSettle re-attach
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
161 // Hide initially
162 root.classList.add('audio-player-hidden');
163
164 // ============================================================
165 // 4. Track / queue loading
166 // ============================================================
167 function setCoverImage(el, url) {
168 if (url) {
169 el.style.backgroundImage = `url("${url}")`;
170 el.classList.add('has-image');
171 el.innerHTML = '';
172 } else {
173 el.style.backgroundImage = '';
174 el.classList.remove('has-image');
175 el.innerHTML = SVG.musicNote;
176 }
177 }
178
179 function loadTrack(index) {
180 if (!queue[index]) {
181 console.warn('[pcms-audio] loadTrack: no track at index', index);
182 return;
183 }
184 currentIndex = index;
185 const t = queue[index];
186 if (!t.url) {
187 console.error('[pcms-audio] track has no url', t);
188 return;
189 }
190 console.log('[pcms-audio] loading', t.title, t.url);
191 // Schone overgang: pause + reset voorkomt state-corruption van het
192 // audio-element na meerdere src-changes (bug die continuous playback
193 // brak na 3-4 tracks). audio.load() forceert reset van internal state.
194 try { audio.pause(); } catch (e) {}
195 audio.src = t.url;
196 try { audio.load(); } catch (e) {}
197 titleEl.textContent = t.title || 'Untitled';
198 artistEl.textContent = t.artist || '';
199 sheetTitle.textContent = t.title || 'Untitled';
200 sheetArtist.textContent = t.artist || '';
201 sheetAlbum.textContent = albumName || '';
202 setCoverImage(cover, t.cover);
203 setCoverImage(sheetCover, t.cover);
204 root.classList.remove('audio-player-hidden');
205 document.body.classList.add('has-audio-player');
206 renderQueue();
207 }
208
209 function setQueue(tracks, startIdx, opts) {
210 queue = Array.isArray(tracks) ? tracks.slice() : [];
211 albumName = (opts && opts.albumName) || '';
212 if (!queue.length) return;
213 loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0);
214 play();
215 }
216
217 function play() {
218 if (!audio.src) return;
219 const p = audio.play();
220 if (p && typeof p.catch === 'function') {
221 p.catch((err) => {
222 console.warn('[pcms-audio] play() rejected:', err.name, err.message);
223 // Browser-autoplay-policy heeft 't gestopt (typisch na 3-4
224 // automatische plays op iOS Safari, of als tab tijdelijk inactive
225 // was). Visuele hint dat user op play moet tappen.
226 if (err && err.name === 'NotAllowedError') {
227 root.classList.add('audio-needs-tap');
228 isPlaying = false;
229 root.classList.remove('is-playing');
230 }
231 });
232 }
233 }
234 function pause() { audio.pause(); }
235 function togglePlay() { audio.paused ? play() : pause(); }
236 function next() {
237 if (!queue.length) return;
238 loadTrack((currentIndex + 1) % queue.length);
239 play();
240 }
241 function prev() {
242 if (!queue.length) return;
243 loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1);
244 play();
245 }
246 function close() {
247 pause();
248 root.classList.add('audio-player-hidden');
249 document.body.classList.remove('has-audio-player');
250 closeSheet();
251 queue = [];
252 albumName = '';
253 }
254
255 function renderQueue() {
256 if (!queue.length) { sheetQueue.hidden = true; return; }
257 sheetQueueList.innerHTML = '';
258 queue.forEach((t, i) => {
259 const li = document.createElement('li');
260 li.className = 'audio-sheet-queue-item' + (i === currentIndex ? ' is-current' : '');
261 li.dataset.idx = String(i);
262 li.innerHTML = `<span class="aqi-num">${i + 1}.</span> <span class="aqi-title">${escapeHtml(t.title || 'Untitled')}</span>`
263 + (t.artist ? `<span class="aqi-artist">${escapeHtml(t.artist)}</span>` : '');
264 sheetQueueList.appendChild(li);
265 });
266 sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
267 li.addEventListener('click', () => {
268 const idx = parseInt(li.dataset.idx, 10);
269 if (!isNaN(idx) && idx !== currentIndex) {
270 loadTrack(idx);
271 play();
272 }
273 });
274 });
275 sheetQueue.hidden = queue.length < 2;
276 }
277
278 function escapeHtml(s) {
279 return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
280 }
281
282 // ============================================================
283 // 5. Audio element events → UI sync
284 // ============================================================
285 // Error-counter voorkomt infinite-loop als ALLE tracks broken zijn.
286 let consecutiveErrors = 0;
287
288 audio.addEventListener('play', () => {
289 isPlaying = true;
290 consecutiveErrors = 0; // reset bij succesvolle play
291 root.classList.add('is-playing');
292 root.classList.remove('audio-needs-tap'); // verstop tap-hint
293 });
294 audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
295 audio.addEventListener('ended', next);
296 audio.addEventListener('error', (e) => {
297 const code = audio.error ? audio.error.code : '?';
298 console.error('[pcms-audio] playback error', code, audio.src, e);
299 consecutiveErrors++;
300 // Bij netwerk/decode-fout: skip naar volgende track ipv stilstaan.
301 // Max 3 fouten op rij voordat we opgeven (anders infinite loop).
302 if (consecutiveErrors < 3 && queue.length > 1) {
303 console.warn('[pcms-audio] auto-skip naar volgende na error', consecutiveErrors);
304 setTimeout(next, 400);
305 }
306 });
307 audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled at', audio.currentTime));
308 audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
309 audio.addEventListener('timeupdate', () => {
310 if (!audio.duration || isNaN(audio.duration)) return;
311 const pct = (audio.currentTime / audio.duration) * 100;
312 seekFill.style.width = pct + '%';
313 sheetSeekFill.style.width = pct + '%';
314 currentEl.textContent = formatTime(audio.currentTime);
315 totalEl.textContent = formatTime(audio.duration);
316 sheetCurrent.textContent = formatTime(audio.currentTime);
317 sheetTotal.textContent = formatTime(audio.duration);
318 });
319
320 function formatTime(s) {
321 if (!s || isNaN(s)) return '0:00';
322 const m = Math.floor(s / 60), sec = Math.floor(s % 60);
323 return m + ':' + (sec < 10 ? '0' : '') + sec;
324 }
325
326 function attachSeek(seekEl) {
327 seekEl.addEventListener('click', (e) => {
328 if (!audio.duration) return;
329 const rect = seekEl.getBoundingClientRect();
330 const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
331 audio.currentTime = ratio * audio.duration;
332 });
333 }
334 attachSeek(seek);
335 attachSeek(sheetSeek);
336
337 audio.volume = 0.8;
338 volumeSlider.addEventListener('input', () => {
339 audio.volume = volumeSlider.value / 100;
340 if (volumeSlider.value > 0) audio.muted = false;
341 });
342 muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; });
343
344 // ============================================================
345 // 6. Control wiring
346 // ============================================================
347 playBtn.addEventListener('click', togglePlay);
348 prevBtn.addEventListener('click', prev);
349 nextBtn.addEventListener('click', next);
350 sheetPlay.addEventListener('click', togglePlay);
351 sheetPrev.addEventListener('click', prev);
352 sheetNext.addEventListener('click', next);
353
354 // ============================================================
355 // 7. Sheet expand/close + drag-down-to-close
356 // ============================================================
357 function openSheet() {
358 sheet.classList.add('is-open');
359 sheet.setAttribute('aria-hidden', 'false');
360 document.body.classList.add('audio-sheet-locked');
361 }
362 function closeSheet() {
363 sheet.classList.remove('is-open');
364 sheet.setAttribute('aria-hidden', 'true');
365 document.body.classList.remove('audio-sheet-locked');
366 sheetPanel.style.removeProperty('--pcms-drag-y');
367 sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
368 }
369 expandTrigger.addEventListener('click', openSheet);
370 sheetClose.addEventListener('click', closeSheet);
371 sheetBackdrop.addEventListener('click', closeSheet);
372 document.addEventListener('keydown', (e) => {
373 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
374 });
375
376 // Drag-down-to-close on touch devices.
377 //
378 // We set --pcms-drag-y as a CSS custom prop instead of writing
379 // sheetPanel.style.transform directly. The reason: on desktop the panel
380 // uses `transform: translate(-50%, 0)` for horizontal centering. Writing
381 // `style.transform = translateY(...)` would obliterate the -50% and the
382 // panel would jump rightward. With a custom prop, audio.css composes the
383 // final transform per breakpoint:
384 // mobile: transform: translateY(var(--pcms-drag-y, 0))
385 // desktop: transform: translate(-50%, var(--pcms-drag-y, 0))
386 let dragStartY = 0, dragLastY = 0, isDragging = false;
387 function onPointerDown(e) {
388 if (e.pointerType !== 'touch') return;
389 if (sheetPanel.scrollTop > 0) return; // queue is scrolled, don't drag
390 dragStartY = dragLastY = e.clientY;
391 isDragging = true;
392 sheetPanel.classList.add('is-dragging');
393 sheetBackdrop.classList.add('is-dragging');
394 }
395 function onPointerMove(e) {
396 if (!isDragging) return;
397 dragLastY = e.clientY;
398 const dy = Math.max(0, dragLastY - dragStartY);
399 sheetPanel.style.setProperty('--pcms-drag-y', dy + 'px');
400 const progress = Math.max(0, 1 - dy / sheetPanel.offsetHeight);
401 sheetBackdrop.style.setProperty('--pcms-sheet-progress', String(progress));
402 }
403 function onPointerUp() {
404 if (!isDragging) return;
405 isDragging = false;
406 sheetPanel.classList.remove('is-dragging');
407 sheetBackdrop.classList.remove('is-dragging');
408 const dy = dragLastY - dragStartY;
409 if (dy > 100) {
410 closeSheet();
411 } else {
412 sheetPanel.style.removeProperty('--pcms-drag-y');
413 sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
414 }
415 }
416 if (window.PointerEvent) {
417 dragZone.addEventListener('pointerdown', onPointerDown);
418 document.addEventListener('pointermove', onPointerMove);
419 document.addEventListener('pointerup', onPointerUp);
420 document.addEventListener('pointercancel', onPointerUp);
421 }
422
423 // ============================================================
424 // 8. Hook up post-audio-track + post-album-cover-btn + .pat-row + .post-album-playall
425 // ============================================================
426 // Four entry points fire the same play action:
427 // - .pat-play → single-track widget in a post
428 // - .pat-row → track row inside an album/playlist tracklist
429 // - .post-album-cover-btn → big cover button (plays album from track 0)
430 // - .post-album-playall → "Speel album" / "Speel playlist" button
431 //
432 // For .pat-play the metadata lives on the surrounding .post-audio-track
433 // wrapper. For the other three the data is on the button itself. The
434 // handler reads from button-first, falls back to wrapper.
435 function attachListeners() {
436 const playBtns = document.querySelectorAll(
437 '.post-audio-track .pat-play, ' +
438 '.post-album-tracks .pat-row, ' +
439 '.post-album-cover-btn, ' +
440 '.post-album-playall'
441 );
442 if (playBtns.length) console.log('[pcms-audio] attaching to', playBtns.length, 'play buttons');
443
444 playBtns.forEach((btn) => {
445 if (btn.dataset.pcmsAttached) return;
446 btn.dataset.pcmsAttached = '1';
447
448 btn.addEventListener('click', (e) => {
449 e.preventDefault();
450 e.stopPropagation();
451 // Resolve metadata: button-first, then closest .post-audio-track wrapper
452 // (only inline single-track widgets put the data on the wrapper).
453 const wrapper = btn.closest('.post-audio-track');
454 const albumId = btn.dataset.pcmsAlbumId || (wrapper && wrapper.dataset.pcmsAlbumId);
455 const trackData = btn.dataset.pcmsTrack || (wrapper && wrapper.dataset.pcmsTrack);
456 const trackUrl = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
457 console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
458
459 if (albumId) {
460 const album = document.getElementById(albumId);
461 if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
462 try {
463 const tracks = JSON.parse(album.dataset.pcmsAlbum);
464 // Start at the clicked track if we know its URL, else start at 0
465 // (cover-btn and playall both want to start from the beginning).
466 const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
467 setQueue(tracks, startIdx >= 0 ? startIdx : 0, { albumName: album.dataset.pcmsAlbumTitle || '' });
468 } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
469 } else if (trackData) {
470 try {
471 const t = JSON.parse(trackData);
472 setQueue([t], 0);
473 } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
474 } else if (trackUrl) {
475 // Fallback: at minimum we have the signed URL
476 setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '' }], 0);
477 } else {
478 console.error('[pcms-audio] no track data or url on button or wrapper', btn);
479 }
480 });
481 });
482 }
483
484 attachListeners();
485 document.body.addEventListener('htmx:afterSettle', attachListeners);
486
487 // ============================================================
488 // 8b. Admin playlist delete (event delegation)
489 // ============================================================
490 // The post-album embed renders a [data-pcms-playlist-delete] button
491 // top-right when the viewer is admin (server decides; not client).
492 // We delegate from document.body so HTMX-swapped content works too.
493 document.body.addEventListener('click', async (e) => {
494 const btn = e.target.closest('[data-pcms-playlist-delete]');
495 if (!btn) return;
496 e.preventDefault();
497 e.stopPropagation();
498 const id = btn.dataset.pcmsPlaylistDelete;
499 const title = btn.dataset.pcmsPlaylistTitle || id;
500 if (!id) return;
501 if (!confirm(`Playlist "${title}" verwijderen? De embed in deze post toont vanaf nu een placeholder.`)) return;
502 btn.disabled = true;
503 try {
504 const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
505 method: 'POST',
506 headers: { 'X-CSRF-Token': '' },
507 credentials: 'same-origin',
508 });
509 const j = await r.json().catch(() => ({}));
510 if (j && j.ok) {
511 location.reload();
512 } else {
513 alert('Verwijderen mislukt: ' + ((j && j.error) || 'onbekende fout'));
514 btn.disabled = false;
515 }
516 } catch (err) {
517 alert('Verwijderen mislukt: ' + err.message);
518 btn.disabled = false;
519 }
520 });
521
522 // ============================================================
523 // 9. Public API
524 // ============================================================
525 window.pcmsAudioPlayer = {
526 setQueue, play, pause, next, prev, close, openSheet, closeSheet,
527 isPlaying: () => isPlaying,
528 currentTrack: () => queue[currentIndex] || null,
529 };
530
531 // ============================================================
532 // 10. Site-level pre-seed (window.PCMS_SITE_TRACKS)
533 // ============================================================
534 if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
535 queue = window.PCMS_SITE_TRACKS.map(t => ({
536 url: t.media_url || t.url,
537 title: t.title || 'Untitled',
538 artist: t.artist || '',
539 cover: t.cover_url || t.cover || '',
540 }));
541 if (queue.length) loadTrack(0);
542 }
543})();
Note: See TracBrowser for help on using the repository browser.