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

main
Last change on this file since 4fbe2c1 was 995b100, checked in by Robin <roboburr@…>, 3 weeks ago

YouTube-playlists in een post, met dezelfde parsing als de hub

Een link naar een YouTube-album speelde het eerste nummer en stopte, en
een kale playlist-link werd helemaal niet als YouTube herkend.
detectProvider hield alleen de video-id vast en gooide list= weg.

De ref kent nu drie vormen, exact die van de Klonkt hub, zodat één ref
tussen de twee heen en weer kan zonder vertaling:

"<video>" een video
"<video>?list=<L>" die video, en door de lijst heen
"list:<L>" de hele playlist (YouTube's videoseries)

list mag voor of na v= staan en is in een gebakken href vaak
entity-gecodeerd (&amp;), dus er wordt over de hele URL gezocht in plaats
van op een vaste volgorde. youtube-nocookie.com telt mee als host: dat is
wat een embed zelf uitzendt en dus wat mensen terugplakken.

videoseries is EXACT elf tekens, net als een video-id. Geen lengte- of
grensregel vangt hem -- hij moet bij naam uitgesloten worden. Ik liep er
tijdens het bouwen zelf in met een grenscontrole die eroverheen leek te
gaan; vandaar de test die hem apart vastlegt.

Aan de clientkant (embed-player.js) kennen ytId/ytList/ytEmbedSrc dezelfde
drie vormen. Twee dingen die daar meekomen:

  • De poster interpoleerde een null video-id tot i.ytimg.com/vi/null/hqdefault.jpg -- een 404 als achtergrond. Een kale playlist heeft geen video, en krijgt nu gewoon geen poster.
  • YouTube meldt binnen een playlist "ended" TUSSEN elk tweetal nummers. Daar meteen onEnded op vuren geeft de wachtrij door na nummer één en kapt het album af. Op een lijst wachten we daarom 2,5 seconde, en telt alleen een stilte die niet door het volgende nummer wordt onderbroken -- dezelfde regel als in de hub.

De dode youtubeIframe() is meegegaan: hij wordt nergens aangeroepen, maar
een terugval die de playlist stil laat vallen is de ergste soort, want
die ziet eruit alsof het werkte.

Wat hier NIET in zit: de hub haalt ook de nummerlijst van een playlist op
(Data API met sleutel, anders keyless via de RSS-feed). Dat vraagt een
sleutel en een bewaarplek en is een aparte keuze.

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

  • Property mode set to 100644
File size: 30.6 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 src = ytEmbedSrc(ref, url, 'autoplay=1&rel=0');
116 if (src) return { src, 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 // Three ref shapes, the same ones AudioEmbedService.detectProvider produces
137 // and the same ones the Klonkt hub uses, so a ref travels between them
138 // unchanged: "<video>" | "<video>?list=<L>" | "list:<L>"
139 //
140 // ytId answers only "which VIDEO", because that is what a poster thumbnail
141 // needs; for a bare playlist there is no video and it returns null.
142 function ytId(ref, url) {
143 const r = String(ref || '');
144 if (r.indexOf('list:') === 0) return null; // a playlist has no single video
145 const bare = r.split('?list=')[0];
146 if (/^[A-Za-z0-9_-]{11}$/.test(bare)) return bare;
147 const m = (url || '').match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
148 if (m) return m[1];
149 return null; // no blind slice — an invalid ref returns nothing rather than a broken id
150 }
151 /** The playlist id of a ref (or of the URL it came from), else null. */
152 function ytList(ref, url) {
153 const r = String(ref || '');
154 if (r.indexOf('list:') === 0) return r.slice(5) || null;
155 const i = r.indexOf('?list=');
156 if (i > 0) return r.slice(i + 6) || null;
157 const m = (url || '').match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/);
158 return m ? m[1] : null;
159 }
160 /** The /embed/ URL for a ref. A bare playlist embeds as `videoseries`. */
161 function ytEmbedSrc(ref, url, query) {
162 const base = 'https://www.youtube-nocookie.com/embed/';
163 const id = ytId(ref, url);
164 const list = ytList(ref, url);
165 let src;
166 if (!id && list) src = base + 'videoseries?list=' + encodeURIComponent(list);
167 else if (id && list) src = base + encodeURIComponent(id) + '?list=' + encodeURIComponent(list);
168 else if (id) src = base + encodeURIComponent(id);
169 else return null;
170 return src + (src.indexOf('?') > 0 ? '&' : '?') + (query || '');
171 }
172 // Only allow http(s) as href (defense-in-depth against javascript:/data: URIs).
173 function safeHref(u) {
174 try { const p = new URL(u, location.href); return (p.protocol === 'http:' || p.protocol === 'https:') ? u : '#'; }
175 catch (e) { return '#'; }
176 }
177 const ICON = {
178 play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>',
179 pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4h4v16H7zM13 4h4v16h-4z" fill="currentColor"/></svg>',
180 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>',
181 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>',
182 };
183 const LABEL = { youtube: 'YouTube', soundcloud: 'SoundCloud', spotify: 'Spotify' };
184
185 // Touch / coarse-pointer (phones, most tablets): the custom JS-mounted card is
186 // fragile — Safari tracking-protection blocks the platform API scripts AND the
187 // poster thumbnails (i.ytimg.com) → a black/blank box. Render the plain, reliable
188 // platform iframe directly instead (same approach the News feed uses). The
189 // padding-ratio wrapper reserves height on every browser incl. old iOS Safari
190 // (no `aspect-ratio` dependency). Desktop keeps the rich custom card.
191 const IS_TOUCH = !!(window.matchMedia && window.matchMedia('(hover: none) and (pointer: coarse)').matches);
192
193 function mountPlain(el, provider, ref, url) {
194 el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider, 'pcms-embed-plain');
195 el.classList.remove('pcms-embed-loading');
196 let html = '';
197 if (provider === 'youtube') {
198 const src = ytEmbedSrc(ref, url, 'rel=0');
199 html = src
200 ? '<div class="pcms-embed-ratio"><iframe src="' + escAttr(src) + '" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>'
201 : '<a class="pcms-embed-plain-link" href="' + escAttr(safeHref(url)) + '" target="_blank" rel="noopener">YouTube</a>';
202 } else if (provider === 'soundcloud') {
203 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>';
204 } else if (provider === 'spotify') {
205 const m = (ref || '').match(/^spotify:(\w+):(\w+)$/);
206 const src = m ? 'https://open.spotify.com/embed/' + m[1] + '/' + m[2] : (url || '');
207 html = '<iframe class="pcms-embed-plain-frame" style="height:152px" src="' + escAttr(src) + '" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>';
208 } else {
209 html = '<a class="pcms-embed-plain-link" href="' + escAttr(safeHref(url)) + '" target="_blank" rel="noopener">' + (LABEL[provider] || provider) + '</a>';
210 }
211 el.innerHTML = html;
212 }
213
214 // ============================================================
215 // 3. Card controller — builds the on-brand card + delegates to an adapter
216 // ============================================================
217 function buildCard(el) {
218 const provider = el.dataset.embedProvider;
219 const ref = el.dataset.embedRef || '';
220 const url = el.dataset.embedUrl || '';
221 if (!provider) return;
222 if (IS_TOUCH) { mountPlain(el, provider, ref, url); return; }
223
224 el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider);
225 el.classList.remove('pcms-embed-loading');
226
227 // Our player side (registry peer). pause() points to the adapter once
228 // mounted; before that it's a no-op.
229 let adapter = null;
230 const self = { pause() { if (adapter && adapter.pause) { try { adapter.pause(); } catch (e) {} } } };
231
232 // Teardown hook: called by the MutationObserver when this card leaves the
233 // DOM (HTMX swap) → clean up the adapter (timers/iframes) + release the registry,
234 // so no poll timers or players keep leaking.
235 el._pcmsDestroy = function () {
236 try { if (adapter && adapter.destroy) adapter.destroy(); } catch (e) {}
237 registry().release(self);
238 };
239
240 let playing = false;
241 let mounted = false;
242 let dur = 0;
243
244 // --- Mount the UI (differs per provider type) ---
245 const isVideo = provider === 'youtube';
246 const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls
247 // A bare playlist has no video id, and interpolating null gave
248 // i.ytimg.com/vi/null/hqdefault.jpg — a 404 painted as the poster.
249 const posterId = provider === 'youtube' ? ytId(ref, url) : null;
250 const poster = posterId ? `https://i.ytimg.com/vi/${posterId}/hqdefault.jpg` : '';
251
252 el.innerHTML = ''
253 + (isVideo
254 ? `<div class="pcms-embed-stage"><div class="pcms-embed-mount"></div>`
255 + `<button type="button" class="pcms-embed-poster"${poster ? ` style="background-image:url('${poster}')"` : ''} aria-label="Afspelen">`
256 + `<span class="pcms-embed-bigplay">${ICON.play}</span></button></div>`
257 : `<div class="pcms-embed-audio">`
258 + `<button type="button" class="pcms-embed-art" aria-label="Afspelen"><span class="pcms-embed-bigplay">${ICON.play}</span></button>`
259 + `<div class="pcms-embed-info"><div class="pcms-embed-title">${LABEL[provider] || provider}</div>`
260 + `<div class="pcms-embed-sub"></div></div>`
261 + `<div class="pcms-embed-mount"></div></div>`)
262 + (custom
263 ? `<div class="pcms-embed-bar">`
264 + `<button type="button" class="pcms-embed-pp" aria-label="Afspelen/pauzeren">${ICON.play}</button>`
265 + `<span class="pcms-embed-cur mono">0:00</span>`
266 + `<div class="pcms-embed-seek" role="slider" aria-label="Voortgang" tabindex="0"><div class="pcms-embed-seek-fill"></div></div>`
267 + `<span class="pcms-embed-dur mono">0:00</span>`
268 + `<button type="button" class="pcms-embed-mute" aria-label="Dempen">${ICON.volume}</button>`
269 + `<input type="range" class="pcms-embed-vol" min="0" max="100" value="100" aria-label="Volume">`
270 + `<a class="pcms-embed-badge" href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">${LABEL[provider] || provider}</a>`
271 + `</div>`
272 : `<div class="pcms-embed-frame-badge"><a href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">via ${LABEL[provider] || provider}</a></div>`);
273
274 const mountEl = el.querySelector('.pcms-embed-mount');
275 const ppBtn = el.querySelector('.pcms-embed-pp');
276 const curEl = el.querySelector('.pcms-embed-cur');
277 const durEl = el.querySelector('.pcms-embed-dur');
278 const seekEl = el.querySelector('.pcms-embed-seek');
279 const seekFill = el.querySelector('.pcms-embed-seek-fill');
280 const posterBtn = el.querySelector('.pcms-embed-poster, .pcms-embed-art');
281 const subEl = el.querySelector('.pcms-embed-sub');
282 const volEl = el.querySelector('.pcms-embed-vol');
283 const muteBtn = el.querySelector('.pcms-embed-mute');
284 let vol = 100; // current volume 0-100 (applied to the adapter)
285 let preMuteVol = 100;
286
287 function setPlayingUI(on) {
288 playing = on;
289 el.classList.toggle('is-playing', on);
290 if (ppBtn) ppBtn.innerHTML = on ? ICON.pause : ICON.play;
291 }
292 function setProgress(cur, total) {
293 if (total > 0) { dur = total; if (durEl) durEl.textContent = fmt(total); }
294 if (curEl) curEl.textContent = fmt(cur);
295 if (seekFill && dur > 0) seekFill.style.width = Math.max(0, Math.min(100, (cur / dur) * 100)) + '%';
296 }
297
298 const hooks = {
299 onReady(meta) {
300 el.classList.add('is-ready');
301 el.classList.remove('pcms-embed-busy');
302 if (meta && meta.title && subEl) subEl.textContent = meta.title;
303 if (meta && meta.artwork) {
304 const art = el.querySelector('.pcms-embed-art');
305 if (art) { art.style.backgroundImage = `url('${meta.artwork}')`; art.classList.add('has-art'); }
306 }
307 if (meta && meta.duration) setProgress(0, meta.duration);
308 },
309 onPlay() { setPlayingUI(true); registry().setActive(self); },
310 onPause() { setPlayingUI(false); },
311 onEnded() { setPlayingUI(false); setProgress(0, dur); registry().release(self); },
312 onProgress(cur, total) { setProgress(cur, total); },
313 };
314
315 // API blocked/unreachable → plain platform iframe (graceful degradation).
316 // Mutual exclusion for this fallback runs via the blur heuristic
317 // (audio-player.js), because the card never gets .is-mounted.
318 function renderFallback() {
319 const fb = fallbackIframe(provider, ref, url);
320 if (!fb.src) { el.classList.remove('pcms-embed-busy'); el.classList.add('pcms-embed-error'); return; }
321 const iframe = document.createElement('iframe');
322 iframe.src = fb.src;
323 iframe.loading = 'lazy';
324 iframe.title = LABEL[provider] || provider;
325 iframe.setAttribute('allow', 'autoplay; encrypted-media; clipboard-write; picture-in-picture; fullscreen');
326 if (fb.fs) iframe.allowFullscreen = true;
327 iframe.style.cssText = fb.ratio
328 ? 'width:100%;aspect-ratio:16/9;border:0;display:block;'
329 : 'width:100%;height:' + (fb.h || '152px') + ';border:0;display:block;';
330 el.classList.remove('is-mounted', 'pcms-embed-busy');
331 el.classList.add('pcms-embed-fallback');
332 el.innerHTML = '';
333 el.appendChild(iframe);
334
335 // Mutual exclusion also for the fallback iframe. A cross-origin iframe
336 // cannot be paused via an API, so 'pause' = reload WITHOUT
337 // autoplay (= stops the audio, player stays visible/restartable).
338 // We register it as active: now (= user starts the embed) the
339 // site player/other embeds pause; and when the site player starts later,
340 // the registry pauses this fallback.
341 self.pause = function () {
342 try {
343 const noAuto = fb.src
344 .replace(/([?&])(?:autoplay=1|auto_play=true)(&|$)/gi, '$1')
345 .replace(/[?&]$/, '');
346 if (iframe.src === noAuto) {
347 // src ongewijzigd (bv. Spotify zonder autoplay) → forceer een reload
348 iframe.src = 'about:blank';
349 setTimeout(() => { try { iframe.src = noAuto; } catch (e) {} }, 30);
350 } else {
351 iframe.src = noAuto;
352 }
353 } catch (e) {}
354 };
355 registry().setActive(self);
356 }
357
358 // First interaction → mount the adapter + play. The button toggles after that.
359 async function ensureMountedAndPlay() {
360 if (mounted) { if (adapter) adapter.play(); return; }
361 mounted = true;
362 // Spotify: their player cannot be skinned (controls-only) and the
363 // iFrame API bundle often fails to initialise in practice (CDN-gating/503)
364 // → don't wait for the API, show the plain Spotify iframe immediately.
365 if (provider === 'spotify') { renderFallback(); return; }
366 el.classList.add('pcms-embed-busy');
367 try {
368 adapter = await MOUNTERS[provider](mountEl, { ref, url }, hooks);
369 if (el._pcmsApplyVol) el._pcmsApplyVol(); // onthouden volume toepassen
370 // is-mounted: CSS hides the poster (video) or Spotify facade and
371 // shows the real player. For SoundCloud our card+bar stays visible and
372 // the (functional) iframe remains hidden off-screen.
373 el.classList.add('is-mounted');
374 adapter.play();
375 } catch (err) {
376 console.warn('[pcms-embed] API unavailable, falling back to plain iframe', provider, err);
377 renderFallback();
378 }
379 }
380
381 if (posterBtn) posterBtn.addEventListener('click', ensureMountedAndPlay);
382 if (ppBtn) ppBtn.addEventListener('click', () => {
383 if (!mounted) return ensureMountedAndPlay();
384 if (playing) { adapter && adapter.pause(); } else { adapter && adapter.play(); }
385 });
386 if (seekEl) seekEl.addEventListener('click', (e) => {
387 if (!adapter || !adapter.seek || !dur) return;
388 const rect = seekEl.getBoundingClientRect();
389 const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
390 adapter.seek(ratio * dur);
391 });
392 // Volume: slider sets vol (0-100) and applies it to the adapter (YT/SC support
393 // setVolume). The value is remembered and reapplied after mounting.
394 function applyVol() {
395 if (adapter && adapter.setVolume) { try { adapter.setVolume(vol); } catch (e) {} }
396 if (muteBtn) muteBtn.innerHTML = vol === 0 ? ICON.muted : ICON.volume;
397 if (volEl && String(volEl.value) !== String(vol)) volEl.value = vol;
398 el.classList.toggle('is-muted', vol === 0);
399 }
400 if (volEl) volEl.addEventListener('input', () => { vol = parseInt(volEl.value, 10) || 0; if (vol > 0) preMuteVol = vol; applyVol(); });
401 if (muteBtn) muteBtn.addEventListener('click', () => {
402 if (vol > 0) { preMuteVol = vol; vol = 0; } else { vol = preMuteVol || 100; }
403 applyVol();
404 });
405 el._pcmsApplyVol = applyVol; // called by ensureMountedAndPlay after mount
406 }
407
408 function escAttr(s) {
409 return String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
410 }
411
412 // ============================================================
413 // 4. Adapters — normalise the 3 very different APIs to one common shape.
414 // Each mounter returns { play, pause, seek } and calls hooks with
415 // seconds (units are normalised here).
416 // ============================================================
417 const MOUNTERS = {
418 // ---- YouTube: IFrame Player API, eigen controls (controls:0) ----
419 async youtube(mountEl, { ref, url }, hooks) {
420 const YT = await ytApi();
421 const id = ytId(ref, url);
422 const list = ytList(ref, url);
423 return new Promise((resolve, reject) => {
424 let pollTimer = null;
425 let endTimer = null; // see onStateChange: a list "ends" between items
426 const player = new YT.Player(mountEl, {
427 // With a video AND a list, `list` makes the player continue into the
428 // playlist after this song. Without a video it is the playlist
429 // itself, and then listType says so -- videoId must stay absent, or
430 // the API loads that one video and forgets the list.
431 ...(id ? { videoId: id } : {}),
432 host: 'https://www.youtube-nocookie.com',
433 playerVars: {
434 controls: 0, modestbranding: 1, rel: 0, playsinline: 1, fs: 0,
435 disablekb: 1, iv_load_policy: 3, origin: window.location.origin,
436 ...(list ? (id ? { list } : { list, listType: 'playlist' }) : {}),
437 },
438 events: {
439 onReady() {
440 let d = 0; try { d = player.getDuration() || 0; } catch (e) {}
441 hooks.onReady({ duration: d });
442 resolve({
443 play() { try { player.playVideo(); } catch (e) {} },
444 pause() { try { player.pauseVideo(); } catch (e) {} },
445 seek(sec) { try { player.seekTo(sec, true); } catch (e) {} },
446 setVolume(pct) { try { if (pct <= 0) player.mute(); else { player.unMute(); player.setVolume(pct); } } catch (e) {} },
447 destroy() {
448 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
449 if (endTimer) { clearTimeout(endTimer); endTimer = null; }
450 try { player.destroy(); } catch (e) {}
451 },
452 });
453 },
454 onStateChange(e) {
455 // -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued
456 if (e.data === 1) {
457 // A next playlist item started, so the pending "it is over" was
458 // false alarm.
459 if (endTimer) { clearTimeout(endTimer); endTimer = null; }
460 hooks.onPlay();
461 if (!pollTimer) pollTimer = setInterval(() => {
462 try { hooks.onProgress(player.getCurrentTime() || 0, player.getDuration() || 0); } catch (e) {}
463 }, 250);
464 } else if (e.data === 2) {
465 hooks.onPause();
466 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
467 } else if (e.data === 0) {
468 if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
469 // INSIDE a playlist, YouTube reports "ended" between every two
470 // songs as well. Firing onEnded there hands the queue on after
471 // song one and cuts the album off. So on a list we wait, and
472 // only a silence that is not interrupted by the next song
473 // counts as the end. Same rule as the hub (2.5s).
474 if (list) {
475 if (!endTimer) endTimer = setTimeout(() => { endTimer = null; hooks.onEnded(); }, 2500);
476 } else {
477 hooks.onEnded();
478 }
479 }
480 },
481 onError() { reject(new Error('YT error')); },
482 },
483 });
484 });
485 },
486
487 // ---- SoundCloud: Widget API, visual=false, custom controls ----
488 async soundcloud(mountEl, { ref, url }, hooks) {
489 const SC = await scApi();
490 // Build the iframe ourselves (bare bar) and then attach SC.Widget to it.
491 const iframe = document.createElement('iframe');
492 iframe.allow = 'autoplay';
493 iframe.title = 'SoundCloud';
494 const params = new URLSearchParams({
495 url: ref || url, visual: 'false', auto_play: 'false', hide_related: 'true',
496 show_comments: 'false', show_user: 'false', show_teaser: 'false',
497 sharing: 'false', buy: 'false', download: 'false', show_artwork: 'true',
498 single_active: 'true', color: 'ff5500',
499 });
500 iframe.src = 'https://w.soundcloud.com/player/?' + params.toString();
501 mountEl.appendChild(iframe);
502 const widget = SC.Widget(iframe);
503 const E = SC.Widget.Events;
504 return new Promise((resolve, reject) => {
505 let resolved = false;
506 widget.bind(E.READY, () => {
507 widget.getCurrentSound((sound) => {
508 const meta = sound ? {
509 title: sound.title || '',
510 artwork: (sound.artwork_url || (sound.user && sound.user.avatar_url) || '').replace('-large', '-t300x300'),
511 } : {};
512 widget.getDuration((ms) => { meta.duration = (ms || 0) / 1000; hooks.onReady(meta); });
513 });
514 resolved = true;
515 resolve({
516 play() { widget.play(); },
517 pause() { widget.pause(); },
518 seek(sec) { widget.seekTo(sec * 1000); },
519 setVolume(pct) { try { widget.setVolume(pct); } catch (e) {} },
520 destroy() {
521 try { ['READY', 'PLAY', 'PAUSE', 'FINISH', 'PLAY_PROGRESS', 'ERROR'].forEach((k) => E[k] && widget.unbind(E[k])); } catch (e) {}
522 try { iframe.remove(); } catch (e) {}
523 },
524 });
525 });
526 widget.bind(E.PLAY, () => hooks.onPlay());
527 widget.bind(E.PAUSE, () => hooks.onPause());
528 widget.bind(E.FINISH, () => hooks.onEnded());
529 widget.bind(E.PLAY_PROGRESS, (d) => {
530 hooks.onProgress((d.currentPosition || 0) / 1000, 0);
531 });
532 widget.bind(E.ERROR, () => { if (!resolved) reject(new Error('SC error')); });
533 setTimeout(() => { if (!resolved) reject(new Error('SC timeout')); }, 12000);
534 });
535 },
536
537 // ---- Spotify: iFrame API — controls + our frame; no custom skin ----
538 async spotify(mountEl, { ref, url }, hooks) {
539 const IFrameAPI = await spotifyApi();
540 const uri = ref || url;
541 return new Promise((resolve, reject) => {
542 let lastPaused = true, resolved = false;
543 IFrameAPI.createController(mountEl, { uri, width: '100%', height: 152 }, (controller) => {
544 controller.addListener('ready', () => {
545 hooks.onReady({});
546 resolved = true;
547 resolve({
548 play() { try { controller.resume(); } catch (e) { try { controller.play(); } catch (e2) {} } },
549 pause() { try { controller.pause(); } catch (e) {} },
550 seek(sec) { try { controller.seek(sec); } catch (e) {} },
551 destroy() { try { controller.destroy(); } catch (e) {} },
552 });
553 });
554 controller.addListener('playback_update', (e) => {
555 const d = e && e.data ? e.data : {};
556 hooks.onProgress((d.position || 0) / 1000, (d.duration || 0) / 1000);
557 // No reliable 'ended' event from Spotify; we treat every
558 // isPaused transition as play/pause. End = just a pause (the bar
559 // stays at the end position rather than misleadingly jumping to 0).
560 if (d.isPaused === false && lastPaused) { lastPaused = false; hooks.onPlay(); }
561 else if (d.isPaused === true && !lastPaused) { lastPaused = true; hooks.onPause(); }
562 });
563 });
564 setTimeout(() => { if (!resolved) reject(new Error('Spotify timeout')); }, 12000);
565 });
566 },
567 };
568
569 // ============================================================
570 // 5. Scan + HTMX/DOM-mutation-aware (the site partially navigates via HTMX swaps)
571 // ============================================================
572 function scan(root) {
573 (root || document).querySelectorAll('.folio-embed[data-embed-provider]').forEach((el) => {
574 if (el.dataset.embedInit) return;
575 el.dataset.embedInit = '1';
576 try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard failed', e); }
577 });
578 }
579
580 if (document.readyState === 'loading') {
581 document.addEventListener('DOMContentLoaded', () => scan(document));
582 } else {
583 scan(document);
584 }
585 // HTMX replaces #pcms-main on internal navigation → rescan.
586 document.body.addEventListener('htmx:afterSwap', (e) => scan(e.target || document));
587 document.body.addEventListener('htmx:load', (e) => scan(e.target || document));
588
589 // Teardown on DOM removal (HTMX replaces #pcms-main innerHTML, or an SPA-like
590 // swap). Without this, YouTube poll timers + adapters/iframes linger when
591 // navigating away while an embed is playing → CPU/memory leak that accumulates
592 // per navigation. We call el._pcmsDestroy() for every card that truly leaves the document.
593 const teardownObserver = new MutationObserver((muts) => {
594 for (const m of muts) {
595 m.removedNodes.forEach((node) => {
596 if (node.nodeType !== 1) return;
597 const cards = [];
598 if (node.matches && node.matches('.folio-embed[data-embed-init]')) cards.push(node);
599 if (node.querySelectorAll) node.querySelectorAll('.folio-embed[data-embed-init]').forEach((c) => cards.push(c));
600 cards.forEach((c) => {
601 if (typeof c._pcmsDestroy === 'function' && !document.contains(c)) {
602 try { c._pcmsDestroy(); } catch (e) {}
603 }
604 });
605 });
606 }
607 });
608 try { teardownObserver.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
609
610 window.pcmsEmbedPlayer = { scan, registry };
611})();
Note: See TracBrowser for help on using the repository browser.