source: Klonkt/src/assets/js/embed-player.js@ d43ea3f

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

feat(embed): YouTube playlist/album as audio on the site

An audio-only YouTube embed with ?list=… now renders as an album card (same
style as own albums): cover + album name + numbered track list (titles via the
oEmbed proxy; video IDs via getPlaylist). Clicking a track or the album art
plays via the site audio player (loadPlaylist on index → next/prev within the
album; title/cover update per track; row highlight via #track-<videoId>).
Duration per track is empty (—:—) — not available without a YouTube Data API key.
busters audio-player.js?v=23, embed-player.js?v=12, embed.css?v=7.

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

  • Property mode set to 100644
File size: 32.9 KB
Line 
1/**
2 * Klonkt Embed Player — eigen, in-huisstijl media-embeds bovenop de ÉCHTE
3 * player-API's (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API).
4 *
5 * De server (AudioEmbedService) rendert per embed een placeholder:
6 * <div class="folio-embed folio-embed--<provider> pcms-embed-card"
7 * data-embed-provider data-embed-ref data-embed-type data-embed-url></div>
8 * Dit script bouwt daar onze eigen kaart omheen (cover/poster + onze play-knop +
9 * voortgangsbalk in huisstijl) en bestuurt de onderliggende speler via de
10 * platform-API, zodat play/pause/voortgang in ÓNZE handen liggen.
11 *
12 * Capability-eerlijkheid:
13 * - youtube/soundcloud → volledig eigen controls (native chrome verborgen).
14 * - spotify → besturing + onze frame eromheen; Spotify's eigen UI
15 * blijft binnenin (kan niet anders zonder Premium+OAuth).
16 *
17 * Mutual exclusion: elke speler (incl. de site-audiospeler in audio-player.js)
18 * registreert zich in window.pcmsMediaRegistry. Start er één → de vorige pauzeert.
19 * Dit vervangt de oude focus/blur-heuristiek door echte play-events.
20 *
21 * Singleton — guard tegen dubbel-init (HTMX laadt scripts soms opnieuw).
22 */
23(function () {
24 if (window.pcmsEmbedPlayer) return;
25
26 // ============================================================
27 // 0. Gedeelde playback-registry (ook door audio-player.js gebruikt)
28 // ============================================================
29 function registry() {
30 if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
31 const r = {
32 _active: null,
33 // Markeer `player` als de enige actieve; pauzeer de vorige.
34 setActive(player) {
35 if (this._active && this._active !== player && this._active.pause) {
36 try { this._active.pause(); } catch (e) {}
37 }
38 this._active = player;
39 },
40 release(player) { if (this._active === player) this._active = null; },
41 };
42 window.pcmsMediaRegistry = r;
43 return r;
44 }
45
46 // ============================================================
47 // 1. Lazy script-loaders — 1 promise per platform, gedeeld over N embeds.
48 // We laden een platform-script PAS als er een embed van dat platform op de
49 // pagina daadwerkelijk wordt gestart.
50 // ============================================================
51 const scripts = {};
52 function loadScript(src) {
53 return new Promise((resolve, reject) => {
54 const s = document.createElement('script');
55 s.src = src; s.async = true;
56 s.onload = () => resolve();
57 s.onerror = () => reject(new Error('embed-script faalde: ' + src));
58 document.head.appendChild(s);
59 });
60 }
61 // Belangrijk: alle loaders REJECTEN bij een script-fout (bv. een ad-blocker die
62 // het API-script blokkeert) én na een time-out — zodat de aanroeper netjes kan
63 // terugvallen op het kale platform-iframe i.p.v. eeuwig te hangen.
64 const API_TIMEOUT = 8000;
65
66 // YouTube: globale callback onYouTubeIframeAPIReady (1×) → in promise wikkelen.
67 function ytApi() {
68 if (scripts.yt) return scripts.yt;
69 scripts.yt = new Promise((resolve, reject) => {
70 if (window.YT && window.YT.Player) return resolve(window.YT);
71 const prev = window.onYouTubeIframeAPIReady;
72 window.onYouTubeIframeAPIReady = function () {
73 if (typeof prev === 'function') { try { prev(); } catch (e) {} }
74 resolve(window.YT);
75 };
76 loadScript('https://www.youtube.com/iframe_api').catch(reject);
77 setTimeout(() => reject(new Error('YT API timeout')), API_TIMEOUT);
78 });
79 return scripts.yt;
80 }
81 // SoundCloud: geen globale ready-callback; resolve op script-onload, daarna
82 // wacht elke widget op z'n eigen SC.Widget.Events.READY.
83 function scApi() {
84 if (scripts.sc) return scripts.sc;
85 scripts.sc = new Promise((resolve, reject) => {
86 if (window.SC && window.SC.Widget) return resolve(window.SC);
87 loadScript('https://w.soundcloud.com/player/api.js')
88 .then(() => resolve(window.SC)).catch(reject);
89 setTimeout(() => { (window.SC && window.SC.Widget) ? resolve(window.SC) : reject(new Error('SC API timeout')); }, API_TIMEOUT);
90 });
91 return scripts.sc;
92 }
93 // Spotify: globale callback onSpotifyIframeApiReady(IFrameAPI) (1×) → wrappen.
94 function spotifyApi() {
95 if (scripts.sp) return scripts.sp;
96 scripts.sp = new Promise((resolve, reject) => {
97 if (window.__spotifyIframeApi) return resolve(window.__spotifyIframeApi);
98 window.onSpotifyIframeApiReady = function (IFrameAPI) {
99 window.__spotifyIframeApi = IFrameAPI;
100 resolve(IFrameAPI);
101 };
102 loadScript('https://open.spotify.com/embed/iframe-api/v1').catch(reject);
103 // Korter dan API_TIMEOUT: Spotify's bundle initialiseert snel óf helemaal
104 // niet (CDN 503 / origin-gating). Niet 8s wachten vóór de iframe-fallback.
105 setTimeout(() => reject(new Error('Spotify API timeout')), 4000);
106 });
107 return scripts.sp;
108 }
109
110 // Kaal platform-iframe als fallback wanneer de JS-API geblokkeerd/onbereikbaar
111 // is. Ad-blockers laten de embed-iframes doorgaans wél door. Autoplay-param
112 // omdat we hier altijd in een user-gesture-context zitten (klik op play).
113 function fallbackIframe(provider, ref, url) {
114 if (provider === 'youtube') {
115 const id = ytId(ref, url);
116 return { src: 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) + '?autoplay=1&rel=0', ratio: true, fs: true };
117 }
118 if (provider === 'soundcloud') {
119 return { src: 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(ref || url) + '&auto_play=true&visual=true&hide_related=true', h: '300px' };
120 }
121 if (provider === 'spotify') {
122 const m = (ref || '').match(/^spotify:(\w+):(\w+)$/);
123 return { src: m ? `https://open.spotify.com/embed/${m[1]}/${m[2]}` : (url || ''), h: '152px' };
124 }
125 return { src: url || '' };
126 }
127
128 // ============================================================
129 // 2. Helpers
130 // ============================================================
131 function fmt(sec) {
132 if (!sec || isNaN(sec) || sec < 0) return '0:00';
133 const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
134 return m + ':' + (s < 10 ? '0' : '') + s;
135 }
136 function ytId(ref, url) {
137 if (ref && /^[A-Za-z0-9_-]{11}$/.test(ref)) return ref;
138 const m = (url || '').match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
139 if (m) return m[1];
140 return null; // geen blinde slice — een ongeldige ref geeft liever niets dan een kapotte id
141 }
142 // Alleen http(s) als href toelaten (defense-in-depth tegen javascript:/data:).
143 function safeHref(u) {
144 try { const p = new URL(u, location.href); return (p.protocol === 'http:' || p.protocol === 'https:') ? u : '#'; }
145 catch (e) { return '#'; }
146 }
147 const ICON = {
148 play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>',
149 pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4h4v16H7zM13 4h4v16h-4z" fill="currentColor"/></svg>',
150 volume: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor"/><path d="M16 8a5 5 0 010 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
151 muted: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor"/><path d="M16 9l5 6M21 9l-5 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
152 };
153 const LABEL = { youtube: 'YouTube', soundcloud: 'SoundCloud', spotify: 'Spotify' };
154
155 // ============================================================
156 // 3. Card controller — bouwt de huisstijl-kaart + delegeert naar een adapter
157 // ============================================================
158 function buildCard(el) {
159 const provider = el.dataset.embedProvider;
160 const ref = el.dataset.embedRef || '';
161 const url = el.dataset.embedUrl || '';
162 if (!provider) return;
163
164 // Audio-only YouTube met een afspeellijst (?list=…) → album-kaart met tracklijst.
165 if (provider === 'youtube' && el.dataset.embedMode === 'audio') {
166 const lm = url.match(/[?&]list=([A-Za-z0-9_-]+)/);
167 if (lm) { try { buildYtAlbum(el, lm[1]); } catch (e) { console.error('[pcms-embed] yt-album faalde', e); } return; }
168 }
169
170 el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider);
171 el.classList.remove('pcms-embed-loading');
172
173 // Onze speler-zijde (registry-peer). pause() wijst naar de adapter zodra die
174 // gemount is; ervoor is 't een no-op.
175 let adapter = null;
176 const self = { pause() { if (adapter && adapter.pause) { try { adapter.pause(); } catch (e) {} } } };
177
178 // Teardown-hook: aangeroepen door de MutationObserver als deze kaart uit de
179 // DOM verdwijnt (HTMX-swap) → adapter opruimen (timers/iframes) + registry
180 // vrijgeven, zodat er geen poll-timers of spelers blijven lekken.
181 el._pcmsDestroy = function () {
182 try { if (adapter && adapter.destroy) adapter.destroy(); } catch (e) {}
183 registry().release(self);
184 };
185
186 let playing = false;
187 let mounted = false;
188 let dur = 0;
189
190 // --- UI ophangen (verschilt per provider-type) ---
191 // Audio-only YouTube (data-embed-mode="audio"): render als audio-kaart met
192 // onze controls; de YT-speler draait verborgen (off-screen) en levert alleen
193 // het geluid. Video blijft de default.
194 const audioOnly = provider === 'youtube' && el.dataset.embedMode === 'audio';
195 if (audioOnly) el.classList.add('pcms-embed-audio-mode');
196 const isVideo = provider === 'youtube' && !audioOnly;
197 // Audio-only YT speelt via de site-onderbalk → geen eigen in-kaart controlebar.
198 const custom = (provider === 'youtube' || provider === 'soundcloud') && !audioOnly;
199 const poster = provider === 'youtube'
200 ? `https://i.ytimg.com/vi/${ytId(ref, url)}/hqdefault.jpg` : '';
201 // 16:9-thumbnail ZONDER zwarte balken (mqdefault) voor de vierkante cover —
202 // hqdefault is 4:3 met letterbox en vult een vierkant niet schoon.
203 const ytThumb = provider === 'youtube'
204 ? `https://i.ytimg.com/vi/${ytId(ref, url)}/mqdefault.jpg` : '';
205
206 el.innerHTML = ''
207 + (isVideo
208 ? `<div class="pcms-embed-stage"><div class="pcms-embed-mount"></div>`
209 + `<button type="button" class="pcms-embed-poster"${poster ? ` style="background-image:url('${poster}')"` : ''} aria-label="Afspelen">`
210 + `<span class="pcms-embed-bigplay">${ICON.play}</span></button></div>`
211 : `<div class="pcms-embed-audio">`
212 + `<button type="button" class="pcms-embed-art" aria-label="Afspelen"><span class="pcms-embed-bigplay">${ICON.play}</span></button>`
213 + `<div class="pcms-embed-info"><div class="pcms-embed-title">${LABEL[provider] || provider}</div>`
214 + `<div class="pcms-embed-sub"></div></div>`
215 + `<div class="pcms-embed-mount"></div></div>`)
216 + (custom
217 ? `<div class="pcms-embed-bar">`
218 + `<button type="button" class="pcms-embed-pp" aria-label="Afspelen/pauzeren">${ICON.play}</button>`
219 + `<span class="pcms-embed-cur mono">0:00</span>`
220 + `<div class="pcms-embed-seek" role="slider" aria-label="Voortgang" tabindex="0"><div class="pcms-embed-seek-fill"></div></div>`
221 + `<span class="pcms-embed-dur mono">0:00</span>`
222 + `<button type="button" class="pcms-embed-mute" aria-label="Dempen">${ICON.volume}</button>`
223 + `<input type="range" class="pcms-embed-vol" min="0" max="100" value="100" aria-label="Volume">`
224 + `<a class="pcms-embed-badge" href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">${LABEL[provider] || provider}</a>`
225 + `</div>`
226 : `<div class="pcms-embed-frame-badge"><a href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">via ${LABEL[provider] || provider}</a></div>`);
227
228 const mountEl = el.querySelector('.pcms-embed-mount');
229 const ppBtn = el.querySelector('.pcms-embed-pp');
230 const curEl = el.querySelector('.pcms-embed-cur');
231 const durEl = el.querySelector('.pcms-embed-dur');
232 const seekEl = el.querySelector('.pcms-embed-seek');
233 const seekFill = el.querySelector('.pcms-embed-seek-fill');
234 const posterBtn = el.querySelector('.pcms-embed-poster, .pcms-embed-art');
235 const subEl = el.querySelector('.pcms-embed-sub');
236 const volEl = el.querySelector('.pcms-embed-vol');
237 const muteBtn = el.querySelector('.pcms-embed-mute');
238 let vol = 100; // huidige volume 0-100 (wordt op de adapter toegepast)
239 let preMuteVol = 100;
240 // Audio-only YouTube: zet de thumbnail als albumhoes op de audio-kaart.
241 let ytTitle = ''; // echte videotitel (via same-origin oEmbed-proxy)
242 if (audioOnly && ytThumb) {
243 const art0 = el.querySelector('.pcms-embed-art');
244 if (art0) { art0.style.backgroundImage = `url('${ytThumb}')`; art0.classList.add('has-art'); }
245 // Haal de echte titel op (server-proxy, want YT-oEmbed heeft geen CORS) en
246 // toon 'm i.p.v. "YouTube".
247 fetch('/audio/yt-title?url=' + encodeURIComponent(url))
248 .then((r) => (r.ok ? r.json() : null))
249 .then((d) => {
250 if (d && d.title) {
251 ytTitle = d.title;
252 const tt = el.querySelector('.pcms-embed-title');
253 if (tt) tt.textContent = d.title;
254 }
255 })
256 .catch(() => {});
257 }
258
259 function setPlayingUI(on) {
260 playing = on;
261 el.classList.toggle('is-playing', on);
262 if (ppBtn) ppBtn.innerHTML = on ? ICON.pause : ICON.play;
263 }
264 function setProgress(cur, total) {
265 if (total > 0) { dur = total; if (durEl) durEl.textContent = fmt(total); }
266 if (curEl) curEl.textContent = fmt(cur);
267 if (seekFill && dur > 0) seekFill.style.width = Math.max(0, Math.min(100, (cur / dur) * 100)) + '%';
268 }
269
270 const hooks = {
271 onReady(meta) {
272 el.classList.add('is-ready');
273 el.classList.remove('pcms-embed-busy');
274 if (meta && meta.title && subEl) subEl.textContent = meta.title;
275 if (meta && meta.artwork) {
276 const art = el.querySelector('.pcms-embed-art');
277 if (art) { art.style.backgroundImage = `url('${meta.artwork}')`; art.classList.add('has-art'); }
278 }
279 if (meta && meta.duration) setProgress(0, meta.duration);
280 },
281 onPlay() { setPlayingUI(true); registry().setActive(self); },
282 onPause() { setPlayingUI(false); },
283 onEnded() { setPlayingUI(false); setProgress(0, dur); registry().release(self); },
284 onProgress(cur, total) { setProgress(cur, total); },
285 };
286
287 // API geblokkeerd/onbereikbaar → kaal platform-iframe (graceful degradation).
288 // De mutual-exclusion loopt voor deze fallback via de blur-heuristiek
289 // (audio-player.js), want de kaart krijgt geen .is-mounted.
290 function renderFallback() {
291 const fb = fallbackIframe(provider, ref, url);
292 if (!fb.src) { el.classList.remove('pcms-embed-busy'); el.classList.add('pcms-embed-error'); return; }
293 const iframe = document.createElement('iframe');
294 iframe.src = fb.src;
295 iframe.loading = 'lazy';
296 iframe.title = LABEL[provider] || provider;
297 iframe.setAttribute('allow', 'autoplay; encrypted-media; clipboard-write; picture-in-picture; fullscreen');
298 if (fb.fs) iframe.allowFullscreen = true;
299 iframe.style.cssText = fb.ratio
300 ? 'width:100%;aspect-ratio:16/9;border:0;display:block;'
301 : 'width:100%;height:' + (fb.h || '152px') + ';border:0;display:block;';
302 el.classList.remove('is-mounted', 'pcms-embed-busy');
303 el.classList.add('pcms-embed-fallback');
304 el.innerHTML = '';
305 el.appendChild(iframe);
306
307 // Mutual exclusion óók voor de fallback-iframe. Een cross-origin iframe
308 // kunnen we niet via een API pauzeren, dus 'pauze' = herladen ZONDER
309 // autoplay (= stopt het geluid, speler blijft zichtbaar/herstartbaar).
310 // We registreren 'm als actief: nu (= gebruiker start de embed) pauzeert de
311 // site-speler/andere embeds; en als de site-speler later start, pauzeert de
312 // registry deze fallback.
313 self.pause = function () {
314 try {
315 const noAuto = fb.src
316 .replace(/([?&])(?:autoplay=1|auto_play=true)(&|$)/gi, '$1')
317 .replace(/[?&]$/, '');
318 if (iframe.src === noAuto) {
319 // src ongewijzigd (bv. Spotify zonder autoplay) → forceer een reload
320 iframe.src = 'about:blank';
321 setTimeout(() => { try { iframe.src = noAuto; } catch (e) {} }, 30);
322 } else {
323 iframe.src = noAuto;
324 }
325 } catch (e) {}
326 };
327 registry().setActive(self);
328 }
329
330 // Eerste interactie → adapter mounten + spelen. Daarna toggelt de knop.
331 async function ensureMountedAndPlay() {
332 // Audio-only YouTube → speel via de site-audiospeler (onderbalk): zichtbaar
333 // én doorspelen bij navigeren. De inline-kaart is enkel de launcher.
334 if (audioOnly && window.pcmsAudioPlayer && typeof window.pcmsAudioPlayer.playYouTube === 'function') {
335 window.pcmsAudioPlayer.playYouTube({ id: ytId(ref, url), title: ytTitle || undefined, cover: ytThumb, postUrl: location.pathname + location.search });
336 el.classList.add('pcms-embed-elsewhere');
337 return;
338 }
339 if (mounted) { if (adapter) adapter.play(); return; }
340 mounted = true;
341 // Spotify: hun speler is toch niet te skinnen (besturing-only) én de
342 // iFrame-API-bundle initialiseert in de praktijk vaak niet (CDN-gating/503)
343 // → niet op de API wachten, meteen het kale Spotify-iframe tonen. Instant.
344 if (provider === 'spotify') { renderFallback(); return; }
345 el.classList.add('pcms-embed-busy');
346 try {
347 adapter = await MOUNTERS[provider](mountEl, { ref, url }, hooks);
348 if (el._pcmsApplyVol) el._pcmsApplyVol(); // onthouden volume toepassen
349 // is-mounted: CSS verbergt de poster (video) of de Spotify-facade en
350 // toont de echte speler. Voor SoundCloud blijft onze kaart+balk staan en
351 // blijft het (functionele) iframe off-screen verborgen.
352 el.classList.add('is-mounted');
353 adapter.play();
354 } catch (err) {
355 console.warn('[pcms-embed] API niet beschikbaar, val terug op kaal iframe', provider, err);
356 renderFallback();
357 }
358 }
359
360 if (posterBtn) posterBtn.addEventListener('click', ensureMountedAndPlay);
361 if (ppBtn) ppBtn.addEventListener('click', () => {
362 if (!mounted) return ensureMountedAndPlay();
363 if (playing) { adapter && adapter.pause(); } else { adapter && adapter.play(); }
364 });
365 if (seekEl) seekEl.addEventListener('click', (e) => {
366 if (!adapter || !adapter.seek || !dur) return;
367 const rect = seekEl.getBoundingClientRect();
368 const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
369 adapter.seek(ratio * dur);
370 });
371 // Volume: schuif zet vol (0-100) en past 'm toe op de adapter (YT/SC hebben
372 // setVolume). De waarde wordt onthouden en na het mounten opnieuw toegepast.
373 function applyVol() {
374 if (adapter && adapter.setVolume) { try { adapter.setVolume(vol); } catch (e) {} }
375 if (muteBtn) muteBtn.innerHTML = vol === 0 ? ICON.muted : ICON.volume;
376 if (volEl && String(volEl.value) !== String(vol)) volEl.value = vol;
377 el.classList.toggle('is-muted', vol === 0);
378 }
379 if (volEl) volEl.addEventListener('input', () => { vol = parseInt(volEl.value, 10) || 0; if (vol > 0) preMuteVol = vol; applyVol(); });
380 if (muteBtn) muteBtn.addEventListener('click', () => {
381 if (vol > 0) { preMuteVol = vol; vol = 0; } else { vol = preMuteVol || 100; }
382 applyVol();
383 });
384 el._pcmsApplyVol = applyVol; // door ensureMountedAndPlay aangeroepen na mount
385 }
386
387 function escAttr(s) {
388 return String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
389 }
390
391 // Haal de video-IDs van een YouTube-afspeellijst op via een verborgen,
392 // tijdelijke speler (cuePlaylist → getPlaylist). Geen API-key nodig.
393 function ytPlaylistIds(listId) {
394 return ytApi().then((YT) => new Promise((resolve) => {
395 if (!YT || !YT.Player) { resolve([]); return; }
396 const host = document.createElement('div');
397 host.style.cssText = 'position:absolute;left:-99999px;top:0;width:320px;height:180px;opacity:0;pointer-events:none';
398 const mount = document.createElement('div'); host.appendChild(mount); document.body.appendChild(host);
399 let done = false;
400 const finish = (ids) => { if (done) return; done = true; try { p.destroy(); } catch (e) {} try { host.remove(); } catch (e) {} resolve(ids || []); };
401 const p = new YT.Player(mount, {
402 host: 'https://www.youtube-nocookie.com', playerVars: { playsinline: 1 },
403 events: { onReady: () => {
404 try { p.cuePlaylist({ list: listId }); } catch (e) {}
405 setTimeout(() => { let ids = []; try { ids = p.getPlaylist() || []; } catch (e) {} finish(ids); }, 2500);
406 } },
407 });
408 setTimeout(() => finish([]), 9000);
409 }));
410 }
411
412 // Render een audio-only YouTube-AFSPEELLIJST als album-kaart (zelfde stijl als
413 // de eigen albums) met tracklijst; afspelen loopt via de site-audiospeler.
414 async function buildYtAlbum(el, listId) {
415 el.className = 'folio-embed pcms-ytalbum'; // geen losse card-chrome; .post-album stuurt de stijl
416 const thumb = (id) => `https://i.ytimg.com/vi/${id}/mqdefault.jpg`;
417 el.innerHTML = ''
418 + '<div class="post-album">'
419 + '<div class="post-album-header">'
420 + '<button type="button" class="post-album-cover-btn" aria-label="Album afspelen">'
421 + '<img class="post-album-cover-img" src="" alt="">'
422 + '<span class="post-album-play-overlay" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 4l12 8-12 8z"/></svg></span>'
423 + '</button>'
424 + '<div class="post-album-info">'
425 + '<h3 class="post-album-title">Album laden…</h3>'
426 + '<p class="post-album-artist"></p>'
427 + '<p class="post-album-count">YouTube</p>'
428 + '</div>'
429 + '</div>'
430 + '<ol class="post-album-tracks"><li class="pcms-ytalbum-msg">Tracks laden…</li></ol>'
431 + '</div>';
432 const coverImg = el.querySelector('.post-album-cover-img');
433 const titleEl = el.querySelector('.post-album-title');
434 const countEl = el.querySelector('.post-album-count');
435 const olEl = el.querySelector('.post-album-tracks');
436 const coverBtn = el.querySelector('.post-album-cover-btn');
437
438 // Albumnaam via de oEmbed-proxy (playlist-url).
439 fetch('/audio/yt-title?url=' + encodeURIComponent('https://www.youtube.com/playlist?list=' + listId))
440 .then((r) => (r.ok ? r.json() : null)).then((d) => { if (d && d.title) titleEl.textContent = d.title; }).catch(() => {});
441
442 let ids = [];
443 try { ids = await ytPlaylistIds(listId); } catch (e) { ids = []; }
444 if (!ids.length) { olEl.innerHTML = '<li class="pcms-ytalbum-msg">Kon de afspeellijst niet laden.</li>'; return; }
445 countEl.textContent = ids.length + ' track' + (ids.length === 1 ? '' : 's');
446 if (coverImg) coverImg.src = thumb(ids[0]);
447
448 olEl.innerHTML = ids.map((id, i) =>
449 `<li class="post-album-track-compact" id="track-${id}" data-yt-id="${id}" data-yt-index="${i}">`
450 + `<button type="button" class="pat-row" aria-label="Speel track ${i + 1}">`
451 + `<span class="pat-cover" style="background-image:url('${thumb(id)}')" aria-hidden="true"></span>`
452 + `<span class="pat-meta"><span class="pat-title">Track ${i + 1}</span><span class="pat-artist"></span></span>`
453 + `<span class="pat-duration pat-duration-empty">—:—</span>`
454 + `</button></li>`
455 ).join('');
456
457 // Tracktitels lazy invullen (oEmbed-proxy per video).
458 ids.forEach((id, i) => {
459 fetch('/audio/yt-title?url=' + encodeURIComponent('https://www.youtube.com/watch?v=' + id))
460 .then((r) => (r.ok ? r.json() : null))
461 .then((d) => { if (d && d.title) { const tt = olEl.querySelector(`[data-yt-index="${i}"] .pat-title`); if (tt) tt.textContent = d.title; } })
462 .catch(() => {});
463 });
464
465 function playAt(i) {
466 if (!(window.pcmsAudioPlayer && window.pcmsAudioPlayer.playYouTube)) return;
467 const tt = (olEl.querySelector(`[data-yt-index="${i}"] .pat-title`) || {}).textContent || '';
468 window.pcmsAudioPlayer.playYouTube({ list: listId, index: i, title: tt, cover: thumb(ids[i]), postUrl: location.pathname + location.search });
469 }
470 coverBtn.addEventListener('click', () => playAt(0));
471 olEl.addEventListener('click', (e) => { const li = e.target.closest('[data-yt-index]'); if (li) playAt(parseInt(li.dataset.ytIndex, 10) || 0); });
472 }
473
474 // ============================================================
475 // 4. Adapters — normaliseren de 3 zeer verschillende API's naar 1 vorm.
476 // Elke mounter geeft { play, pause, seek } terug en roept hooks aan met
477 // seconden (units worden hier rechtgetrokken).
478 // ============================================================
479 const MOUNTERS = {
480 // ---- YouTube: IFrame Player API, eigen controls (controls:0) ----
481 async youtube(mountEl, { ref, url }, hooks) {
482 const YT = await ytApi();
483 const id = ytId(ref, url);
484 return new Promise((resolve, reject) => {
485 let pollTimer = null;
486 const player = new YT.Player(mountEl, {
487 videoId: id,
488 host: 'https://www.youtube-nocookie.com',
489 playerVars: {
490 controls: 0, modestbranding: 1, rel: 0, playsinline: 1, fs: 0,
491 disablekb: 1, iv_load_policy: 3, origin: window.location.origin,
492 },
493 events: {
494 onReady() {
495 let d = 0; const meta = {};
496 try { d = player.getDuration() || 0; } catch (e) {}
497 try { const vd = player.getVideoData(); if (vd && vd.title) meta.title = vd.title; } catch (e) {}
498 meta.duration = d;
499 hooks.onReady(meta);
500 resolve({
501 play() { try { player.playVideo(); } catch (e) {} },
502 pause() { try { player.pauseVideo(); } catch (e) {} },
503 seek(sec) { try { player.seekTo(sec, true); } catch (e) {} },
504 setVolume(pct) { try { if (pct <= 0) player.mute(); else { player.unMute(); player.setVolume(pct); } } catch (e) {} },
505 destroy() {
506 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
507 try { player.destroy(); } catch (e) {}
508 },
509 });
510 },
511 onStateChange(e) {
512 // -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued
513 if (e.data === 1) {
514 hooks.onPlay();
515 if (!pollTimer) pollTimer = setInterval(() => {
516 try { hooks.onProgress(player.getCurrentTime() || 0, player.getDuration() || 0); } catch (e) {}
517 }, 250);
518 } else if (e.data === 2) {
519 hooks.onPause();
520 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
521 } else if (e.data === 0) {
522 hooks.onEnded();
523 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
524 }
525 },
526 onError() { reject(new Error('YT error')); },
527 },
528 });
529 });
530 },
531
532 // ---- SoundCloud: Widget API, visual=false, eigen controls ----
533 async soundcloud(mountEl, { ref, url }, hooks) {
534 const SC = await scApi();
535 // Iframe zelf bouwen (kale balk) en daarna SC.Widget eraan hangen.
536 const iframe = document.createElement('iframe');
537 iframe.allow = 'autoplay';
538 iframe.title = 'SoundCloud';
539 const params = new URLSearchParams({
540 url: ref || url, visual: 'false', auto_play: 'false', hide_related: 'true',
541 show_comments: 'false', show_user: 'false', show_teaser: 'false',
542 sharing: 'false', buy: 'false', download: 'false', show_artwork: 'true',
543 single_active: 'true', color: 'ff5500',
544 });
545 iframe.src = 'https://w.soundcloud.com/player/?' + params.toString();
546 mountEl.appendChild(iframe);
547 const widget = SC.Widget(iframe);
548 const E = SC.Widget.Events;
549 return new Promise((resolve, reject) => {
550 let resolved = false;
551 widget.bind(E.READY, () => {
552 widget.getCurrentSound((sound) => {
553 const meta = sound ? {
554 title: sound.title || '',
555 artwork: (sound.artwork_url || (sound.user && sound.user.avatar_url) || '').replace('-large', '-t300x300'),
556 } : {};
557 widget.getDuration((ms) => { meta.duration = (ms || 0) / 1000; hooks.onReady(meta); });
558 });
559 resolved = true;
560 resolve({
561 play() { widget.play(); },
562 pause() { widget.pause(); },
563 seek(sec) { widget.seekTo(sec * 1000); },
564 setVolume(pct) { try { widget.setVolume(pct); } catch (e) {} },
565 destroy() {
566 try { ['READY', 'PLAY', 'PAUSE', 'FINISH', 'PLAY_PROGRESS', 'ERROR'].forEach((k) => E[k] && widget.unbind(E[k])); } catch (e) {}
567 try { iframe.remove(); } catch (e) {}
568 },
569 });
570 });
571 widget.bind(E.PLAY, () => hooks.onPlay());
572 widget.bind(E.PAUSE, () => hooks.onPause());
573 widget.bind(E.FINISH, () => hooks.onEnded());
574 widget.bind(E.PLAY_PROGRESS, (d) => {
575 hooks.onProgress((d.currentPosition || 0) / 1000, 0);
576 });
577 widget.bind(E.ERROR, () => { if (!resolved) reject(new Error('SC error')); });
578 setTimeout(() => { if (!resolved) reject(new Error('SC timeout')); }, 12000);
579 });
580 },
581
582 // ---- Spotify: iFrame API — besturing + onze frame; geen eigen skin ----
583 async spotify(mountEl, { ref, url }, hooks) {
584 const IFrameAPI = await spotifyApi();
585 const uri = ref || url;
586 return new Promise((resolve, reject) => {
587 let lastPaused = true, resolved = false;
588 IFrameAPI.createController(mountEl, { uri, width: '100%', height: 152 }, (controller) => {
589 controller.addListener('ready', () => {
590 hooks.onReady({});
591 resolved = true;
592 resolve({
593 play() { try { controller.resume(); } catch (e) { try { controller.play(); } catch (e2) {} } },
594 pause() { try { controller.pause(); } catch (e) {} },
595 seek(sec) { try { controller.seek(sec); } catch (e) {} },
596 destroy() { try { controller.destroy(); } catch (e) {} },
597 });
598 });
599 controller.addListener('playback_update', (e) => {
600 const d = e && e.data ? e.data : {};
601 hooks.onProgress((d.position || 0) / 1000, (d.duration || 0) / 1000);
602 // Geen betrouwbare 'ended'-event bij Spotify; we behandelen elke
603 // isPaused-overgang als play/pause. Einde = gewoon een pauze (de balk
604 // blijft op de eindpositie i.p.v. misleidend naar 0 te springen).
605 if (d.isPaused === false && lastPaused) { lastPaused = false; hooks.onPlay(); }
606 else if (d.isPaused === true && !lastPaused) { lastPaused = true; hooks.onPause(); }
607 });
608 });
609 setTimeout(() => { if (!resolved) reject(new Error('Spotify timeout')); }, 12000);
610 });
611 },
612 };
613
614 // ============================================================
615 // 5. Scan + HTMX-/DOM-mutatie-aware (de site navigeert deels via HTMX-swaps)
616 // ============================================================
617 function scan(root) {
618 (root || document).querySelectorAll('.folio-embed[data-embed-provider]').forEach((el) => {
619 if (el.dataset.embedInit) return;
620 el.dataset.embedInit = '1';
621 try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard faalde', e); }
622 });
623 }
624
625 if (document.readyState === 'loading') {
626 document.addEventListener('DOMContentLoaded', () => scan(document));
627 } else {
628 scan(document);
629 }
630 // HTMX vervangt #pcms-main bij interne navigatie → opnieuw scannen.
631 document.body.addEventListener('htmx:afterSwap', (e) => scan(e.target || document));
632 document.body.addEventListener('htmx:load', (e) => scan(e.target || document));
633
634 // Teardown bij verwijdering uit de DOM (HTMX vervangt #pcms-main innerHTML, of
635 // een SPA-achtige swap). Zonder dit blijven YouTube-poll-timers + adapters/
636 // iframes hangen als je wegnavigeert terwijl een embed speelt → CPU/geheugenlek
637 // dat per navigatie opstapelt. We roepen el._pcmsDestroy() aan voor elke kaart
638 // die echt uit het document verdwijnt.
639 const teardownObserver = new MutationObserver((muts) => {
640 for (const m of muts) {
641 m.removedNodes.forEach((node) => {
642 if (node.nodeType !== 1) return;
643 const cards = [];
644 if (node.matches && node.matches('.folio-embed[data-embed-init]')) cards.push(node);
645 if (node.querySelectorAll) node.querySelectorAll('.folio-embed[data-embed-init]').forEach((c) => cards.push(c));
646 cards.forEach((c) => {
647 if (typeof c._pcmsDestroy === 'function' && !document.contains(c)) {
648 try { c._pcmsDestroy(); } catch (e) {}
649 }
650 });
651 });
652 }
653 });
654 try { teardownObserver.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
655
656 window.pcmsEmbedPlayer = { scan, registry };
657})();
Note: See TracBrowser for help on using the repository browser.