/** * Klonkt Embed Player — custom, on-brand media embeds on top of the REAL * player APIs (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API). * * The server (AudioEmbedService) renders a placeholder per embed: *
* This script wraps it in our own card (cover/poster + our play button + * progress bar in brand style) and controls the underlying player via the * platform API, so play/pause/progress are in OUR hands. * * Capability honesty: * - youtube/soundcloud → fully custom controls (native chrome hidden). * - spotify → controls + our frame around it; Spotify's own UI * stays inside (no alternative without Premium+OAuth). * * Mutual exclusion: every player (incl. the site audio player in audio-player.js) * registers itself in window.pcmsMediaRegistry. Starting one pauses the previous. * This replaces the old focus/blur heuristic with real play events. * * Singleton — guard against double-init (HTMX can reload scripts). */ (function () { if (window.pcmsEmbedPlayer) return; // ============================================================ // 0. Shared playback registry (also used by audio-player.js) // ============================================================ function registry() { if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry; const r = { _active: null, // Mark `player` as the sole active one; pause the previous. setActive(player) { if (this._active && this._active !== player && this._active.pause) { try { this._active.pause(); } catch (e) {} } this._active = player; }, release(player) { if (this._active === player) this._active = null; }, }; window.pcmsMediaRegistry = r; return r; } // ============================================================ // 1. Lazy script loaders — 1 promise per platform, shared across N embeds. // A platform script is only loaded when an embed from that platform // is actually started on the page. // ============================================================ const scripts = {}; function loadScript(src) { return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = src; s.async = true; s.onload = () => resolve(); s.onerror = () => reject(new Error('embed script failed: ' + src)); document.head.appendChild(s); }); } // Important: all loaders REJECT on script errors (e.g. an ad-blocker blocking // the API script) and on timeout — so the caller can gracefully fall back to // the plain platform iframe instead of hanging indefinitely. const API_TIMEOUT = 8000; // YouTube: global callback onYouTubeIframeAPIReady (once) → wrap in a promise. function ytApi() { if (scripts.yt) return scripts.yt; scripts.yt = new Promise((resolve, reject) => { if (window.YT && window.YT.Player) return resolve(window.YT); const prev = window.onYouTubeIframeAPIReady; window.onYouTubeIframeAPIReady = function () { if (typeof prev === 'function') { try { prev(); } catch (e) {} } resolve(window.YT); }; loadScript('https://www.youtube.com/iframe_api').catch(reject); setTimeout(() => reject(new Error('YT API timeout')), API_TIMEOUT); }); return scripts.yt; } // SoundCloud: no global ready callback; resolve on script onload, then // each widget waits for its own SC.Widget.Events.READY. function scApi() { if (scripts.sc) return scripts.sc; scripts.sc = new Promise((resolve, reject) => { if (window.SC && window.SC.Widget) return resolve(window.SC); loadScript('https://w.soundcloud.com/player/api.js') .then(() => resolve(window.SC)).catch(reject); setTimeout(() => { (window.SC && window.SC.Widget) ? resolve(window.SC) : reject(new Error('SC API timeout')); }, API_TIMEOUT); }); return scripts.sc; } // Spotify: global callback onSpotifyIframeApiReady(IFrameAPI) (once) → wrap in a promise. function spotifyApi() { if (scripts.sp) return scripts.sp; scripts.sp = new Promise((resolve, reject) => { if (window.__spotifyIframeApi) return resolve(window.__spotifyIframeApi); window.onSpotifyIframeApiReady = function (IFrameAPI) { window.__spotifyIframeApi = IFrameAPI; resolve(IFrameAPI); }; loadScript('https://open.spotify.com/embed/iframe-api/v1').catch(reject); // Shorter than API_TIMEOUT: Spotify's bundle initialises quickly or not at all // (CDN 503 / origin-gating). No need to wait 8 s before the iframe fallback. setTimeout(() => reject(new Error('Spotify API timeout')), 4000); }); return scripts.sp; } // Plain platform iframe as fallback when the JS API is blocked/unreachable. // Ad-blockers generally let embed iframes through. Autoplay parameter // is safe here because we're always in a user-gesture context (play click). function fallbackIframe(provider, ref, url) { if (provider === 'youtube') { const src = ytEmbedSrc(ref, url, 'autoplay=1&rel=0'); if (src) return { src, ratio: true, fs: true }; } if (provider === 'soundcloud') { return { src: 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(ref || url) + '&auto_play=true&visual=true&hide_related=true', h: '300px' }; } if (provider === 'spotify') { const m = (ref || '').match(/^spotify:(\w+):(\w+)$/); return { src: m ? `https://open.spotify.com/embed/${m[1]}/${m[2]}` : (url || ''), h: '152px' }; } return { src: url || '' }; } // ============================================================ // 2. Helpers // ============================================================ function fmt(sec) { if (!sec || isNaN(sec) || sec < 0) return '0:00'; const m = Math.floor(sec / 60), s = Math.floor(sec % 60); return m + ':' + (s < 10 ? '0' : '') + s; } // Three ref shapes, the same ones AudioEmbedService.detectProvider produces // and the same ones the Klonkt hub uses, so a ref travels between them // unchanged: "