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

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

fix(embed): Spotify directly as bare iframe (instant, no API wait)

The Spotify iFrame API bundle often fails to initialise in practice (CDN
gating), and their player cannot be skinned anyway. Therefore with Spotify
show the bare iframe immediately instead of waiting for the API → instant
rather than seconds of "sluggishness". CSP whitelist + 4s timeout (previous
commit) remain as a fallback for those who do get the API. buster
embed-player.js?v=6.

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

  • Property mode set to 100644
File size: 23.7 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 };
151 const LABEL = { youtube: 'YouTube', soundcloud: 'SoundCloud', spotify: 'Spotify' };
152
153 // ============================================================
154 // 3. Card controller — bouwt de huisstijl-kaart + delegeert naar een adapter
155 // ============================================================
156 function buildCard(el) {
157 const provider = el.dataset.embedProvider;
158 const ref = el.dataset.embedRef || '';
159 const url = el.dataset.embedUrl || '';
160 if (!provider) return;
161
162 el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider);
163 el.classList.remove('pcms-embed-loading');
164
165 // Onze speler-zijde (registry-peer). pause() wijst naar de adapter zodra die
166 // gemount is; ervoor is 't een no-op.
167 let adapter = null;
168 const self = { pause() { if (adapter && adapter.pause) { try { adapter.pause(); } catch (e) {} } } };
169
170 // Teardown-hook: aangeroepen door de MutationObserver als deze kaart uit de
171 // DOM verdwijnt (HTMX-swap) → adapter opruimen (timers/iframes) + registry
172 // vrijgeven, zodat er geen poll-timers of spelers blijven lekken.
173 el._pcmsDestroy = function () {
174 try { if (adapter && adapter.destroy) adapter.destroy(); } catch (e) {}
175 registry().release(self);
176 };
177
178 let playing = false;
179 let mounted = false;
180 let dur = 0;
181
182 // --- UI ophangen (verschilt per provider-type) ---
183 const isVideo = provider === 'youtube';
184 const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls
185 const poster = provider === 'youtube'
186 ? `https://i.ytimg.com/vi/${ytId(ref, url)}/hqdefault.jpg` : '';
187
188 el.innerHTML = ''
189 + (isVideo
190 ? `<div class="pcms-embed-stage"><div class="pcms-embed-mount"></div>`
191 + `<button type="button" class="pcms-embed-poster"${poster ? ` style="background-image:url('${poster}')"` : ''} aria-label="Afspelen">`
192 + `<span class="pcms-embed-bigplay">${ICON.play}</span></button></div>`
193 : `<div class="pcms-embed-audio">`
194 + `<button type="button" class="pcms-embed-art" aria-label="Afspelen"><span class="pcms-embed-bigplay">${ICON.play}</span></button>`
195 + `<div class="pcms-embed-info"><div class="pcms-embed-title">${LABEL[provider] || provider}</div>`
196 + `<div class="pcms-embed-sub"></div></div>`
197 + `<div class="pcms-embed-mount"></div></div>`)
198 + (custom
199 ? `<div class="pcms-embed-bar">`
200 + `<button type="button" class="pcms-embed-pp" aria-label="Afspelen/pauzeren">${ICON.play}</button>`
201 + `<span class="pcms-embed-cur mono">0:00</span>`
202 + `<div class="pcms-embed-seek" role="slider" aria-label="Voortgang" tabindex="0"><div class="pcms-embed-seek-fill"></div></div>`
203 + `<span class="pcms-embed-dur mono">0:00</span>`
204 + `<a class="pcms-embed-badge" href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">${LABEL[provider] || provider}</a>`
205 + `</div>`
206 : `<div class="pcms-embed-frame-badge"><a href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">via ${LABEL[provider] || provider}</a></div>`);
207
208 const mountEl = el.querySelector('.pcms-embed-mount');
209 const ppBtn = el.querySelector('.pcms-embed-pp');
210 const curEl = el.querySelector('.pcms-embed-cur');
211 const durEl = el.querySelector('.pcms-embed-dur');
212 const seekEl = el.querySelector('.pcms-embed-seek');
213 const seekFill = el.querySelector('.pcms-embed-seek-fill');
214 const posterBtn = el.querySelector('.pcms-embed-poster, .pcms-embed-art');
215 const subEl = el.querySelector('.pcms-embed-sub');
216
217 function setPlayingUI(on) {
218 playing = on;
219 el.classList.toggle('is-playing', on);
220 if (ppBtn) ppBtn.innerHTML = on ? ICON.pause : ICON.play;
221 }
222 function setProgress(cur, total) {
223 if (total > 0) { dur = total; if (durEl) durEl.textContent = fmt(total); }
224 if (curEl) curEl.textContent = fmt(cur);
225 if (seekFill && dur > 0) seekFill.style.width = Math.max(0, Math.min(100, (cur / dur) * 100)) + '%';
226 }
227
228 const hooks = {
229 onReady(meta) {
230 el.classList.add('is-ready');
231 el.classList.remove('pcms-embed-busy');
232 if (meta && meta.title && subEl) subEl.textContent = meta.title;
233 if (meta && meta.artwork) {
234 const art = el.querySelector('.pcms-embed-art');
235 if (art) { art.style.backgroundImage = `url('${meta.artwork}')`; art.classList.add('has-art'); }
236 }
237 if (meta && meta.duration) setProgress(0, meta.duration);
238 },
239 onPlay() { setPlayingUI(true); registry().setActive(self); },
240 onPause() { setPlayingUI(false); },
241 onEnded() { setPlayingUI(false); setProgress(0, dur); registry().release(self); },
242 onProgress(cur, total) { setProgress(cur, total); },
243 };
244
245 // API geblokkeerd/onbereikbaar → kaal platform-iframe (graceful degradation).
246 // De mutual-exclusion loopt voor deze fallback via de blur-heuristiek
247 // (audio-player.js), want de kaart krijgt geen .is-mounted.
248 function renderFallback() {
249 const fb = fallbackIframe(provider, ref, url);
250 if (!fb.src) { el.classList.remove('pcms-embed-busy'); el.classList.add('pcms-embed-error'); return; }
251 const iframe = document.createElement('iframe');
252 iframe.src = fb.src;
253 iframe.loading = 'lazy';
254 iframe.title = LABEL[provider] || provider;
255 iframe.setAttribute('allow', 'autoplay; encrypted-media; clipboard-write; picture-in-picture; fullscreen');
256 if (fb.fs) iframe.allowFullscreen = true;
257 iframe.style.cssText = fb.ratio
258 ? 'width:100%;aspect-ratio:16/9;border:0;display:block;'
259 : 'width:100%;height:' + (fb.h || '152px') + ';border:0;display:block;';
260 el.classList.remove('is-mounted', 'pcms-embed-busy');
261 el.classList.add('pcms-embed-fallback');
262 el.innerHTML = '';
263 el.appendChild(iframe);
264
265 // Mutual exclusion óók voor de fallback-iframe. Een cross-origin iframe
266 // kunnen we niet via een API pauzeren, dus 'pauze' = herladen ZONDER
267 // autoplay (= stopt het geluid, speler blijft zichtbaar/herstartbaar).
268 // We registreren 'm als actief: nu (= gebruiker start de embed) pauzeert de
269 // site-speler/andere embeds; en als de site-speler later start, pauzeert de
270 // registry deze fallback.
271 self.pause = function () {
272 try {
273 const noAuto = fb.src
274 .replace(/([?&])(?:autoplay=1|auto_play=true)(&|$)/gi, '$1')
275 .replace(/[?&]$/, '');
276 if (iframe.src === noAuto) {
277 // src ongewijzigd (bv. Spotify zonder autoplay) → forceer een reload
278 iframe.src = 'about:blank';
279 setTimeout(() => { try { iframe.src = noAuto; } catch (e) {} }, 30);
280 } else {
281 iframe.src = noAuto;
282 }
283 } catch (e) {}
284 };
285 registry().setActive(self);
286 }
287
288 // Eerste interactie → adapter mounten + spelen. Daarna toggelt de knop.
289 async function ensureMountedAndPlay() {
290 if (mounted) { if (adapter) adapter.play(); return; }
291 mounted = true;
292 // Spotify: hun speler is toch niet te skinnen (besturing-only) én de
293 // iFrame-API-bundle initialiseert in de praktijk vaak niet (CDN-gating/503)
294 // → niet op de API wachten, meteen het kale Spotify-iframe tonen. Instant.
295 if (provider === 'spotify') { renderFallback(); return; }
296 el.classList.add('pcms-embed-busy');
297 try {
298 adapter = await MOUNTERS[provider](mountEl, { ref, url }, hooks);
299 // is-mounted: CSS verbergt de poster (video) of de Spotify-facade en
300 // toont de echte speler. Voor SoundCloud blijft onze kaart+balk staan en
301 // blijft het (functionele) iframe off-screen verborgen.
302 el.classList.add('is-mounted');
303 adapter.play();
304 } catch (err) {
305 console.warn('[pcms-embed] API niet beschikbaar, val terug op kaal iframe', provider, err);
306 renderFallback();
307 }
308 }
309
310 if (posterBtn) posterBtn.addEventListener('click', ensureMountedAndPlay);
311 if (ppBtn) ppBtn.addEventListener('click', () => {
312 if (!mounted) return ensureMountedAndPlay();
313 if (playing) { adapter && adapter.pause(); } else { adapter && adapter.play(); }
314 });
315 if (seekEl) seekEl.addEventListener('click', (e) => {
316 if (!adapter || !adapter.seek || !dur) return;
317 const rect = seekEl.getBoundingClientRect();
318 const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
319 adapter.seek(ratio * dur);
320 });
321 }
322
323 function escAttr(s) {
324 return String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
325 }
326
327 // ============================================================
328 // 4. Adapters — normaliseren de 3 zeer verschillende API's naar 1 vorm.
329 // Elke mounter geeft { play, pause, seek } terug en roept hooks aan met
330 // seconden (units worden hier rechtgetrokken).
331 // ============================================================
332 const MOUNTERS = {
333 // ---- YouTube: IFrame Player API, eigen controls (controls:0) ----
334 async youtube(mountEl, { ref, url }, hooks) {
335 const YT = await ytApi();
336 const id = ytId(ref, url);
337 return new Promise((resolve, reject) => {
338 let pollTimer = null;
339 const player = new YT.Player(mountEl, {
340 videoId: id,
341 host: 'https://www.youtube-nocookie.com',
342 playerVars: {
343 controls: 0, modestbranding: 1, rel: 0, playsinline: 1, fs: 0,
344 disablekb: 1, iv_load_policy: 3, origin: window.location.origin,
345 },
346 events: {
347 onReady() {
348 let d = 0; try { d = player.getDuration() || 0; } catch (e) {}
349 hooks.onReady({ duration: d });
350 resolve({
351 play() { try { player.playVideo(); } catch (e) {} },
352 pause() { try { player.pauseVideo(); } catch (e) {} },
353 seek(sec) { try { player.seekTo(sec, true); } catch (e) {} },
354 destroy() {
355 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
356 try { player.destroy(); } catch (e) {}
357 },
358 });
359 },
360 onStateChange(e) {
361 // -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued
362 if (e.data === 1) {
363 hooks.onPlay();
364 if (!pollTimer) pollTimer = setInterval(() => {
365 try { hooks.onProgress(player.getCurrentTime() || 0, player.getDuration() || 0); } catch (e) {}
366 }, 250);
367 } else if (e.data === 2) {
368 hooks.onPause();
369 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
370 } else if (e.data === 0) {
371 hooks.onEnded();
372 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
373 }
374 },
375 onError() { reject(new Error('YT error')); },
376 },
377 });
378 });
379 },
380
381 // ---- SoundCloud: Widget API, visual=false, eigen controls ----
382 async soundcloud(mountEl, { ref, url }, hooks) {
383 const SC = await scApi();
384 // Iframe zelf bouwen (kale balk) en daarna SC.Widget eraan hangen.
385 const iframe = document.createElement('iframe');
386 iframe.allow = 'autoplay';
387 iframe.title = 'SoundCloud';
388 const params = new URLSearchParams({
389 url: ref || url, visual: 'false', auto_play: 'false', hide_related: 'true',
390 show_comments: 'false', show_user: 'false', show_teaser: 'false',
391 sharing: 'false', buy: 'false', download: 'false', show_artwork: 'true',
392 single_active: 'true', color: 'ff5500',
393 });
394 iframe.src = 'https://w.soundcloud.com/player/?' + params.toString();
395 mountEl.appendChild(iframe);
396 const widget = SC.Widget(iframe);
397 const E = SC.Widget.Events;
398 return new Promise((resolve, reject) => {
399 let resolved = false;
400 widget.bind(E.READY, () => {
401 widget.getCurrentSound((sound) => {
402 const meta = sound ? {
403 title: sound.title || '',
404 artwork: (sound.artwork_url || (sound.user && sound.user.avatar_url) || '').replace('-large', '-t300x300'),
405 } : {};
406 widget.getDuration((ms) => { meta.duration = (ms || 0) / 1000; hooks.onReady(meta); });
407 });
408 resolved = true;
409 resolve({
410 play() { widget.play(); },
411 pause() { widget.pause(); },
412 seek(sec) { widget.seekTo(sec * 1000); },
413 destroy() {
414 try { ['READY', 'PLAY', 'PAUSE', 'FINISH', 'PLAY_PROGRESS', 'ERROR'].forEach((k) => E[k] && widget.unbind(E[k])); } catch (e) {}
415 try { iframe.remove(); } catch (e) {}
416 },
417 });
418 });
419 widget.bind(E.PLAY, () => hooks.onPlay());
420 widget.bind(E.PAUSE, () => hooks.onPause());
421 widget.bind(E.FINISH, () => hooks.onEnded());
422 widget.bind(E.PLAY_PROGRESS, (d) => {
423 hooks.onProgress((d.currentPosition || 0) / 1000, 0);
424 });
425 widget.bind(E.ERROR, () => { if (!resolved) reject(new Error('SC error')); });
426 setTimeout(() => { if (!resolved) reject(new Error('SC timeout')); }, 12000);
427 });
428 },
429
430 // ---- Spotify: iFrame API — besturing + onze frame; geen eigen skin ----
431 async spotify(mountEl, { ref, url }, hooks) {
432 const IFrameAPI = await spotifyApi();
433 const uri = ref || url;
434 return new Promise((resolve, reject) => {
435 let lastPaused = true, resolved = false;
436 IFrameAPI.createController(mountEl, { uri, width: '100%', height: 152 }, (controller) => {
437 controller.addListener('ready', () => {
438 hooks.onReady({});
439 resolved = true;
440 resolve({
441 play() { try { controller.resume(); } catch (e) { try { controller.play(); } catch (e2) {} } },
442 pause() { try { controller.pause(); } catch (e) {} },
443 seek(sec) { try { controller.seek(sec); } catch (e) {} },
444 destroy() { try { controller.destroy(); } catch (e) {} },
445 });
446 });
447 controller.addListener('playback_update', (e) => {
448 const d = e && e.data ? e.data : {};
449 hooks.onProgress((d.position || 0) / 1000, (d.duration || 0) / 1000);
450 // Geen betrouwbare 'ended'-event bij Spotify; we behandelen elke
451 // isPaused-overgang als play/pause. Einde = gewoon een pauze (de balk
452 // blijft op de eindpositie i.p.v. misleidend naar 0 te springen).
453 if (d.isPaused === false && lastPaused) { lastPaused = false; hooks.onPlay(); }
454 else if (d.isPaused === true && !lastPaused) { lastPaused = true; hooks.onPause(); }
455 });
456 });
457 setTimeout(() => { if (!resolved) reject(new Error('Spotify timeout')); }, 12000);
458 });
459 },
460 };
461
462 // ============================================================
463 // 5. Scan + HTMX-/DOM-mutatie-aware (de site navigeert deels via HTMX-swaps)
464 // ============================================================
465 function scan(root) {
466 (root || document).querySelectorAll('.folio-embed[data-embed-provider]').forEach((el) => {
467 if (el.dataset.embedInit) return;
468 el.dataset.embedInit = '1';
469 try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard faalde', e); }
470 });
471 }
472
473 if (document.readyState === 'loading') {
474 document.addEventListener('DOMContentLoaded', () => scan(document));
475 } else {
476 scan(document);
477 }
478 // HTMX vervangt #pcms-main bij interne navigatie → opnieuw scannen.
479 document.body.addEventListener('htmx:afterSwap', (e) => scan(e.target || document));
480 document.body.addEventListener('htmx:load', (e) => scan(e.target || document));
481
482 // Teardown bij verwijdering uit de DOM (HTMX vervangt #pcms-main innerHTML, of
483 // een SPA-achtige swap). Zonder dit blijven YouTube-poll-timers + adapters/
484 // iframes hangen als je wegnavigeert terwijl een embed speelt → CPU/geheugenlek
485 // dat per navigatie opstapelt. We roepen el._pcmsDestroy() aan voor elke kaart
486 // die echt uit het document verdwijnt.
487 const teardownObserver = new MutationObserver((muts) => {
488 for (const m of muts) {
489 m.removedNodes.forEach((node) => {
490 if (node.nodeType !== 1) return;
491 const cards = [];
492 if (node.matches && node.matches('.folio-embed[data-embed-init]')) cards.push(node);
493 if (node.querySelectorAll) node.querySelectorAll('.folio-embed[data-embed-init]').forEach((c) => cards.push(c));
494 cards.forEach((c) => {
495 if (typeof c._pcmsDestroy === 'function' && !document.contains(c)) {
496 try { c._pcmsDestroy(); } catch (e) {}
497 }
498 });
499 });
500 }
501 });
502 try { teardownObserver.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
503
504 window.pcmsEmbedPlayer = { scan, registry };
505})();
Note: See TracBrowser for help on using the repository browser.