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

main 1.3.0
Last change on this file since a369029 was 73cffc3, checked in by Robin Genis <roboburr@…>, 2 months ago

feat(player): remember volume + mute across sessions (localStorage)

The audio player reset to 80% on every load. It now restores the last volume and mute state
from localStorage (pcmsVolume/pcmsMuted) and saves on every change.

  • assets/js/audio-player.js — read/write pcmsVolume + pcmsMuted on load + input/mute
  • views/shell.ejs — bump audio-player.js cache buster to v31
  • Property mode set to 100644
File size: 43.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 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 // Blob playback state. We fetch each track's bytes and play from a blob:
161 // object URL — no plain media URL is ever exposed to the page. currentObjectUrl
162 // is revoked when we move on, so we don't leak one Blob per track in memory.
163 let currentObjectUrl = null;
164 // Monotonic load token: a fast prev/next can fire several loads before an
165 // earlier fetch resolves. Only the latest load may set audio.src.
166 let loadSeq = 0;
167 // Next-track prefetch. While the current track plays we download the *next*
168 // track's bytes into a held blob, so `ended` → next() can swap src instantly
169 // (no silent gap, and no long async window where the browser's autoplay
170 // activation can lapse and reject play()). At most one track ahead is held.
171 // Shape: { url, objUrl } — objUrl is null while the fetch is still in flight.
172 let preload = null;
173
174 // Hide initially
175 root.classList.add('audio-player-hidden');
176
177 // ============================================================
178 // 4. Track / queue loading
179 // ============================================================
180 function setCoverImage(el, url) {
181 if (url) {
182 el.style.backgroundImage = `url("${url}")`;
183 el.classList.add('has-image');
184 el.innerHTML = '';
185 } else {
186 el.style.backgroundImage = '';
187 el.classList.remove('has-image');
188 el.innerHTML = SVG.musicNote;
189 }
190 }
191
192 // Fetch the track bytes and hand back a blob: object URL. The X-Audio-Player
193 // header + same-origin credentials get us past the stream route's access gate.
194 // Retries a few times with backoff: a single transient network blip used to
195 // bump the error counter and SKIP the song (auto-advance past it). Now one
196 // hiccup just costs a retry, and we only give up after genuinely failing.
197 async function fetchAsObjectUrl(url, attempts) {
198 attempts = attempts || 1;
199 let lastErr;
200 for (let i = 0; i < attempts; i++) {
201 try {
202 const r = await fetch(url, {
203 credentials: 'same-origin',
204 headers: { 'X-Audio-Player': '1' },
205 });
206 if (!r.ok) throw new Error('HTTP ' + r.status);
207 const blob = await r.blob();
208 return URL.createObjectURL(blob);
209 } catch (e) {
210 lastErr = e;
211 if (i < attempts - 1) {
212 await new Promise((res) => setTimeout(res, 350 * (i + 1)));
213 }
214 }
215 }
216 throw lastErr;
217 }
218
219 // Apply a ready blob URL to the <audio> element. Single source of truth for
220 // "swap the playing source": used by both the cached-preload path and the
221 // fresh-fetch path so there's one place that touches audio.src.
222 function applyBlob(objUrl, autoplay, mySeq) {
223 if (mySeq !== loadSeq) { try { URL.revokeObjectURL(objUrl); } catch (e) {} return; } // superseded
224 root.classList.remove('audio-loading');
225 // Free the previously-playing track's blob — otherwise each track leaks a
226 // copy. Never the same handle as objUrl (createObjectURL is unique), so this
227 // can't revoke the source we're about to play.
228 if (currentObjectUrl && currentObjectUrl !== objUrl) {
229 try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
230 }
231 currentObjectUrl = objUrl;
232 // Clean transition: pause + load forces a reset of internal state after
233 // multiple src changes (prevents state corruption of the audio element).
234 try { audio.pause(); } catch (e) {}
235 audio.src = objUrl;
236 try { audio.load(); } catch (e) {}
237 if (autoplay) play();
238 }
239
240 function onLoadError(err, mySeq) {
241 if (mySeq !== loadSeq) return; // superseded — ignore stale failure
242 root.classList.remove('audio-loading');
243 console.error('[pcms-audio] track load failed after retries', err);
244 // Genuine failure (after retries): bump the counter and auto-skip, but stop
245 // after 3 in a row so a fully-broken queue can't loop "next" forever.
246 consecutiveErrors++;
247 if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
248 }
249
250 // Discard any held/in-flight preload and free its blob if resolved.
251 function dropPreload() {
252 if (preload && preload.objUrl) { try { URL.revokeObjectURL(preload.objUrl); } catch (e) {} }
253 preload = null;
254 }
255
256 // Prefetch the *next* track's bytes in the background. Idempotent: re-calling
257 // while the same track is already cached / in flight is a no-op. Called from
258 // the `playing` event so the network is otherwise idle.
259 function preloadNext() {
260 if (queue.length < 2) return;
261 const ni = (currentIndex + 1) % queue.length;
262 const t = queue[ni];
263 if (!t || !t.url) return;
264 if (preload && preload.url === t.url) return; // already held or in flight
265 dropPreload(); // different track queued before → free it
266 const marker = { url: t.url, objUrl: null };
267 preload = marker;
268 fetchAsObjectUrl(t.url, 2).then((obj) => {
269 // Only keep it if this is still the track we want next; otherwise free it.
270 if (preload === marker) { marker.objUrl = obj; }
271 else { try { URL.revokeObjectURL(obj); } catch (e) {} }
272 }).catch(() => { if (preload === marker) preload = null; });
273 }
274
275 // metaOnly: show the track in the UI but DON'T download its bytes yet.
276 // Used by the site pre-seed so opening a page doesn't auto-download audio;
277 // the blob is fetched lazily on the first play().
278 // Persistently mark the current track (stays highlighted as long as it's active).
279 function markPlaying(trackId) {
280 document.querySelectorAll('.pat-playing').forEach((e) => e.classList.remove('pat-playing'));
281 if (!trackId) return;
282 const el = document.getElementById('track-' + trackId);
283 if (el) el.classList.add('pat-playing');
284 }
285 // After an htmx navigation the post DOM is replaced → reapply the highlight.
286 document.body.addEventListener('htmx:afterSettle', () => {
287 const t = queue[currentIndex];
288 if (t) markPlaying(t.id);
289 });
290
291 // Media Session metadata (lock-screen / notification info + artwork). Set per
292 // track; the action handlers are wired once below. Keeping a live media session
293 // is what lets iOS continue a programmatic auto-advance play() instead of
294 // pausing it immediately.
295 function updateMediaMetadata(t) {
296 if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
297 try {
298 const art = [];
299 if (t && t.cover) {
300 let u = t.cover; try { u = new URL(t.cover, location.href).href; } catch (e) {}
301 art.push({ src: u, sizes: '512x512', type: '' });
302 }
303 navigator.mediaSession.metadata = new MediaMetadata({
304 title: (t && t.title) || 'Untitled',
305 artist: (t && t.artist) || '',
306 album: albumName || '',
307 artwork: art,
308 });
309 } catch (e) { /* non-fatal */ }
310 }
311
312 function loadTrack(index, autoplay, metaOnly) {
313 if (!queue[index]) {
314 console.warn('[pcms-audio] loadTrack: no track at index', index);
315 return;
316 }
317 currentIndex = index;
318 const t = queue[index];
319 if (!t.url) {
320 console.error('[pcms-audio] track has no url', t);
321 return;
322 }
323 console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : '');
324 // Metadata + chrome update synchronously so the UI reacts instantly while
325 // the bytes download.
326 titleEl.textContent = t.title || 'Untitled';
327 artistEl.textContent = t.artist || '';
328 sheetTitle.textContent = t.title || 'Untitled';
329 sheetArtist.textContent = t.artist || '';
330 sheetAlbum.textContent = albumName || '';
331 setCoverImage(cover, t.cover);
332 setCoverImage(sheetCover, t.cover);
333 root.classList.remove('audio-player-hidden');
334 document.body.classList.add('has-audio-player');
335 renderQueue();
336 markPlaying(t.id);
337 updateMediaMetadata(t);
338
339 if (metaOnly) return;
340
341 const mySeq = ++loadSeq;
342
343 // Fast path: the bytes for this exact track were already prefetched while
344 // the previous track played → swap in instantly, no gap, no fetch window.
345 if (preload && preload.url === t.url && preload.objUrl) {
346 const obj = preload.objUrl;
347 preload = null; // ownership moves to applyBlob (becomes currentObjectUrl)
348 applyBlob(obj, autoplay, mySeq);
349 return;
350 }
351
352 // Not preloaded (or preload still in flight) → drop any stale preload and
353 // fetch fresh, retrying transient failures before giving up.
354 dropPreload();
355 root.classList.add('audio-loading');
356 fetchAsObjectUrl(t.url, 3)
357 .then((objUrl) => applyBlob(objUrl, autoplay, mySeq))
358 .catch((err) => onLoadError(err, mySeq));
359 }
360
361 // True when the viewport is in the mobile sheet-layout — matches the CSS
362 // breakpoint where .audio-sheet slides up full-width from the bottom
363 // (@media max-width:719.98px). On wider/desktop widths the sheet is a centered
364 // panel, so we do NOT auto-open it there.
365 // Three layouts (Robin 2026-06-15): phone (<768) = fullscreen sheet;
366 // tablet/car (768–1199) = large landscape full-player (tablet + car-mode);
367 // desktop (≥1200) = mini-player only, NO full player. matchMedia so this
368 // exactly follows the CSS breakpoints.
369 function playerTier() {
370 if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop';
371 if (window.matchMedia('(min-width: 768px)').matches) return 'tablet';
372 return 'phone';
373 }
374 function hasFullPlayer() { return playerTier() !== 'desktop'; }
375 function isMobileView() { return playerTier() === 'phone'; }
376
377 function setQueue(tracks, startIdx, opts) {
378 queue = Array.isArray(tracks) ? tracks.slice() : [];
379 albumName = (opts && opts.albumName) || '';
380 if (!queue.length) return;
381 loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
382 // Mobile: a track press from an album/playlist auto-opens the full
383 // now-playing sheet (Spotify-style) instead of just the thin mini-strip.
384 // Only here (setQueue = a fresh, user-initiated queue) —
385 // not on next/prev or the site pre-seed — so a sheet the user
386 // deliberately closed doesn't reappear by itself.
387 if (hasFullPlayer()) openSheet();
388 }
389
390 function play() {
391 if (!audio.src) {
392 // Nothing fetched yet (pre-seed showed metadata only, or a load is still
393 // in flight). Kick off the blob load for the current track and autoplay.
394 if (queue[currentIndex]) loadTrack(currentIndex, true);
395 return;
396 }
397 const p = audio.play();
398 if (p && typeof p.catch === 'function') {
399 p.catch((err) => {
400 console.warn('[pcms-audio] play() rejected:', err.name, err.message);
401 // Browser autoplay policy blocked it (typically after 3-4
402 // auto-plays on iOS Safari, or when the tab was temporarily inactive).
403 // Show a visual hint for the user to tap play.
404 if (err && err.name === 'NotAllowedError') {
405 root.classList.add('audio-needs-tap');
406 isPlaying = false;
407 root.classList.remove('is-playing');
408 }
409 });
410 }
411 }
412 function pause() { audio.pause(); }
413 function togglePlay() { audio.paused ? play() : pause(); }
414 function next() {
415 if (!queue.length) return;
416 loadTrack((currentIndex + 1) % queue.length, true);
417 }
418 function prev() {
419 if (!queue.length) return;
420 loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
421 }
422 function close() {
423 pause();
424 mediaRegistry().release(registrySelf);
425 root.classList.add('audio-player-hidden');
426 document.body.classList.remove('has-audio-player');
427 closeSheet();
428 queue = [];
429 albumName = '';
430 loadSeq++; // cancel any in-flight load
431 dropPreload(); // free any prefetched next-track blob
432 if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
433 currentObjectUrl = null;
434 try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
435 }
436
437 function renderQueue() {
438 if (!queue.length) { sheetQueue.hidden = true; return; }
439 sheetQueueList.innerHTML = '';
440 queue.forEach((t, i) => {
441 const li = document.createElement('li');
442 li.className = 'audio-sheet-queue-item' + (i === currentIndex ? ' is-current' : '');
443 li.dataset.idx = String(i);
444 li.innerHTML = `<span class="aqi-num">${i + 1}.</span> <span class="aqi-title">${escapeHtml(t.title || 'Untitled')}</span>`
445 + (t.artist ? `<span class="aqi-artist">${escapeHtml(t.artist)}</span>` : '');
446 sheetQueueList.appendChild(li);
447 });
448 sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
449 li.addEventListener('click', () => {
450 const idx = parseInt(li.dataset.idx, 10);
451 if (!isNaN(idx) && idx !== currentIndex) {
452 loadTrack(idx, true);
453 }
454 });
455 });
456 sheetQueue.hidden = queue.length < 2;
457 }
458
459 function escapeHtml(s) {
460 return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
461 }
462
463 // ============================================================
464 // 4b. Mutual exclusion — shared media registry (see embed-player.js).
465 // ============================================================
466 // All players (this site player + YouTube/SoundCloud/Spotify embeds)
467 // register themselves in window.pcmsMediaRegistry. Starting one pauses
468 // the previous. This is the precise replacement for the old focus/blur
469 // heuristic for embeds with a real JS API. (The blur fallback below stays
470 // for iframe-only embeds without an API: Bandcamp/Apple Music/Vimeo.)
471 function mediaRegistry() {
472 if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
473 const r = {
474 _active: null,
475 setActive(player) {
476 if (this._active && this._active !== player && this._active.pause) {
477 try { this._active.pause(); } catch (e) {}
478 }
479 this._active = player;
480 },
481 release(player) { if (this._active === player) this._active = null; },
482 };
483 window.pcmsMediaRegistry = r;
484 return r;
485 }
486 const registrySelf = { pause() { try { audio.pause(); } catch (e) {} } };
487
488 // ============================================================
489 // 5. Audio element events → UI sync
490 // ============================================================
491 // Error counter prevents an infinite loop when ALL tracks are broken.
492 let consecutiveErrors = 0;
493
494 audio.addEventListener('play', () => {
495 isPlaying = true;
496 root.classList.add('is-playing');
497 root.classList.remove('audio-needs-tap'); // hide tap hint
498 mediaRegistry().setActive(registrySelf); // pause any currently playing embeds
499 if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'playing'; } catch (e) {} }
500 });
501
502 // Media Session action handlers (wired once): lock-screen / headset / car
503 // controls, and — crucially — an active session so iOS keeps a programmatic
504 // auto-advance playing instead of pausing it the instant it starts.
505 if ('mediaSession' in navigator) {
506 const ms = navigator.mediaSession;
507 const wire = (action, fn) => { try { ms.setActionHandler(action, fn); } catch (e) { /* unsupported action */ } };
508 wire('play', () => play());
509 wire('pause', () => pause());
510 wire('previoustrack', () => prev());
511 wire('nexttrack', () => next());
512 wire('seekto', (e) => { if (e && e.seekTime != null && audio.duration) { try { audio.currentTime = e.seekTime; } catch (er) {} } });
513 }
514 // Reset the error counter only on a REAL playback start (`playing`), not the
515 // eager `play` event. `play` fires before any network/decode error, so resetting
516 // there would prevent the 3-strikes stop from ever triggering on a broken
517 // track → infinite "next" loop. `playing` only fires when audio is actually playing.
518 audio.addEventListener('playing', () => { consecutiveErrors = 0; preloadNext(); });
519 audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'paused'; } catch (e) {} } });
520 audio.addEventListener('ended', next);
521 audio.addEventListener('error', (e) => {
522 const code = audio.error ? audio.error.code : '?';
523 console.error('[pcms-audio] playback error', code, audio.src, e);
524 consecutiveErrors++;
525 // On network/decode error: skip to next track instead of stalling.
526 // Max 3 consecutive errors before giving up (otherwise infinite loop).
527 if (consecutiveErrors < 3 && queue.length > 1) {
528 console.warn('[pcms-audio] auto-skip to next after error', consecutiveErrors);
529 setTimeout(next, 400);
530 }
531 });
532 audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled at', audio.currentTime));
533 audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
534 audio.addEventListener('timeupdate', () => {
535 if (!audio.duration || isNaN(audio.duration)) return;
536 const pct = (audio.currentTime / audio.duration) * 100;
537 seekFill.style.width = pct + '%';
538 sheetSeekFill.style.width = pct + '%';
539 currentEl.textContent = formatTime(audio.currentTime);
540 totalEl.textContent = formatTime(audio.duration);
541 sheetCurrent.textContent = formatTime(audio.currentTime);
542 sheetTotal.textContent = formatTime(audio.duration);
543 });
544
545 function formatTime(s) {
546 if (!s || isNaN(s)) return '0:00';
547 const m = Math.floor(s / 60), sec = Math.floor(s % 60);
548 return m + ':' + (sec < 10 ? '0' : '') + sec;
549 }
550
551 function attachSeek(seekEl) {
552 seekEl.addEventListener('click', (e) => {
553 if (!audio.duration) return;
554 const rect = seekEl.getBoundingClientRect();
555 const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
556 audio.currentTime = ratio * audio.duration;
557 });
558 }
559 attachSeek(seek);
560 attachSeek(sheetSeek);
561
562 // Volume + mute persist across sessions/pages via localStorage.
563 const VOL_KEY = 'pcmsVolume', MUTE_KEY = 'pcmsMuted';
564 const saveVol = () => { try { localStorage.setItem(VOL_KEY, String(audio.volume)); localStorage.setItem(MUTE_KEY, audio.muted ? '1' : '0'); } catch (e) { /* private mode */ } };
565 let _initVol = parseFloat(localStorage.getItem(VOL_KEY));
566 if (!isFinite(_initVol) || _initVol < 0 || _initVol > 1) _initVol = 0.8;
567 audio.volume = _initVol;
568 volumeSlider.value = Math.round(_initVol * 100);
569 if (localStorage.getItem(MUTE_KEY) === '1') audio.muted = true;
570 root.classList.toggle('is-muted', audio.muted || audio.volume === 0);
571 volumeSlider.addEventListener('input', () => {
572 audio.volume = volumeSlider.value / 100;
573 if (volumeSlider.value > 0) audio.muted = false;
574 saveVol();
575 });
576 muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; saveVol(); });
577
578 // ============================================================
579 // 6. Control wiring
580 // ============================================================
581 playBtn.addEventListener('click', togglePlay);
582 prevBtn.addEventListener('click', prev);
583 nextBtn.addEventListener('click', next);
584 sheetPlay.addEventListener('click', togglePlay);
585 sheetPrev.addEventListener('click', prev);
586 sheetNext.addEventListener('click', next);
587
588 // ============================================================
589 // 7. Sheet expand/close + drag-down-to-close
590 // ============================================================
591 // Back button closes the sheet on mobile: on open we push a history entry
592 // so the phone back button (popstate) closes the sheet first instead of
593 // leaving the page. We balance it on a UI-initiated close via history.back().
594 let sheetHistoryPushed = false;
595
596 function openSheet() {
597 if (!hasFullPlayer()) return; // desktop (≥1200): no full player, mini-player only
598 if (sheet.classList.contains('is-open')) return;
599 sheet.classList.add('is-open');
600 sheet.setAttribute('aria-hidden', 'false');
601 document.body.classList.add('audio-sheet-locked');
602 // Phone + tablet: push a history entry so the back button closes the sheet first.
603 try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {}
604 }
605 function closeSheet(fromPopstate) {
606 if (!sheet.classList.contains('is-open')) return;
607 sheet.classList.remove('is-open');
608 sheet.setAttribute('aria-hidden', 'true');
609 document.body.classList.remove('audio-sheet-locked');
610 sheetPanel.style.removeProperty('--pcms-drag-y');
611 sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
612 // UI close (X / swipe / backdrop / Esc): pop our own history entry so the
613 // next back button navigates normally. On a popstate close (back button itself)
614 // the entry is already popped.
615 const wasPushed = sheetHistoryPushed;
616 sheetHistoryPushed = false;
617 if (wasPushed && !fromPopstate) { try { history.back(); } catch (e) {} }
618 }
619 window.addEventListener('popstate', () => {
620 if (sheet.classList.contains('is-open')) closeSheet(true);
621 });
622 // Clicking the mini-player track info:
623 // - DESKTOP (≥1200px): jump to the post the track came from (if known),
624 // via htmx so audio keeps playing. No post known → fall back to the sheet.
625 // - MOBILE/TABLET: always open the full now-playing sheet.
626 function scrollToTrack(trackId) {
627 if (!trackId) { window.scrollTo(0, 0); return; }
628 const el = document.getElementById('track-' + trackId);
629 if (!el) { window.scrollTo(0, 0); return; }
630 el.scrollIntoView({ block: 'center', behavior: 'smooth' });
631 el.classList.add('pat-flash');
632 setTimeout(() => el.classList.remove('pat-flash'), 1600);
633 }
634 function goToPost(url, trackId) {
635 const hash = trackId ? ('#track-' + trackId) : '';
636 if (window.htmx && url.charAt(0) === '/') {
637 try {
638 const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' });
639 history.pushState({}, '', url + hash);
640 // Scroll to the track after the swap (small delay so the global
641 // afterSwap scroll-to-top runs first); fall back to top if not found.
642 const go = () => setTimeout(() => scrollToTrack(trackId), 60);
643 if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150);
644 return;
645 } catch (e) { /* fall back to full navigation */ }
646 }
647 location.href = url + hash;
648 }
649 expandTrigger.addEventListener('click', () => {
650 const t = queue[currentIndex];
651 // Only on true desktop (≥1200, no full-player) do we jump to the post.
652 // Tablet + phone have a full-player → open it (same as mobile behaviour).
653 const jumpToPost = !hasFullPlayer();
654 if (jumpToPost && t) {
655 // 1) Track played from a post → we already know that URL.
656 if (t.postUrl) { goToPost(t.postUrl, t.id); return; }
657 // 2) Site-wide track (no postUrl) → look up the post via the track id.
658 if (t.id) {
659 fetch('/audio/track/' + encodeURIComponent(t.id) + '/post')
660 .then((r) => (r.ok ? r.json() : null))
661 .then((d) => { if (d && d.url) goToPost(d.url, t.id); else openSheet(); })
662 .catch(() => openSheet());
663 return;
664 }
665 }
666 openSheet();
667 });
668 sheetClose.addEventListener('click', () => closeSheet());
669 sheetBackdrop.addEventListener('click', () => closeSheet());
670 document.addEventListener('keydown', (e) => {
671 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
672 });
673 // Resized to desktop width (≥1200) while the full player is open? Close it —
674 // the full player doesn't exist on desktop.
675 window.addEventListener('resize', () => {
676 if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet();
677 });
678
679 // Fallback for mutual exclusion. For YouTube/SoundCloud/Spotify embeds the
680 // registry already handles this precisely (real play events). But for
681 // iframe-only embeds WITHOUT a JS API (Bandcamp/Apple/Vimeo) and for the
682 // iframe FALLBACK (when an ad-blocker blocks the player API) there is no
683 // play event: we catch those via focus. User clicks such an iframe →
684 // window 'blur' → pause our player. (For API embeds this is at worst a
685 // harmless double-pause.)
686 window.addEventListener('blur', () => {
687 setTimeout(() => {
688 const el = document.activeElement;
689 // Only embed iframes (inside .folio-embed) pause the player — not a
690 // random iframe (captcha/ad/map) that happens to receive focus.
691 if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
692 pause();
693 }
694 }, 0);
695 });
696
697 // Drag-down-to-close on touch devices.
698 //
699 // We set --pcms-drag-y as a CSS custom prop instead of writing
700 // sheetPanel.style.transform directly. The reason: on desktop the panel
701 // uses `transform: translate(-50%, 0)` for horizontal centering. Writing
702 // `style.transform = translateY(...)` would obliterate the -50% and the
703 // panel would jump rightward. With a custom prop, audio.css composes the
704 // final transform per breakpoint:
705 // mobile: transform: translateY(var(--pcms-drag-y, 0))
706 // desktop: transform: translate(-50%, var(--pcms-drag-y, 0))
707 let dragStartY = 0, dragLastY = 0, isDragging = false;
708 function onPointerDown(e) {
709 if (e.pointerType !== 'touch') return;
710 if (sheetPanel.scrollTop > 0) return; // queue is scrolled, don't drag
711 dragStartY = dragLastY = e.clientY;
712 isDragging = true;
713 sheetPanel.classList.add('is-dragging');
714 sheetBackdrop.classList.add('is-dragging');
715 }
716 function onPointerMove(e) {
717 if (!isDragging) return;
718 dragLastY = e.clientY;
719 const dy = Math.max(0, dragLastY - dragStartY);
720 sheetPanel.style.setProperty('--pcms-drag-y', dy + 'px');
721 const progress = Math.max(0, 1 - dy / sheetPanel.offsetHeight);
722 sheetBackdrop.style.setProperty('--pcms-sheet-progress', String(progress));
723 }
724 function onPointerUp() {
725 if (!isDragging) return;
726 isDragging = false;
727 sheetPanel.classList.remove('is-dragging');
728 sheetBackdrop.classList.remove('is-dragging');
729 const dy = dragLastY - dragStartY;
730 if (dy > 100) {
731 closeSheet();
732 } else {
733 sheetPanel.style.removeProperty('--pcms-drag-y');
734 sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
735 }
736 }
737 if (window.PointerEvent) {
738 dragZone.addEventListener('pointerdown', onPointerDown);
739 document.addEventListener('pointermove', onPointerMove);
740 document.addEventListener('pointerup', onPointerUp);
741 document.addEventListener('pointercancel', onPointerUp);
742 }
743
744 // ============================================================
745 // 8. Hook up post-audio-track + post-album-cover-btn + .pat-row + .post-album-playall
746 // ============================================================
747 // Four entry points fire the same play action:
748 // - .pat-play → single-track widget in a post
749 // - .pat-row → track row inside an album/playlist tracklist
750 // - .post-album-cover-btn → big cover button (plays album from track 0)
751 // - .post-album-playall → "Speel album" / "Speel playlist" button
752 //
753 // For .pat-play the metadata lives on the surrounding .post-audio-track
754 // wrapper. For the other three the data is on the button itself. The
755 // handler reads from button-first, falls back to wrapper.
756 // Event delegation on document.body instead of per-button listeners. This
757 // survives HTMX history-restores: the mobile back button (popstate) lets HTMX
758 // restore #pcms-main from its snapshot; a per-element `data-pcms-attached` flag
759 // would leave dead buttons (flag baked into the snapshot, listener gone). One
760 // delegated listener works regardless of how many times the DOM is (re)swapped.
761 const PLAY_SELECTOR =
762 '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall';
763 document.body.addEventListener('click', (e) => {
764 const btn = e.target.closest(PLAY_SELECTOR);
765 if (!btn) return;
766 e.preventDefault();
767 e.stopPropagation();
768 // The post you're playing FROM = the current page (embeds live in post content).
769 // Store it on the track(s) so the desktop mini-player can jump back to it —
770 // survives the sessionStorage resume as well.
771 const postUrl = location.pathname + location.search;
772 // Resolve metadata: button-first, then closest .post-audio-track wrapper
773 // (only inline single-track widgets put the data on the wrapper).
774 const wrapper = btn.closest('.post-audio-track');
775 const albumId = btn.dataset.pcmsAlbumId || (wrapper && wrapper.dataset.pcmsAlbumId);
776 const trackData = btn.dataset.pcmsTrack || (wrapper && wrapper.dataset.pcmsTrack);
777 const trackUrl = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
778 console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
779
780 if (albumId) {
781 const album = document.getElementById(albumId);
782 if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
783 try {
784 const tracks = JSON.parse(album.dataset.pcmsAlbum);
785 tracks.forEach((t) => { t.postUrl = postUrl; });
786 // Start at the clicked track if we know its URL, else start at 0
787 // (cover-btn and playall both want to start from the beginning).
788 const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
789 setQueue(tracks, startIdx >= 0 ? startIdx : 0, { albumName: album.dataset.pcmsAlbumTitle || '' });
790 } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
791 } else if (trackData) {
792 try {
793 const t = JSON.parse(trackData);
794 t.postUrl = postUrl;
795 setQueue([t], 0);
796 } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
797 } else if (trackUrl) {
798 // Fallback: at minimum we have the signed URL
799 setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '', postUrl }], 0);
800 } else {
801 console.error('[pcms-audio] no track data or url on button or wrapper', btn);
802 }
803 });
804
805 // ============================================================
806 // 8b. Admin playlist delete (event delegation)
807 // ============================================================
808 // The post-album embed renders a [data-pcms-playlist-delete] button
809 // top-right when the viewer is admin (server decides; not client).
810 // We delegate from document.body so HTMX-swapped content works too.
811 document.body.addEventListener('click', async (e) => {
812 const btn = e.target.closest('[data-pcms-playlist-delete]');
813 if (!btn) return;
814 e.preventDefault();
815 e.stopPropagation();
816 const id = btn.dataset.pcmsPlaylistDelete;
817 const title = btn.dataset.pcmsPlaylistTitle || id;
818 if (!id) return;
819 if (!confirm(`Playlist "${title}" verwijderen? De embed in deze post toont vanaf nu een placeholder.`)) return;
820 btn.disabled = true;
821 try {
822 const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
823 method: 'POST',
824 headers: { 'X-CSRF-Token': '' },
825 credentials: 'same-origin',
826 });
827 const j = await r.json().catch(() => ({}));
828 if (j && j.ok) {
829 location.reload();
830 } else {
831 alert('Verwijderen mislukt: ' + ((j && j.error) || 'onbekende fout'));
832 btn.disabled = false;
833 }
834 } catch (err) {
835 alert('Verwijderen mislukt: ' + err.message);
836 btn.disabled = false;
837 }
838 });
839
840 // ============================================================
841 // 9. Public API
842 // ============================================================
843 window.pcmsAudioPlayer = {
844 setQueue, play, pause, next, prev, close, openSheet, closeSheet,
845 isPlaying: () => isPlaying,
846 currentTrack: () => queue[currentIndex] || null,
847 };
848
849 // ============================================================
850 // 10. Session persistence — player "survives" across page navigations
851 // ============================================================
852 // An <audio> element doesn't survive a full page load (and cross-context
853 // navigation — e.g. to the headerless hub overview — is intentionally a
854 // full-nav). We save the session to sessionStorage and restore + resume it
855 // on the next page: the player comes back with the same track at the same
856 // position. In Chrome (high media engagement) it resumes immediately;
857 // if the browser blocks autoplay it waits at that position (one tap = play).
858 const PLAYER_STATE_KEY = 'pcms-player-state';
859 let pendingSeek = 0;
860 function savePlayerState() {
861 try {
862 if (!queue.length) { sessionStorage.removeItem(PLAYER_STATE_KEY); return; }
863 sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
864 queue, currentIndex, albumName,
865 time: audio.currentTime || 0,
866 playing: !!audio.src && !audio.paused,
867 }));
868 } catch (e) {}
869 }
870 window.addEventListener('pagehide', savePlayerState);
871 window.addEventListener('beforeunload', savePlayerState);
872 audio.addEventListener('play', savePlayerState);
873 audio.addEventListener('pause', savePlayerState);
874 audio.addEventListener('ended', savePlayerState);
875 setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000);
876 // Apply the restored position once track metadata is available.
877 audio.addEventListener('loadedmetadata', () => {
878 if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) {
879 try { audio.currentTime = Math.min(pendingSeek, audio.duration - 0.25); } catch (e) {}
880 pendingSeek = 0;
881 }
882 });
883 function restorePlayerState() {
884 let s = null;
885 try { s = JSON.parse(sessionStorage.getItem(PLAYER_STATE_KEY) || 'null'); } catch (e) { return false; }
886 if (!s || !Array.isArray(s.queue) || !s.queue.length) return false;
887 queue = s.queue;
888 albumName = s.albumName || '';
889 pendingSeek = s.time || 0;
890 const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
891 // playing → fetch + (attempt to) resume; paused → meta-only.
892 loadTrack(idx, !!s.playing, !s.playing);
893 return true;
894 }
895
896 // ============================================================
897 // 11. Site-level pre-seed (window.PCMS_SITE_TRACKS)
898 // ============================================================
899 // An active session (restore) wins over the page seed, so music that is
900 // already playing continues instead of being replaced by the new page's tracks.
901 if (!restorePlayerState()) {
902 if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
903 queue = window.PCMS_SITE_TRACKS.map(t => ({
904 id: t.id || null,
905 url: t.media_url || t.url,
906 title: t.title || 'Untitled',
907 artist: t.artist || '',
908 cover: t.cover_url || t.cover || '',
909 }));
910 // Only prime the queue — the player bar appears only on the first audio click
911 // (.post-audio-track or the mini-player play button calls setQueue/loadTrack,
912 // which shows the bar). No more pre-seed bar on page load.
913 currentIndex = 0;
914 }
915 }
916})();
Note: See TracBrowser for help on using the repository browser.