| 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|---|
| 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 | })();
|
|---|