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

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

feat(player): YouTube audio via the site audio player (continuous play + bottom bar)

Audio-only YouTube embeds now play through the persistent site audio player
instead of a per-card player:

  • audio-player.js gets a second backend: a hidden YT IFrame player in the (persistent) player root. loadTrack/play/pause/seek/volume/mute/progress/ended are backend-aware (ytMode). New API: pcmsAudioPlayer.playYouTube({id,...}).
  • embed-player.js: an audio-only YT embed is now a launcher that calls pcmsAudioPlayer.playYouTube (no own control bar anymore).

Result: YouTube audio appears in the bottom bar, keeps playing during htmx
navigation, and mutual exclusion with the site tracks is automatic (one player).
busters audio-player.js?v=21, embed-player.js?v=9, embed.css?v=6.

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

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