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

main
Last change on this file since 8ab20b6 was 8ab20b6, checked in by roboburr <roboburr@…>, 3 months ago

fix(player): muted start to work around YouTube autoplay block

YouTube blocks unmuted autoplay (track loaded title but stayed at 0:00).
Now the YT player starts MUTED (muted autoplay is always allowed) and
unmutes in onYTState once it is actually playing (unless the user muted it
themselves). buster audio-player.js?v=27.

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

  • Property mode set to 100644
File size: 49.8 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 // Schone overgang: pause + load forceert reset van internal state na
233 // meerdere src-changes (voorkomt state-corruption van het 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 // Markeer de huidige track persistent (blijvende highlight zolang 'ie actief is).
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 // Na een htmx-navigatie is de post-DOM vervangen → highlight opnieuw zetten.
286 document.body.addEventListener('htmx:afterSettle', () => {
287 const t = queue[currentIndex];
288 if (t) markPlaying(t.id);
289 });
290
291 // ============================================================
292 // 4a. YouTube-backend — speelt YT-AUDIO via een verborgen IFrame-speler in de
293 // (persistente) speler-root. Zo speelt YouTube net als de site-tracks door bij
294 // htmx-navigatie en is 't bedienbaar in de onderbalk. Video wordt niet getoond.
295 // ============================================================
296 let ytMode = false, ytPlayer = null, ytReady = false, ytApiLoading = null, ytTick = null;
297
298 function loadYTApi() {
299 if (window.YT && window.YT.Player) return Promise.resolve(window.YT);
300 if (ytApiLoading) return ytApiLoading;
301 ytApiLoading = new Promise((resolve) => {
302 const prev = window.onYouTubeIframeAPIReady;
303 window.onYouTubeIframeAPIReady = function () { if (prev) { try { prev(); } catch (e) {} } resolve(window.YT); };
304 if (!document.querySelector('script[src*="youtube.com/iframe_api"]')) {
305 const s = document.createElement('script'); s.src = 'https://www.youtube.com/iframe_api'; s.async = true; document.head.appendChild(s);
306 }
307 setTimeout(() => { if (window.YT && window.YT.Player) resolve(window.YT); }, 8000);
308 });
309 return ytApiLoading;
310 }
311
312 function ensureYT() {
313 if (ytPlayer) return Promise.resolve(ytPlayer);
314 return loadYTApi().then((YT) => new Promise((resolve) => {
315 if (!YT || !YT.Player) { resolve(null); return; }
316 let host = document.getElementById('pcms-yt-host');
317 if (!host) {
318 host = document.createElement('div'); host.id = 'pcms-yt-host';
319 const m = document.createElement('div'); m.id = 'pcms-yt-mount'; host.appendChild(m);
320 root.appendChild(host);
321 }
322 ytPlayer = new YT.Player('pcms-yt-mount', {
323 host: 'https://www.youtube-nocookie.com',
324 playerVars: { controls: 0, playsinline: 1, rel: 0, modestbranding: 1, iv_load_policy: 3, origin: window.location.origin },
325 events: {
326 onReady: () => { ytReady = true; try { ytPlayer.setVolume(audio.muted ? 0 : Math.round(audio.volume * 100)); } catch (e) {} resolve(ytPlayer); },
327 onStateChange: onYTState,
328 },
329 });
330 setTimeout(() => resolve(ytPlayer), 8000);
331 }));
332 }
333
334 function onYTState(e) {
335 if (!ytMode) return;
336 const s = e.data; // 1 playing, 2 paused, 0 ended
337 if (s === 1) {
338 isPlaying = true; root.classList.add('is-playing'); root.classList.remove('audio-needs-tap');
339 // We startten gedempt (autoplay-policy) → nu 't speelt het geluid terugzetten,
340 // tenzij de gebruiker zelf gedempt heeft.
341 try { if (!audio.muted) { ytPlayer.unMute(); ytPlayer.setVolume(Math.round(audio.volume * 100)); } } catch (e) {}
342 mediaRegistry().setActive(registrySelf); startYTTick(); pullYTMeta();
343 } else if (s === 2) {
344 isPlaying = false; root.classList.remove('is-playing'); stopYTTick();
345 } else if (s === 0) {
346 stopYTTick();
347 // Bij een afspeellijst regelt YouTube zelf het doorschakelen → niet dubbel.
348 const t = queue[currentIndex];
349 if (!(t && t.ytList)) next();
350 }
351 }
352
353 function startYTTick() {
354 if (ytTick) return;
355 ytTick = setInterval(() => {
356 if (!ytPlayer || !ytMode) return;
357 try { updateProgressUI(ytPlayer.getCurrentTime() || 0, ytPlayer.getDuration() || 0); } catch (e) {}
358 }, 250);
359 }
360 function stopYTTick() { if (ytTick) { clearInterval(ytTick); ytTick = null; } }
361
362 function pullYTMeta() {
363 try {
364 const t = queue[currentIndex]; if (!t || (!t.yt && !t.ytList) || !ytPlayer.getVideoData) return;
365 const vd = ytPlayer.getVideoData() || {};
366 // Bij een afspeellijst wisselt de titel per nummer → altijd bijwerken.
367 if (vd.title && (t.ytList || !t.title || t.title === 'YouTube')) {
368 titleEl.textContent = vd.title; sheetTitle.textContent = vd.title;
369 if (!t.ytList) t.title = vd.title;
370 }
371 if (vd.video_id) {
372 setYtCover(vd.video_id);
373 markPlaying(vd.video_id); // licht de bijbehorende album-rij op (id="track-<videoId>")
374 }
375 } catch (e) {}
376 }
377
378 // Gedeelde voortgang-UI (beide backends). Audio gebruikt 'timeupdate', YT de tick.
379 function updateProgressUI(cur, dur) {
380 if (!dur || isNaN(dur)) return;
381 const pct = Math.max(0, Math.min(100, (cur / dur) * 100));
382 seekFill.style.width = pct + '%'; sheetSeekFill.style.width = pct + '%';
383 currentEl.textContent = formatTime(cur); totalEl.textContent = formatTime(dur);
384 sheetCurrent.textContent = formatTime(cur); sheetTotal.textContent = formatTime(dur);
385 }
386
387 function loadTrack(index, autoplay, metaOnly) {
388 if (!queue[index]) {
389 console.warn('[pcms-audio] loadTrack: no track at index', index);
390 return;
391 }
392 currentIndex = index;
393 const t = queue[index];
394 // Metadata + chrome update synchronously (geldt voor beide backends).
395 titleEl.textContent = t.title || (t.yt ? 'YouTube' : 'Untitled');
396 artistEl.textContent = t.artist || '';
397 sheetTitle.textContent = t.title || (t.yt ? 'YouTube' : 'Untitled');
398 sheetArtist.textContent = t.artist || '';
399 sheetAlbum.textContent = albumName || '';
400 setCoverImage(cover, t.cover);
401 setCoverImage(sheetCover, t.cover);
402 root.classList.remove('audio-player-hidden');
403 document.body.classList.add('has-audio-player');
404 renderQueue();
405 markPlaying(t.id);
406
407 if (metaOnly) return;
408
409 // YouTube-bron (los nummer óf afspeellijst/album) → verborgen YT-speler.
410 if (t.yt || t.ytList) {
411 ytMode = true;
412 try { audio.pause(); } catch (e) {}
413 loadSeq++; dropPreload();
414 updateProgressUI(0, 1); currentEl.textContent = '0:00'; totalEl.textContent = '0:00';
415 seekFill.style.width = '0%'; sheetSeekFill.style.width = '0%';
416 root.classList.add('audio-loading');
417 ensureYT().then((p) => {
418 if (queue[currentIndex] !== t) return; // inmiddels andere track gekozen
419 root.classList.remove('audio-loading');
420 if (!p) { onLoadError(new Error('YT API niet beschikbaar'), loadSeq); return; }
421 try {
422 // Gedempt starten = autoplay is ALTIJD toegestaan (YouTube blokkeert
423 // anders het geluid). onYTState ontdempt zodra 't echt speelt.
424 if (autoplay) { try { p.mute(); } catch (e) {} }
425 if (t.ytList) {
426 const idx = t.ytIndex || 0;
427 // listType:'playlist' is vereist voor de object-vorm; zonder dit laadt
428 // (en speelt) de afspeellijst niet betrouwbaar.
429 if (autoplay) p.loadPlaylist({ listType: 'playlist', list: t.ytList, index: idx });
430 else p.cuePlaylist({ listType: 'playlist', list: t.ytList, index: idx });
431 } else {
432 if (autoplay) p.loadVideoById(t.yt); else p.cueVideoById(t.yt);
433 }
434 p.setVolume(audio.muted ? 0 : Math.round(audio.volume * 100));
435 } catch (e) {}
436 });
437 return;
438 }
439
440 // Normale blob-audio track.
441 ytMode = false;
442 stopYTTick();
443 try { if (ytPlayer && ytPlayer.stopVideo) ytPlayer.stopVideo(); } catch (e) {}
444 if (!t.url) { console.error('[pcms-audio] track has no url', t); return; }
445
446 const mySeq = ++loadSeq;
447
448 // Fast path: the bytes for this exact track were already prefetched while
449 // the previous track played → swap in instantly, no gap, no fetch window.
450 if (preload && preload.url === t.url && preload.objUrl) {
451 const obj = preload.objUrl;
452 preload = null; // ownership moves to applyBlob (becomes currentObjectUrl)
453 applyBlob(obj, autoplay, mySeq);
454 return;
455 }
456
457 // Not preloaded (or preload still in flight) → drop any stale preload and
458 // fetch fresh, retrying transient failures before giving up.
459 dropPreload();
460 root.classList.add('audio-loading');
461 fetchAsObjectUrl(t.url, 3)
462 .then((objUrl) => applyBlob(objUrl, autoplay, mySeq))
463 .catch((err) => onLoadError(err, mySeq));
464 }
465
466 // True when the viewport is in the mobile sheet-layout — matches the CSS
467 // breakpoint where .audio-sheet slides up full-width from the bottom
468 // (@media max-width:719.98px). On wider/desktop widths the sheet is a centered
469 // panel, so we do NOT auto-open it there.
470 // Drie weergaves (Robin 2026-06-15): telefoon (<768) = fullscreen sheet;
471 // tablet/auto (768–1199) = grote landscape full-player (tablet + tablet-in-de-
472 // auto); desktop (≥1200) = alleen mini-speler, GEEN full player. matchMedia
473 // zodat dit exact de CSS-breekpunten volgt.
474 function playerTier() {
475 if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop';
476 if (window.matchMedia('(min-width: 768px)').matches) return 'tablet';
477 return 'phone';
478 }
479 function hasFullPlayer() { return playerTier() !== 'desktop'; }
480 function isMobileView() { return playerTier() === 'phone'; }
481
482 function setQueue(tracks, startIdx, opts) {
483 queue = Array.isArray(tracks) ? tracks.slice() : [];
484 albumName = (opts && opts.albumName) || '';
485 if (!queue.length) return;
486 loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
487 // Mobile: a track press from an album/playlist auto-opens the full
488 // now-playing sheet (Spotify-style) i.p.v. alleen de dunne mini-strip.
489 // Alleen hier (setQueue = een verse, door-de-gebruiker-gestarte queue) —
490 // niet bij next/prev of de site-pre-seed — zodat een sheet die de gebruiker
491 // bewust sloot niet vanzelf terugkomt.
492 if (hasFullPlayer()) openSheet();
493 }
494
495 function play() {
496 if (ytMode) {
497 if (ytPlayer && ytReady) { try { ytPlayer.playVideo(); } catch (e) {} }
498 else if (queue[currentIndex]) loadTrack(currentIndex, true);
499 return;
500 }
501 if (!audio.src) {
502 // Nothing fetched yet (pre-seed showed metadata only, or a load is still
503 // in flight). Kick off the blob load for the current track and autoplay.
504 if (queue[currentIndex]) loadTrack(currentIndex, true);
505 return;
506 }
507 const p = audio.play();
508 if (p && typeof p.catch === 'function') {
509 p.catch((err) => {
510 console.warn('[pcms-audio] play() rejected:', err.name, err.message);
511 // Browser-autoplay-policy heeft 't gestopt (typisch na 3-4
512 // automatische plays op iOS Safari, of als tab tijdelijk inactive
513 // was). Visuele hint dat user op play moet tappen.
514 if (err && err.name === 'NotAllowedError') {
515 root.classList.add('audio-needs-tap');
516 isPlaying = false;
517 root.classList.remove('is-playing');
518 }
519 });
520 }
521 }
522 function pause() { if (ytMode) { try { ytPlayer && ytPlayer.pauseVideo(); } catch (e) {} return; } audio.pause(); }
523 function togglePlay() { if (ytMode) { isPlaying ? pause() : play(); return; } audio.paused ? play() : pause(); }
524 function next() {
525 const t = queue[currentIndex];
526 if (t && t.ytList && ytPlayer && ytMode) { try { ytPlayer.nextVideo(); } catch (e) {} return; }
527 if (!queue.length) return;
528 loadTrack((currentIndex + 1) % queue.length, true);
529 }
530 function prev() {
531 const t = queue[currentIndex];
532 if (t && t.ytList && ytPlayer && ytMode) { try { ytPlayer.previousVideo(); } catch (e) {} return; }
533 if (!queue.length) return;
534 loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
535 }
536 function close() {
537 pause();
538 mediaRegistry().release(registrySelf);
539 root.classList.add('audio-player-hidden');
540 document.body.classList.remove('has-audio-player');
541 closeSheet();
542 ytMode = false; stopYTTick();
543 try { if (ytPlayer && ytPlayer.stopVideo) ytPlayer.stopVideo(); } catch (e) {}
544 queue = [];
545 albumName = '';
546 loadSeq++; // cancel any in-flight load
547 dropPreload(); // free any prefetched next-track blob
548 if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
549 currentObjectUrl = null;
550 try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
551 }
552
553 function renderQueue() {
554 if (!queue.length) { sheetQueue.hidden = true; return; }
555 sheetQueueList.innerHTML = '';
556 queue.forEach((t, i) => {
557 const li = document.createElement('li');
558 li.className = 'audio-sheet-queue-item' + (i === currentIndex ? ' is-current' : '');
559 li.dataset.idx = String(i);
560 li.innerHTML = `<span class="aqi-num">${i + 1}.</span> <span class="aqi-title">${escapeHtml(t.title || 'Untitled')}</span>`
561 + (t.artist ? `<span class="aqi-artist">${escapeHtml(t.artist)}</span>` : '');
562 sheetQueueList.appendChild(li);
563 });
564 sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
565 li.addEventListener('click', () => {
566 const idx = parseInt(li.dataset.idx, 10);
567 if (!isNaN(idx) && idx !== currentIndex) {
568 loadTrack(idx, true);
569 }
570 });
571 });
572 sheetQueue.hidden = queue.length < 2;
573 }
574
575 function escapeHtml(s) {
576 return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
577 }
578
579 // ============================================================
580 // 4b. Mutual exclusion — gedeelde media-registry (zie embed-player.js).
581 // ============================================================
582 // Alle spelers (deze site-speler + de YouTube/SoundCloud/Spotify-embeds)
583 // registreren zich in window.pcmsMediaRegistry. Start er één, dan pauzeert de
584 // vorige. Dit is de precieze vervanger van de oude focus/blur-heuristiek voor
585 // de embeds met een echte JS-API. (De blur-fallback hieronder blijft staan
586 // voor iframe-only embeds zonder API: Bandcamp/Apple Music/Vimeo.)
587 function mediaRegistry() {
588 if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
589 const r = {
590 _active: null,
591 setActive(player) {
592 if (this._active && this._active !== player && this._active.pause) {
593 try { this._active.pause(); } catch (e) {}
594 }
595 this._active = player;
596 },
597 release(player) { if (this._active === player) this._active = null; },
598 };
599 window.pcmsMediaRegistry = r;
600 return r;
601 }
602 const registrySelf = { pause() { try { audio.pause(); } catch (e) {} try { if (ytPlayer && ytPlayer.pauseVideo) ytPlayer.pauseVideo(); } catch (e) {} } };
603
604 // ============================================================
605 // 5. Audio element events → UI sync
606 // ============================================================
607 // Error-counter voorkomt infinite-loop als ALLE tracks broken zijn.
608 let consecutiveErrors = 0;
609
610 audio.addEventListener('play', () => {
611 isPlaying = true;
612 root.classList.add('is-playing');
613 root.classList.remove('audio-needs-tap'); // verstop tap-hint
614 mediaRegistry().setActive(registrySelf); // pauzeer eventueel spelende embeds
615 });
616 // Reset de error-teller pas bij ECHTE playback-start (`playing`), niet bij
617 // het eager `play`-event. `play` vuurt vóór een eventuele netwerk-/decode-
618 // fout, dus resetten daar zou de 3-strikes-stop nooit laten triggeren bij
619 // een kapotte track → infinite "next"-loop. `playing` vuurt alleen als er
620 // daadwerkelijk audio speelt.
621 audio.addEventListener('playing', () => { consecutiveErrors = 0; preloadNext(); });
622 audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
623 audio.addEventListener('ended', next);
624 audio.addEventListener('error', (e) => {
625 const code = audio.error ? audio.error.code : '?';
626 console.error('[pcms-audio] playback error', code, audio.src, e);
627 consecutiveErrors++;
628 // Bij netwerk/decode-fout: skip naar volgende track ipv stilstaan.
629 // Max 3 fouten op rij voordat we opgeven (anders infinite loop).
630 if (consecutiveErrors < 3 && queue.length > 1) {
631 console.warn('[pcms-audio] auto-skip naar volgende na error', consecutiveErrors);
632 setTimeout(next, 400);
633 }
634 });
635 audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled at', audio.currentTime));
636 audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
637 audio.addEventListener('timeupdate', () => {
638 if (ytMode) return; // YT-voortgang loopt via de tick
639 updateProgressUI(audio.currentTime, audio.duration);
640 });
641
642 function formatTime(s) {
643 if (!s || isNaN(s)) return '0:00';
644 const m = Math.floor(s / 60), sec = Math.floor(s % 60);
645 return m + ':' + (sec < 10 ? '0' : '') + sec;
646 }
647
648 function attachSeek(seekEl) {
649 seekEl.addEventListener('click', (e) => {
650 const rect = seekEl.getBoundingClientRect();
651 const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
652 if (ytMode) {
653 try { const d = ytPlayer.getDuration() || 0; if (d) ytPlayer.seekTo(ratio * d, true); } catch (e2) {}
654 return;
655 }
656 if (!audio.duration) return;
657 audio.currentTime = ratio * audio.duration;
658 });
659 }
660 attachSeek(seek);
661 attachSeek(sheetSeek);
662
663 audio.volume = 0.8;
664 volumeSlider.addEventListener('input', () => {
665 const v = parseInt(volumeSlider.value, 10) || 0;
666 audio.volume = v / 100;
667 if (v > 0) audio.muted = false;
668 if (ytPlayer) { try { ytPlayer.setVolume(v); if (v > 0 && ytPlayer.unMute) ytPlayer.unMute(); } catch (e) {} }
669 });
670 muteBtn.addEventListener('click', () => {
671 audio.muted = !audio.muted;
672 if (ytPlayer) { try { audio.muted ? ytPlayer.mute() : ytPlayer.unMute(); } catch (e) {} }
673 root.classList.toggle('is-muted', audio.muted);
674 });
675
676 // ============================================================
677 // 6. Control wiring
678 // ============================================================
679 playBtn.addEventListener('click', togglePlay);
680 prevBtn.addEventListener('click', prev);
681 nextBtn.addEventListener('click', next);
682 sheetPlay.addEventListener('click', togglePlay);
683 sheetPrev.addEventListener('click', prev);
684 sheetNext.addEventListener('click', next);
685
686 // ============================================================
687 // 7. Sheet expand/close + drag-down-to-close
688 // ============================================================
689 // Back-knop sluit de sheet op mobiel: bij openen pushen we een history-entry,
690 // zodat de telefoon-terugknop (popstate) eerst de sheet sluit i.p.v. de pagina
691 // te verlaten. We balanceren 'm bij een UI-sluiting via history.back().
692 let sheetHistoryPushed = false;
693
694 function openSheet() {
695 if (!hasFullPlayer()) return; // desktop (≥1200): geen full player, alleen mini-speler
696 if (sheet.classList.contains('is-open')) return;
697 sheet.classList.add('is-open');
698 sheet.setAttribute('aria-hidden', 'false');
699 document.body.classList.add('audio-sheet-locked');
700 // Telefoon + tablet: history-entry zodat de terug-knop eerst de sheet sluit.
701 try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {}
702 }
703 function closeSheet(fromPopstate) {
704 if (!sheet.classList.contains('is-open')) return;
705 sheet.classList.remove('is-open');
706 sheet.setAttribute('aria-hidden', 'true');
707 document.body.classList.remove('audio-sheet-locked');
708 sheetPanel.style.removeProperty('--pcms-drag-y');
709 sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
710 // UI-sluiting (X / swipe / backdrop / Esc): pop onze eigen history-entry zodat
711 // de volgende terug-knop weer normaal navigeert. Bij een popstate-sluiting
712 // (de terug-knop zelf) is de entry al gepopt.
713 const wasPushed = sheetHistoryPushed;
714 sheetHistoryPushed = false;
715 if (wasPushed && !fromPopstate) { try { history.back(); } catch (e) {} }
716 }
717 window.addEventListener('popstate', () => {
718 if (sheet.classList.contains('is-open')) closeSheet(true);
719 });
720 // Mini-player-info aanklikken:
721 // - DESKTOP (≥768px): spring naar de post waar de track vandaan komt (als bekend),
722 // via htmx zodat de audio blijft spelen. Geen post bekend → val terug op de sheet.
723 // - MOBIEL: altijd de uitgebreide speler-sheet openen (huidig gedrag).
724 function scrollToTrack(trackId) {
725 if (!trackId) { window.scrollTo(0, 0); return; }
726 const el = document.getElementById('track-' + trackId);
727 if (!el) { window.scrollTo(0, 0); return; }
728 el.scrollIntoView({ block: 'center', behavior: 'smooth' });
729 el.classList.add('pat-flash');
730 setTimeout(() => el.classList.remove('pat-flash'), 1600);
731 }
732 function goToPost(url, trackId) {
733 const hash = trackId ? ('#track-' + trackId) : '';
734 if (window.htmx && url.charAt(0) === '/') {
735 try {
736 const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' });
737 history.pushState({}, '', url + hash);
738 // Na de swap naar de track scrollen (klein uitstel zodat de globale
739 // afterSwap-top-scroll eerst is geweest); val terug op top als niet gevonden.
740 const go = () => setTimeout(() => scrollToTrack(trackId), 60);
741 if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150);
742 return;
743 } catch (e) { /* val terug op volledige navigatie */ }
744 }
745 location.href = url + hash;
746 }
747 expandTrigger.addEventListener('click', () => {
748 const t = queue[currentIndex];
749 // Alleen op échte desktop (≥1200, geen full-player) springen we naar de post.
750 // Tablet + telefoon hebben een full-player → open die (zoals mobiel).
751 const jumpToPost = !hasFullPlayer();
752 if (jumpToPost && t) {
753 // 1) Track speelde vanuit een post → die URL kennen we al.
754 if (t.postUrl) { goToPost(t.postUrl, t.id); return; }
755 // 2) Site-brede track (geen postUrl) → zoek de post op via de track-id.
756 if (t.id) {
757 fetch('/audio/track/' + encodeURIComponent(t.id) + '/post')
758 .then((r) => (r.ok ? r.json() : null))
759 .then((d) => { if (d && d.url) goToPost(d.url, t.id); else openSheet(); })
760 .catch(() => openSheet());
761 return;
762 }
763 }
764 openSheet();
765 });
766 sheetClose.addEventListener('click', () => closeSheet());
767 sheetBackdrop.addEventListener('click', () => closeSheet());
768 document.addEventListener('keydown', (e) => {
769 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
770 });
771 // Naar desktop-breedte (≥1200) gesleept terwijl de full player open is? Sluiten —
772 // op desktop bestaat de full player niet.
773 window.addEventListener('resize', () => {
774 if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet();
775 });
776
777 // Vangnet voor mutual exclusion. Voor YouTube/SoundCloud/Spotify-embeds doet de
778 // registry dit al precies (echte play-events). Maar voor iframe-only embeds
779 // ZONDER JS-API (Bandcamp/Apple/Vimeo) én voor de iframe-FALLBACK (als een
780 // ad-blocker de player-API blokkeert) is er geen play-event: daar vangen we het
781 // af via focus. Klikt de gebruiker zo'n iframe aan → window 'blur' → pauzeer
782 // onze speler. (Voor de API-embeds is dit hooguit een onschadelijke dubbele
783 // pauze.)
784 window.addEventListener('blur', () => {
785 setTimeout(() => {
786 const el = document.activeElement;
787 // Alleen embed-iframes (binnen .folio-embed) pauzeren de speler — niet een
788 // willekeurig iframe (captcha/reclame/kaart) dat per ongeluk focus krijgt.
789 if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
790 pause();
791 }
792 }, 0);
793 });
794
795 // Drag-down-to-close on touch devices.
796 //
797 // We set --pcms-drag-y as a CSS custom prop instead of writing
798 // sheetPanel.style.transform directly. The reason: on desktop the panel
799 // uses `transform: translate(-50%, 0)` for horizontal centering. Writing
800 // `style.transform = translateY(...)` would obliterate the -50% and the
801 // panel would jump rightward. With a custom prop, audio.css composes the
802 // final transform per breakpoint:
803 // mobile: transform: translateY(var(--pcms-drag-y, 0))
804 // desktop: transform: translate(-50%, var(--pcms-drag-y, 0))
805 let dragStartY = 0, dragLastY = 0, isDragging = false;
806 function onPointerDown(e) {
807 if (e.pointerType !== 'touch') return;
808 if (sheetPanel.scrollTop > 0) return; // queue is scrolled, don't drag
809 dragStartY = dragLastY = e.clientY;
810 isDragging = true;
811 sheetPanel.classList.add('is-dragging');
812 sheetBackdrop.classList.add('is-dragging');
813 }
814 function onPointerMove(e) {
815 if (!isDragging) return;
816 dragLastY = e.clientY;
817 const dy = Math.max(0, dragLastY - dragStartY);
818 sheetPanel.style.setProperty('--pcms-drag-y', dy + 'px');
819 const progress = Math.max(0, 1 - dy / sheetPanel.offsetHeight);
820 sheetBackdrop.style.setProperty('--pcms-sheet-progress', String(progress));
821 }
822 function onPointerUp() {
823 if (!isDragging) return;
824 isDragging = false;
825 sheetPanel.classList.remove('is-dragging');
826 sheetBackdrop.classList.remove('is-dragging');
827 const dy = dragLastY - dragStartY;
828 if (dy > 100) {
829 closeSheet();
830 } else {
831 sheetPanel.style.removeProperty('--pcms-drag-y');
832 sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
833 }
834 }
835 if (window.PointerEvent) {
836 dragZone.addEventListener('pointerdown', onPointerDown);
837 document.addEventListener('pointermove', onPointerMove);
838 document.addEventListener('pointerup', onPointerUp);
839 document.addEventListener('pointercancel', onPointerUp);
840 }
841
842 // ============================================================
843 // 8. Hook up post-audio-track + post-album-cover-btn + .pat-row + .post-album-playall
844 // ============================================================
845 // Four entry points fire the same play action:
846 // - .pat-play → single-track widget in a post
847 // - .pat-row → track row inside an album/playlist tracklist
848 // - .post-album-cover-btn → big cover button (plays album from track 0)
849 // - .post-album-playall → "Speel album" / "Speel playlist" button
850 //
851 // For .pat-play the metadata lives on the surrounding .post-audio-track
852 // wrapper. For the other three the data is on the button itself. The
853 // handler reads from button-first, falls back to wrapper.
854 // Event-delegation op document.body i.p.v. per-knop listeners. Dit overleeft
855 // HTMX history-restores: de mobiele terug-knop (popstate) laat HTMX #pcms-main
856 // terugzetten uit z'n snapshot; een per-element `data-pcms-attached`-vlag zou
857 // dan dode knoppen geven (vlag ingebakken in de snapshot, listener weg). Eén
858 // gedelegeerde listener werkt ongeacht hoe vaak de DOM ge(her)swapt wordt.
859 const PLAY_SELECTOR =
860 '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall';
861 document.body.addEventListener('click', (e) => {
862 const btn = e.target.closest(PLAY_SELECTOR);
863 if (!btn) return;
864 e.preventDefault();
865 e.stopPropagation();
866 // De post waar je VANAF afspeelt = de pagina waar je nu bent (de embeds staan
867 // in de post-content). Bewaar 'm op de track(s) zodat de mini-player er op
868 // desktop naartoe kan springen — overleeft ook de sessionStorage-resume.
869 const postUrl = location.pathname + location.search;
870 // Resolve metadata: button-first, then closest .post-audio-track wrapper
871 // (only inline single-track widgets put the data on the wrapper).
872 const wrapper = btn.closest('.post-audio-track');
873 const albumId = btn.dataset.pcmsAlbumId || (wrapper && wrapper.dataset.pcmsAlbumId);
874 const trackData = btn.dataset.pcmsTrack || (wrapper && wrapper.dataset.pcmsTrack);
875 const trackUrl = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
876 console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
877
878 if (albumId) {
879 const album = document.getElementById(albumId);
880 if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
881 try {
882 const tracks = JSON.parse(album.dataset.pcmsAlbum);
883 tracks.forEach((t) => { t.postUrl = postUrl; });
884 // Start at the clicked track if we know its URL, else start at 0
885 // (cover-btn and playall both want to start from the beginning).
886 const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
887 setQueue(tracks, startIdx >= 0 ? startIdx : 0, { albumName: album.dataset.pcmsAlbumTitle || '' });
888 } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
889 } else if (trackData) {
890 try {
891 const t = JSON.parse(trackData);
892 t.postUrl = postUrl;
893 setQueue([t], 0);
894 } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
895 } else if (trackUrl) {
896 // Fallback: at minimum we have the signed URL
897 setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '', postUrl }], 0);
898 } else {
899 console.error('[pcms-audio] no track data or url on button or wrapper', btn);
900 }
901 });
902
903 // ============================================================
904 // 8b. Admin playlist delete (event delegation)
905 // ============================================================
906 // The post-album embed renders a [data-pcms-playlist-delete] button
907 // top-right when the viewer is admin (server decides; not client).
908 // We delegate from document.body so HTMX-swapped content works too.
909 document.body.addEventListener('click', async (e) => {
910 const btn = e.target.closest('[data-pcms-playlist-delete]');
911 if (!btn) return;
912 e.preventDefault();
913 e.stopPropagation();
914 const id = btn.dataset.pcmsPlaylistDelete;
915 const title = btn.dataset.pcmsPlaylistTitle || id;
916 if (!id) return;
917 if (!confirm(`Playlist "${title}" verwijderen? De embed in deze post toont vanaf nu een placeholder.`)) return;
918 btn.disabled = true;
919 try {
920 const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
921 method: 'POST',
922 headers: { 'X-CSRF-Token': '' },
923 credentials: 'same-origin',
924 });
925 const j = await r.json().catch(() => ({}));
926 if (j && j.ok) {
927 location.reload();
928 } else {
929 alert('Verwijderen mislukt: ' + ((j && j.error) || 'onbekende fout'));
930 btn.disabled = false;
931 }
932 } catch (err) {
933 alert('Verwijderen mislukt: ' + err.message);
934 btn.disabled = false;
935 }
936 });
937
938 // ============================================================
939 // 9. Public API
940 // ============================================================
941 // Speel een YouTube-bron als AUDIO af in deze (persistente) site-speler — door
942 // de embed-speler aangeroepen voor een audio-only YouTube-embed. Zo verschijnt
943 // 'ie in de onderbalk én speelt 'ie door bij navigeren.
944 function playYouTube(opts) {
945 if (!opts || (!opts.id && !opts.list)) return;
946 setQueue([{
947 yt: opts.id || null,
948 ytList: opts.list || null, // YouTube-afspeellijst/album (OLAK…/PL…)
949 ytIndex: opts.index || 0, // startindex binnen de lijst
950 title: opts.title || 'YouTube',
951 artist: opts.artist || '',
952 cover: opts.cover || '',
953 postUrl: opts.postUrl || '',
954 }], 0);
955 // De scherpe cover + echte titel komen via pullYTMeta() zodra het nummer speelt.
956 }
957
958 // Zet de bar-cover op de thumbnail van een video (mqdefault = balkloos), en
959 // upgrade async naar de scherpe maxresdefault als die bestaat.
960 function setYtCover(videoId) {
961 if (!videoId) return;
962 const mq = 'https://i.ytimg.com/vi/' + videoId + '/mqdefault.jpg';
963 setCoverImage(cover, mq); setCoverImage(sheetCover, mq);
964 try {
965 const maxres = 'https://i.ytimg.com/vi/' + videoId + '/maxresdefault.jpg';
966 const im = new Image();
967 im.onload = () => {
968 if (im.naturalWidth <= 120) return;
969 const cur = ytPlayer && ytPlayer.getVideoData ? (ytPlayer.getVideoData() || {}).video_id : null;
970 if (cur === videoId) { setCoverImage(cover, maxres); setCoverImage(sheetCover, maxres); }
971 };
972 im.src = maxres;
973 } catch (e) {}
974 }
975
976 // Speel een YouTube-AFSPEELLIJST af door 'm te expanderen naar onze eigen queue
977 // van losse YouTube-tracks (elk via loadVideoById = bewezen betrouwbaar; next/
978 // prev/ended lopen via onze queue). items = [{yt,title,cover,postUrl}, …].
979 function playYouTubeList(items, startIndex) {
980 if (!Array.isArray(items) || !items.length) return;
981 setQueue(items.map((it) => ({
982 yt: it.yt,
983 title: it.title || 'YouTube',
984 artist: it.artist || '',
985 cover: it.cover || '',
986 postUrl: it.postUrl || '',
987 })), startIndex || 0);
988 }
989
990 // Maak de verborgen YT-speler vast aan (zonder iets te spelen), zodat 'ie bij de
991 // eerste klik al klaar staat → loadVideoById valt dan binnen de user-gesture en
992 // YouTube's autoplay-policy blokkeert het niet.
993 function prewarmYouTube() { try { ensureYT(); } catch (e) {} }
994
995 window.pcmsAudioPlayer = {
996 setQueue, play, pause, next, prev, close, openSheet, closeSheet, playYouTube, playYouTubeList, prewarmYouTube,
997 isPlaying: () => isPlaying,
998 currentTrack: () => queue[currentIndex] || null,
999 };
1000
1001 // ============================================================
1002 // 10. Sessie-persistentie — speler "blijft" over page-navigaties heen
1003 // ============================================================
1004 // Een <audio> overleeft geen volledige page-load (en cross-context navigatie
1005 // — bv. naar de headerloze hub-overview — is bewust full-nav). We bewaren de
1006 // sessie in sessionStorage en herstellen + hervatten 'm op de volgende pagina:
1007 // de speler staat er weer met dezelfde track op dezelfde positie. In Chrome
1008 // (hoge media-engagement) speelt 'ie meteen door; staat de browser autoplay
1009 // niet toe, dan staat 'ie klaar op die plek (één tik = verder).
1010 const PLAYER_STATE_KEY = 'pcms-player-state';
1011 let pendingSeek = 0;
1012 function savePlayerState() {
1013 try {
1014 if (!queue.length) { sessionStorage.removeItem(PLAYER_STATE_KEY); return; }
1015 sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
1016 queue, currentIndex, albumName,
1017 time: audio.currentTime || 0,
1018 playing: !!audio.src && !audio.paused,
1019 }));
1020 } catch (e) {}
1021 }
1022 window.addEventListener('pagehide', savePlayerState);
1023 window.addEventListener('beforeunload', savePlayerState);
1024 audio.addEventListener('play', savePlayerState);
1025 audio.addEventListener('pause', savePlayerState);
1026 audio.addEventListener('ended', savePlayerState);
1027 setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000);
1028 // Herstelde positie toepassen zodra de track-metadata binnen is.
1029 audio.addEventListener('loadedmetadata', () => {
1030 if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) {
1031 try { audio.currentTime = Math.min(pendingSeek, audio.duration - 0.25); } catch (e) {}
1032 pendingSeek = 0;
1033 }
1034 });
1035 function restorePlayerState() {
1036 let s = null;
1037 try { s = JSON.parse(sessionStorage.getItem(PLAYER_STATE_KEY) || 'null'); } catch (e) { return false; }
1038 if (!s || !Array.isArray(s.queue) || !s.queue.length) return false;
1039 queue = s.queue;
1040 albumName = s.albumName || '';
1041 pendingSeek = s.time || 0;
1042 const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
1043 // playing -> fetch + (poging tot) hervatten; gepauzeerd -> alleen metadata.
1044 loadTrack(idx, !!s.playing, !s.playing);
1045 return true;
1046 }
1047
1048 // ============================================================
1049 // 11. Site-level pre-seed (window.PCMS_SITE_TRACKS)
1050 // ============================================================
1051 // Een actieve sessie (restore) wint van de pagina-seed, zodat lopende muziek
1052 // doorgaat i.p.v. vervangen te worden door de tracks van de nieuwe pagina.
1053 if (!restorePlayerState()) {
1054 if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
1055 queue = window.PCMS_SITE_TRACKS.map(t => ({
1056 id: t.id || null,
1057 url: t.media_url || t.url,
1058 title: t.title || 'Untitled',
1059 artist: t.artist || '',
1060 cover: t.cover_url || t.cover || '',
1061 }));
1062 if (queue.length) loadTrack(0, false, true); // metadata only — fetch on first play
1063 }
1064 }
1065})();
Note: See TracBrowser for help on using the repository browser.