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

main
Last change on this file since cb95343 was cb95343, checked in by Robin Genis <roboburr@…>, 2 months ago

fix(embeds): plain responsive iframe on mobile/touch

The custom JS-mounted player cards need the platform API + an i.ytimg.com poster, both
of which mobile Safari tracking-protection blocks → a blank/black box. On touch / coarse-
pointer devices, render the plain platform iframe directly (YouTube/SoundCloud/Spotify),
using a padding-ratio wrapper that reserves height on every browser incl. old iOS Safari.
Desktop keeps the rich card. embed.css v9, embed-player.js v17.

  • Property mode set to 100644
File size: 27.7 KB
Line 
1/**
2 * Klonkt Embed Player — custom, on-brand media embeds on top of the REAL
3 * player APIs (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API).
4 *
5 * The server (AudioEmbedService) renders a placeholder per embed:
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 * This script wraps it in our own card (cover/poster + our play button +
9 * progress bar in brand style) and controls the underlying player via the
10 * platform API, so play/pause/progress are in OUR hands.
11 *
12 * Capability honesty:
13 * - youtube/soundcloud → fully custom controls (native chrome hidden).
14 * - spotify → controls + our frame around it; Spotify's own UI
15 * stays inside (no alternative without Premium+OAuth).
16 *
17 * Mutual exclusion: every player (incl. the site audio player in audio-player.js)
18 * registers itself in window.pcmsMediaRegistry. Starting one pauses the previous.
19 * This replaces the old focus/blur heuristic with real play events.
20 *
21 * Singleton — guard against double-init (HTMX can reload scripts).
22 */
23(function () {
24 if (window.pcmsEmbedPlayer) return;
25
26 // ============================================================
27 // 0. Shared playback registry (also used by audio-player.js)
28 // ============================================================
29 function registry() {
30 if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
31 const r = {
32 _active: null,
33 // Mark `player` as the sole active one; pause the previous.
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, shared across N embeds.
48 // A platform script is only loaded when an embed from that platform
49 // is actually started on the page.
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 failed: ' + src));
58 document.head.appendChild(s);
59 });
60 }
61 // Important: all loaders REJECT on script errors (e.g. an ad-blocker blocking
62 // the API script) and on timeout — so the caller can gracefully fall back to
63 // the plain platform iframe instead of hanging indefinitely.
64 const API_TIMEOUT = 8000;
65
66 // YouTube: global callback onYouTubeIframeAPIReady (once) → wrap in a promise.
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: no global ready callback; resolve on script onload, then
82 // each widget waits for its own 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: global callback onSpotifyIframeApiReady(IFrameAPI) (once) → wrap in a promise.
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 // Shorter than API_TIMEOUT: Spotify's bundle initialises quickly or not at all
104 // (CDN 503 / origin-gating). No need to wait 8 s before the iframe fallback.
105 setTimeout(() => reject(new Error('Spotify API timeout')), 4000);
106 });
107 return scripts.sp;
108 }
109
110 // Plain platform iframe as fallback when the JS API is blocked/unreachable.
111 // Ad-blockers generally let embed iframes through. Autoplay parameter
112 // is safe here because we're always in a user-gesture context (play click).
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; // no blind slice — an invalid ref returns nothing rather than a broken id
141 }
142 // Only allow http(s) as href (defense-in-depth against javascript:/data: URIs).
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 // Touch / coarse-pointer (phones, most tablets): the custom JS-mounted card is
156 // fragile — Safari tracking-protection blocks the platform API scripts AND the
157 // poster thumbnails (i.ytimg.com) → a black/blank box. Render the plain, reliable
158 // platform iframe directly instead (same approach the News feed uses). The
159 // padding-ratio wrapper reserves height on every browser incl. old iOS Safari
160 // (no `aspect-ratio` dependency). Desktop keeps the rich custom card.
161 const IS_TOUCH = !!(window.matchMedia && window.matchMedia('(hover: none) and (pointer: coarse)').matches);
162
163 function mountPlain(el, provider, ref, url) {
164 el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider, 'pcms-embed-plain');
165 el.classList.remove('pcms-embed-loading');
166 let html = '';
167 if (provider === 'youtube') {
168 const id = ytId(ref, url);
169 html = id
170 ? '<div class="pcms-embed-ratio"><iframe src="https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) + '?rel=0" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>'
171 : '<a class="pcms-embed-plain-link" href="' + escAttr(safeHref(url)) + '" target="_blank" rel="noopener">YouTube</a>';
172 } else if (provider === 'soundcloud') {
173 html = '<iframe class="pcms-embed-plain-frame" style="height:166px" src="https://w.soundcloud.com/player/?url=' + encodeURIComponent(ref || url) + '&color=%23ff5500&visual=false" title="SoundCloud" loading="lazy" frameborder="0" allow="autoplay" scrolling="no"></iframe>';
174 } else if (provider === 'spotify') {
175 const m = (ref || '').match(/^spotify:(\w+):(\w+)$/);
176 const src = m ? 'https://open.spotify.com/embed/' + m[1] + '/' + m[2] : (url || '');
177 html = '<iframe class="pcms-embed-plain-frame" style="height:152px" src="' + escAttr(src) + '" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>';
178 } else {
179 html = '<a class="pcms-embed-plain-link" href="' + escAttr(safeHref(url)) + '" target="_blank" rel="noopener">' + (LABEL[provider] || provider) + '</a>';
180 }
181 el.innerHTML = html;
182 }
183
184 // ============================================================
185 // 3. Card controller — builds the on-brand card + delegates to an adapter
186 // ============================================================
187 function buildCard(el) {
188 const provider = el.dataset.embedProvider;
189 const ref = el.dataset.embedRef || '';
190 const url = el.dataset.embedUrl || '';
191 if (!provider) return;
192 if (IS_TOUCH) { mountPlain(el, provider, ref, url); return; }
193
194 el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider);
195 el.classList.remove('pcms-embed-loading');
196
197 // Our player side (registry peer). pause() points to the adapter once
198 // mounted; before that it's a no-op.
199 let adapter = null;
200 const self = { pause() { if (adapter && adapter.pause) { try { adapter.pause(); } catch (e) {} } } };
201
202 // Teardown hook: called by the MutationObserver when this card leaves the
203 // DOM (HTMX swap) → clean up the adapter (timers/iframes) + release the registry,
204 // so no poll timers or players keep leaking.
205 el._pcmsDestroy = function () {
206 try { if (adapter && adapter.destroy) adapter.destroy(); } catch (e) {}
207 registry().release(self);
208 };
209
210 let playing = false;
211 let mounted = false;
212 let dur = 0;
213
214 // --- Mount the UI (differs per provider type) ---
215 const isVideo = provider === 'youtube';
216 const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls
217 const poster = provider === 'youtube'
218 ? `https://i.ytimg.com/vi/${ytId(ref, url)}/hqdefault.jpg` : '';
219
220 el.innerHTML = ''
221 + (isVideo
222 ? `<div class="pcms-embed-stage"><div class="pcms-embed-mount"></div>`
223 + `<button type="button" class="pcms-embed-poster"${poster ? ` style="background-image:url('${poster}')"` : ''} aria-label="Afspelen">`
224 + `<span class="pcms-embed-bigplay">${ICON.play}</span></button></div>`
225 : `<div class="pcms-embed-audio">`
226 + `<button type="button" class="pcms-embed-art" aria-label="Afspelen"><span class="pcms-embed-bigplay">${ICON.play}</span></button>`
227 + `<div class="pcms-embed-info"><div class="pcms-embed-title">${LABEL[provider] || provider}</div>`
228 + `<div class="pcms-embed-sub"></div></div>`
229 + `<div class="pcms-embed-mount"></div></div>`)
230 + (custom
231 ? `<div class="pcms-embed-bar">`
232 + `<button type="button" class="pcms-embed-pp" aria-label="Afspelen/pauzeren">${ICON.play}</button>`
233 + `<span class="pcms-embed-cur mono">0:00</span>`
234 + `<div class="pcms-embed-seek" role="slider" aria-label="Voortgang" tabindex="0"><div class="pcms-embed-seek-fill"></div></div>`
235 + `<span class="pcms-embed-dur mono">0:00</span>`
236 + `<button type="button" class="pcms-embed-mute" aria-label="Dempen">${ICON.volume}</button>`
237 + `<input type="range" class="pcms-embed-vol" min="0" max="100" value="100" aria-label="Volume">`
238 + `<a class="pcms-embed-badge" href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">${LABEL[provider] || provider}</a>`
239 + `</div>`
240 : `<div class="pcms-embed-frame-badge"><a href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">via ${LABEL[provider] || provider}</a></div>`);
241
242 const mountEl = el.querySelector('.pcms-embed-mount');
243 const ppBtn = el.querySelector('.pcms-embed-pp');
244 const curEl = el.querySelector('.pcms-embed-cur');
245 const durEl = el.querySelector('.pcms-embed-dur');
246 const seekEl = el.querySelector('.pcms-embed-seek');
247 const seekFill = el.querySelector('.pcms-embed-seek-fill');
248 const posterBtn = el.querySelector('.pcms-embed-poster, .pcms-embed-art');
249 const subEl = el.querySelector('.pcms-embed-sub');
250 const volEl = el.querySelector('.pcms-embed-vol');
251 const muteBtn = el.querySelector('.pcms-embed-mute');
252 let vol = 100; // current volume 0-100 (applied to the adapter)
253 let preMuteVol = 100;
254
255 function setPlayingUI(on) {
256 playing = on;
257 el.classList.toggle('is-playing', on);
258 if (ppBtn) ppBtn.innerHTML = on ? ICON.pause : ICON.play;
259 }
260 function setProgress(cur, total) {
261 if (total > 0) { dur = total; if (durEl) durEl.textContent = fmt(total); }
262 if (curEl) curEl.textContent = fmt(cur);
263 if (seekFill && dur > 0) seekFill.style.width = Math.max(0, Math.min(100, (cur / dur) * 100)) + '%';
264 }
265
266 const hooks = {
267 onReady(meta) {
268 el.classList.add('is-ready');
269 el.classList.remove('pcms-embed-busy');
270 if (meta && meta.title && subEl) subEl.textContent = meta.title;
271 if (meta && meta.artwork) {
272 const art = el.querySelector('.pcms-embed-art');
273 if (art) { art.style.backgroundImage = `url('${meta.artwork}')`; art.classList.add('has-art'); }
274 }
275 if (meta && meta.duration) setProgress(0, meta.duration);
276 },
277 onPlay() { setPlayingUI(true); registry().setActive(self); },
278 onPause() { setPlayingUI(false); },
279 onEnded() { setPlayingUI(false); setProgress(0, dur); registry().release(self); },
280 onProgress(cur, total) { setProgress(cur, total); },
281 };
282
283 // API blocked/unreachable → plain platform iframe (graceful degradation).
284 // Mutual exclusion for this fallback runs via the blur heuristic
285 // (audio-player.js), because the card never gets .is-mounted.
286 function renderFallback() {
287 const fb = fallbackIframe(provider, ref, url);
288 if (!fb.src) { el.classList.remove('pcms-embed-busy'); el.classList.add('pcms-embed-error'); return; }
289 const iframe = document.createElement('iframe');
290 iframe.src = fb.src;
291 iframe.loading = 'lazy';
292 iframe.title = LABEL[provider] || provider;
293 iframe.setAttribute('allow', 'autoplay; encrypted-media; clipboard-write; picture-in-picture; fullscreen');
294 if (fb.fs) iframe.allowFullscreen = true;
295 iframe.style.cssText = fb.ratio
296 ? 'width:100%;aspect-ratio:16/9;border:0;display:block;'
297 : 'width:100%;height:' + (fb.h || '152px') + ';border:0;display:block;';
298 el.classList.remove('is-mounted', 'pcms-embed-busy');
299 el.classList.add('pcms-embed-fallback');
300 el.innerHTML = '';
301 el.appendChild(iframe);
302
303 // Mutual exclusion also for the fallback iframe. A cross-origin iframe
304 // cannot be paused via an API, so 'pause' = reload WITHOUT
305 // autoplay (= stops the audio, player stays visible/restartable).
306 // We register it as active: now (= user starts the embed) the
307 // site player/other embeds pause; and when the site player starts later,
308 // the registry pauses this fallback.
309 self.pause = function () {
310 try {
311 const noAuto = fb.src
312 .replace(/([?&])(?:autoplay=1|auto_play=true)(&|$)/gi, '$1')
313 .replace(/[?&]$/, '');
314 if (iframe.src === noAuto) {
315 // src ongewijzigd (bv. Spotify zonder autoplay) → forceer een reload
316 iframe.src = 'about:blank';
317 setTimeout(() => { try { iframe.src = noAuto; } catch (e) {} }, 30);
318 } else {
319 iframe.src = noAuto;
320 }
321 } catch (e) {}
322 };
323 registry().setActive(self);
324 }
325
326 // First interaction → mount the adapter + play. The button toggles after that.
327 async function ensureMountedAndPlay() {
328 if (mounted) { if (adapter) adapter.play(); return; }
329 mounted = true;
330 // Spotify: their player cannot be skinned (controls-only) and the
331 // iFrame API bundle often fails to initialise in practice (CDN-gating/503)
332 // → don't wait for the API, show the plain Spotify iframe immediately.
333 if (provider === 'spotify') { renderFallback(); return; }
334 el.classList.add('pcms-embed-busy');
335 try {
336 adapter = await MOUNTERS[provider](mountEl, { ref, url }, hooks);
337 if (el._pcmsApplyVol) el._pcmsApplyVol(); // onthouden volume toepassen
338 // is-mounted: CSS hides the poster (video) or Spotify facade and
339 // shows the real player. For SoundCloud our card+bar stays visible and
340 // the (functional) iframe remains hidden off-screen.
341 el.classList.add('is-mounted');
342 adapter.play();
343 } catch (err) {
344 console.warn('[pcms-embed] API unavailable, falling back to plain iframe', provider, err);
345 renderFallback();
346 }
347 }
348
349 if (posterBtn) posterBtn.addEventListener('click', ensureMountedAndPlay);
350 if (ppBtn) ppBtn.addEventListener('click', () => {
351 if (!mounted) return ensureMountedAndPlay();
352 if (playing) { adapter && adapter.pause(); } else { adapter && adapter.play(); }
353 });
354 if (seekEl) seekEl.addEventListener('click', (e) => {
355 if (!adapter || !adapter.seek || !dur) return;
356 const rect = seekEl.getBoundingClientRect();
357 const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
358 adapter.seek(ratio * dur);
359 });
360 // Volume: slider sets vol (0-100) and applies it to the adapter (YT/SC support
361 // setVolume). The value is remembered and reapplied after mounting.
362 function applyVol() {
363 if (adapter && adapter.setVolume) { try { adapter.setVolume(vol); } catch (e) {} }
364 if (muteBtn) muteBtn.innerHTML = vol === 0 ? ICON.muted : ICON.volume;
365 if (volEl && String(volEl.value) !== String(vol)) volEl.value = vol;
366 el.classList.toggle('is-muted', vol === 0);
367 }
368 if (volEl) volEl.addEventListener('input', () => { vol = parseInt(volEl.value, 10) || 0; if (vol > 0) preMuteVol = vol; applyVol(); });
369 if (muteBtn) muteBtn.addEventListener('click', () => {
370 if (vol > 0) { preMuteVol = vol; vol = 0; } else { vol = preMuteVol || 100; }
371 applyVol();
372 });
373 el._pcmsApplyVol = applyVol; // called by ensureMountedAndPlay after mount
374 }
375
376 function escAttr(s) {
377 return String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
378 }
379
380 // ============================================================
381 // 4. Adapters — normalise the 3 very different APIs to one common shape.
382 // Each mounter returns { play, pause, seek } and calls hooks with
383 // seconds (units are normalised here).
384 // ============================================================
385 const MOUNTERS = {
386 // ---- YouTube: IFrame Player API, eigen controls (controls:0) ----
387 async youtube(mountEl, { ref, url }, hooks) {
388 const YT = await ytApi();
389 const id = ytId(ref, url);
390 return new Promise((resolve, reject) => {
391 let pollTimer = null;
392 const player = new YT.Player(mountEl, {
393 videoId: id,
394 host: 'https://www.youtube-nocookie.com',
395 playerVars: {
396 controls: 0, modestbranding: 1, rel: 0, playsinline: 1, fs: 0,
397 disablekb: 1, iv_load_policy: 3, origin: window.location.origin,
398 },
399 events: {
400 onReady() {
401 let d = 0; try { d = player.getDuration() || 0; } catch (e) {}
402 hooks.onReady({ duration: d });
403 resolve({
404 play() { try { player.playVideo(); } catch (e) {} },
405 pause() { try { player.pauseVideo(); } catch (e) {} },
406 seek(sec) { try { player.seekTo(sec, true); } catch (e) {} },
407 setVolume(pct) { try { if (pct <= 0) player.mute(); else { player.unMute(); player.setVolume(pct); } } catch (e) {} },
408 destroy() {
409 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
410 try { player.destroy(); } catch (e) {}
411 },
412 });
413 },
414 onStateChange(e) {
415 // -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued
416 if (e.data === 1) {
417 hooks.onPlay();
418 if (!pollTimer) pollTimer = setInterval(() => {
419 try { hooks.onProgress(player.getCurrentTime() || 0, player.getDuration() || 0); } catch (e) {}
420 }, 250);
421 } else if (e.data === 2) {
422 hooks.onPause();
423 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
424 } else if (e.data === 0) {
425 hooks.onEnded();
426 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
427 }
428 },
429 onError() { reject(new Error('YT error')); },
430 },
431 });
432 });
433 },
434
435 // ---- SoundCloud: Widget API, visual=false, custom controls ----
436 async soundcloud(mountEl, { ref, url }, hooks) {
437 const SC = await scApi();
438 // Build the iframe ourselves (bare bar) and then attach SC.Widget to it.
439 const iframe = document.createElement('iframe');
440 iframe.allow = 'autoplay';
441 iframe.title = 'SoundCloud';
442 const params = new URLSearchParams({
443 url: ref || url, visual: 'false', auto_play: 'false', hide_related: 'true',
444 show_comments: 'false', show_user: 'false', show_teaser: 'false',
445 sharing: 'false', buy: 'false', download: 'false', show_artwork: 'true',
446 single_active: 'true', color: 'ff5500',
447 });
448 iframe.src = 'https://w.soundcloud.com/player/?' + params.toString();
449 mountEl.appendChild(iframe);
450 const widget = SC.Widget(iframe);
451 const E = SC.Widget.Events;
452 return new Promise((resolve, reject) => {
453 let resolved = false;
454 widget.bind(E.READY, () => {
455 widget.getCurrentSound((sound) => {
456 const meta = sound ? {
457 title: sound.title || '',
458 artwork: (sound.artwork_url || (sound.user && sound.user.avatar_url) || '').replace('-large', '-t300x300'),
459 } : {};
460 widget.getDuration((ms) => { meta.duration = (ms || 0) / 1000; hooks.onReady(meta); });
461 });
462 resolved = true;
463 resolve({
464 play() { widget.play(); },
465 pause() { widget.pause(); },
466 seek(sec) { widget.seekTo(sec * 1000); },
467 setVolume(pct) { try { widget.setVolume(pct); } catch (e) {} },
468 destroy() {
469 try { ['READY', 'PLAY', 'PAUSE', 'FINISH', 'PLAY_PROGRESS', 'ERROR'].forEach((k) => E[k] && widget.unbind(E[k])); } catch (e) {}
470 try { iframe.remove(); } catch (e) {}
471 },
472 });
473 });
474 widget.bind(E.PLAY, () => hooks.onPlay());
475 widget.bind(E.PAUSE, () => hooks.onPause());
476 widget.bind(E.FINISH, () => hooks.onEnded());
477 widget.bind(E.PLAY_PROGRESS, (d) => {
478 hooks.onProgress((d.currentPosition || 0) / 1000, 0);
479 });
480 widget.bind(E.ERROR, () => { if (!resolved) reject(new Error('SC error')); });
481 setTimeout(() => { if (!resolved) reject(new Error('SC timeout')); }, 12000);
482 });
483 },
484
485 // ---- Spotify: iFrame API — controls + our frame; no custom skin ----
486 async spotify(mountEl, { ref, url }, hooks) {
487 const IFrameAPI = await spotifyApi();
488 const uri = ref || url;
489 return new Promise((resolve, reject) => {
490 let lastPaused = true, resolved = false;
491 IFrameAPI.createController(mountEl, { uri, width: '100%', height: 152 }, (controller) => {
492 controller.addListener('ready', () => {
493 hooks.onReady({});
494 resolved = true;
495 resolve({
496 play() { try { controller.resume(); } catch (e) { try { controller.play(); } catch (e2) {} } },
497 pause() { try { controller.pause(); } catch (e) {} },
498 seek(sec) { try { controller.seek(sec); } catch (e) {} },
499 destroy() { try { controller.destroy(); } catch (e) {} },
500 });
501 });
502 controller.addListener('playback_update', (e) => {
503 const d = e && e.data ? e.data : {};
504 hooks.onProgress((d.position || 0) / 1000, (d.duration || 0) / 1000);
505 // No reliable 'ended' event from Spotify; we treat every
506 // isPaused transition as play/pause. End = just a pause (the bar
507 // stays at the end position rather than misleadingly jumping to 0).
508 if (d.isPaused === false && lastPaused) { lastPaused = false; hooks.onPlay(); }
509 else if (d.isPaused === true && !lastPaused) { lastPaused = true; hooks.onPause(); }
510 });
511 });
512 setTimeout(() => { if (!resolved) reject(new Error('Spotify timeout')); }, 12000);
513 });
514 },
515 };
516
517 // ============================================================
518 // 5. Scan + HTMX/DOM-mutation-aware (the site partially navigates via HTMX swaps)
519 // ============================================================
520 function scan(root) {
521 (root || document).querySelectorAll('.folio-embed[data-embed-provider]').forEach((el) => {
522 if (el.dataset.embedInit) return;
523 el.dataset.embedInit = '1';
524 try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard failed', e); }
525 });
526 }
527
528 if (document.readyState === 'loading') {
529 document.addEventListener('DOMContentLoaded', () => scan(document));
530 } else {
531 scan(document);
532 }
533 // HTMX replaces #pcms-main on internal navigation → rescan.
534 document.body.addEventListener('htmx:afterSwap', (e) => scan(e.target || document));
535 document.body.addEventListener('htmx:load', (e) => scan(e.target || document));
536
537 // Teardown on DOM removal (HTMX replaces #pcms-main innerHTML, or an SPA-like
538 // swap). Without this, YouTube poll timers + adapters/iframes linger when
539 // navigating away while an embed is playing → CPU/memory leak that accumulates
540 // per navigation. We call el._pcmsDestroy() for every card that truly leaves the document.
541 const teardownObserver = new MutationObserver((muts) => {
542 for (const m of muts) {
543 m.removedNodes.forEach((node) => {
544 if (node.nodeType !== 1) return;
545 const cards = [];
546 if (node.matches && node.matches('.folio-embed[data-embed-init]')) cards.push(node);
547 if (node.querySelectorAll) node.querySelectorAll('.folio-embed[data-embed-init]').forEach((c) => cards.push(c));
548 cards.forEach((c) => {
549 if (typeof c._pcmsDestroy === 'function' && !document.contains(c)) {
550 try { c._pcmsDestroy(); } catch (e) {}
551 }
552 });
553 });
554 }
555 });
556 try { teardownObserver.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
557
558 window.pcmsEmbedPlayer = { scan, registry };
559})();
Note: See TracBrowser for help on using the repository browser.