Index: src/assets/css/embed.css
===================================================================
--- src/assets/css/embed.css	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
+++ src/assets/css/embed.css	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
@@ -0,0 +1,243 @@
+/* ============================================================
+ * embed.css — eigen, in-huisstijl media-embeds (embed-player.js)
+ * YouTube/SoundCloud = volledig eigen controls; Spotify = onze frame + hun UI.
+ * Gebruikt de site-tokens (--paper/--ink/--accent/--rule/--radius) → past zich
+ * automatisch aan elk thema (light/dark/varianten) aan.
+ * ============================================================ */
+
+.folio-embed.pcms-embed-card {
+  margin: 2rem 0;
+  border: 1px solid var(--rule, #e8e2d5);
+  border-radius: var(--radius, 8px);
+  background: var(--paper-2, #f2ede0);
+  overflow: hidden;
+  position: relative;
+}
+
+/* Placeholder vóór embed-player.js de kaart opbouwt (of als JS uitstaat). */
+.pcms-embed-card.pcms-embed-loading {
+  min-height: 64px;
+}
+/* Fallback-tekst zolang JS de kaart nog niet heeft opgebouwd (of als JS uitstaat).
+ * buildCard() haalt .pcms-embed-loading weg → deze ::after verdwijnt vanzelf. */
+.pcms-embed-card.pcms-embed-loading::after {
+  content: "Speler laden\2026";
+  display: block;
+  padding: 1.25rem 1.5rem;
+  color: var(--ink-faint, #a8a29e);
+  font-size: .85rem;
+  font-family: var(--font-mono, monospace);
+}
+
+/* ---- Video-stage (YouTube): 16:9 ---- */
+.pcms-embed-stage {
+  position: relative;
+  width: 100%;
+  aspect-ratio: 16 / 9;
+  background: #000;
+}
+.pcms-embed-stage .pcms-embed-mount,
+.pcms-embed-stage iframe {
+  position: absolute;
+  inset: 0;
+  width: 100% !important;
+  height: 100% !important;
+  border: 0;
+}
+.pcms-embed-poster {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+  border: 0;
+  padding: 0;
+  cursor: pointer;
+  background-size: cover;
+  background-position: center;
+  background-color: #000;
+  display: grid;
+  place-items: center;
+  transition: opacity var(--transition, 180ms);
+}
+.pcms-embed-card.is-mounted .pcms-embed-poster { display: none; }
+
+/* ---- Audio-layout (SoundCloud / Spotify-facade) ---- */
+.pcms-embed-audio {
+  display: flex;
+  align-items: stretch;
+  gap: 0;
+  min-height: 96px;
+}
+.pcms-embed-art {
+  flex: 0 0 96px;
+  width: 96px;
+  height: 96px;
+  border: 0;
+  padding: 0;
+  cursor: pointer;
+  background: var(--paper, #faf8f3) center / cover no-repeat;
+  display: grid;
+  place-items: center;
+  color: var(--ink, #1a1a1a);
+}
+.pcms-embed-art.has-art .pcms-embed-bigplay {
+  background: rgba(0,0,0,.45);
+  border-radius: 50%;
+}
+.pcms-embed-info {
+  flex: 1 1 auto;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: .2rem;
+  padding: .75rem 1rem;
+}
+.pcms-embed-info .pcms-embed-title {
+  font-weight: 600;
+  font-size: .95rem;
+  color: var(--ink, #1a1a1a);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.pcms-embed-info .pcms-embed-sub {
+  font-size: .8rem;
+  color: var(--ink-muted, #6b6560);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+/* SoundCloud-iframe: functioneel maar onzichtbaar (we sturen via de Widget-API
+ * en tonen onze eigen balk). Off-screen i.p.v. display:none zodat de widget
+ * betrouwbaar initialiseert + audio blijft spelen. */
+.pcms-embed-card--soundcloud .pcms-embed-mount {
+  position: absolute;
+  left: -99999px;
+  top: 0;
+  width: 320px;
+  height: 60px;
+  opacity: 0;
+  pointer-events: none;
+}
+
+/* Spotify: hun iframe IS de speler → tonen. Onze facade (art+info) verdwijnt
+ * zodra de controller gemount is. */
+.pcms-embed-card--spotify .pcms-embed-mount { display: block; width: 100%; }
+.pcms-embed-card--spotify .pcms-embed-mount iframe {
+  width: 100% !important;
+  border: 0;
+  display: block;
+}
+.pcms-embed-card--spotify.is-mounted .pcms-embed-art,
+.pcms-embed-card--spotify.is-mounted .pcms-embed-info { display: none; }
+.pcms-embed-card--spotify:not(.is-mounted) .pcms-embed-mount { display: none; }
+
+/* ---- Grote play-knop (poster/art) ---- */
+.pcms-embed-bigplay {
+  width: 64px;
+  height: 64px;
+  display: grid;
+  place-items: center;
+  border-radius: 50%;
+  background: var(--accent, #c2410c);
+  color: #fff;
+  box-shadow: 0 4px 16px rgba(0,0,0,.35);
+  transition: transform var(--transition, 180ms);
+}
+.pcms-embed-art .pcms-embed-bigplay { width: 44px; height: 44px; }
+.pcms-embed-bigplay svg { width: 42%; height: 42%; }
+.pcms-embed-poster:hover .pcms-embed-bigplay,
+.pcms-embed-art:hover .pcms-embed-bigplay { transform: scale(1.08); }
+
+/* ---- Onze controlebalk (YouTube/SoundCloud) ---- */
+.pcms-embed-bar {
+  display: flex;
+  align-items: center;
+  gap: .65rem;
+  padding: .55rem .75rem;
+  border-top: 1px solid var(--rule, #e8e2d5);
+  background: var(--paper, #faf8f3);
+}
+.pcms-embed-pp {
+  flex: 0 0 auto;
+  width: 38px;
+  height: 38px;
+  border: 0;
+  border-radius: 50%;
+  cursor: pointer;
+  background: var(--accent, #c2410c);
+  color: #fff;
+  display: grid;
+  place-items: center;
+}
+.pcms-embed-pp svg { width: 18px; height: 18px; }
+.pcms-embed-cur, .pcms-embed-dur {
+  flex: 0 0 auto;
+  font-size: .75rem;
+  color: var(--ink-muted, #6b6560);
+  font-variant-numeric: tabular-nums;
+  min-width: 2.6em;
+  text-align: center;
+}
+.pcms-embed-seek {
+  position: relative;
+  flex: 1 1 auto;
+  height: 16px;
+  cursor: pointer;
+}
+.pcms-embed-seek::before {
+  content: "";
+  position: absolute;
+  left: 0; right: 0; top: 50%;
+  transform: translateY(-50%);
+  height: 4px;
+  border-radius: 2px;
+  background: var(--rule-2, #d4ccb8);
+}
+.pcms-embed-seek-fill {
+  position: absolute;
+  left: 0; top: 50%;
+  transform: translateY(-50%);
+  height: 4px;
+  width: 0%;
+  border-radius: 2px;
+  background: var(--accent, #c2410c);
+  pointer-events: none;
+  z-index: 1;
+}
+.pcms-embed-badge {
+  flex: 0 0 auto;
+  font-size: .68rem;
+  letter-spacing: .04em;
+  text-transform: uppercase;
+  color: var(--ink-faint, #a8a29e);
+  text-decoration: none;
+}
+.pcms-embed-badge:hover { color: var(--accent, #c2410c); }
+
+/* Provider-accent op de badge */
+.pcms-embed-card--youtube .pcms-embed-badge { color: #ff0033; }
+.pcms-embed-card--soundcloud .pcms-embed-badge { color: #ff5500; }
+
+/* ---- Spotify-frame-badge (geen eigen balk) ---- */
+.pcms-embed-frame-badge {
+  padding: .4rem .75rem;
+  border-top: 1px solid var(--rule, #e8e2d5);
+  background: var(--paper, #faf8f3);
+  font-size: .68rem;
+  letter-spacing: .04em;
+  text-transform: uppercase;
+}
+.pcms-embed-frame-badge a { color: #1db954; text-decoration: none; }
+.pcms-embed-frame-badge a:hover { text-decoration: underline; }
+.pcms-embed-card--spotify.is-mounted .pcms-embed-frame-badge { display: none; }
+
+/* ---- Busy / error ---- */
+.pcms-embed-card.pcms-embed-busy { cursor: progress; }
+.pcms-embed-card.pcms-embed-error .pcms-embed-sub { color: #c0392b; }
+
+.pcms-embed-bigplay,
+.pcms-embed-pp,
+.pcms-embed-poster { -webkit-tap-highlight-color: transparent; }
Index: src/assets/js/audio-player.js
===================================================================
--- src/assets/js/audio-player.js	(revision 650b601f8a7e059896f71d96fc3de79fe99503fc)
+++ src/assets/js/audio-player.js	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
@@ -386,4 +386,5 @@
   function close() {
     pause();
+    mediaRegistry().release(registrySelf);
     root.classList.add('audio-player-hidden');
     document.body.classList.remove('has-audio-player');
@@ -425,4 +426,29 @@
 
   // ============================================================
+  // 4b. Mutual exclusion — gedeelde media-registry (zie embed-player.js).
+  // ============================================================
+  // Alle spelers (deze site-speler + de YouTube/SoundCloud/Spotify-embeds)
+  // registreren zich in window.pcmsMediaRegistry. Start er één, dan pauzeert de
+  // vorige. Dit is de precieze vervanger van de oude focus/blur-heuristiek voor
+  // de embeds met een echte JS-API. (De blur-fallback hieronder blijft staan
+  // voor iframe-only embeds zonder API: Bandcamp/Apple Music/Vimeo.)
+  function mediaRegistry() {
+    if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
+    const r = {
+      _active: null,
+      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;
+  }
+  const registrySelf = { pause() { try { audio.pause(); } catch (e) {} } };
+
+  // ============================================================
   // 5. Audio element events → UI sync
   // ============================================================
@@ -434,4 +460,5 @@
     root.classList.add('is-playing');
     root.classList.remove('audio-needs-tap');  // verstop tap-hint
+    mediaRegistry().setActive(registrySelf);   // pauzeer eventueel spelende embeds
   });
   // Reset de error-teller pas bij ECHTE playback-start (`playing`), niet bij
@@ -547,12 +574,17 @@
   });
 
-  // Geen dubbel geluid: élke embed (YouTube/Vimeo/SoundCloud/Spotify/Apple Music/
-  // Bandcamp) is een iframe. Zodra de gebruiker er een aanklikt om af te spelen,
-  // gaat de focus naar dat iframe -> window 'blur'. Speelt onze speler dan?
-  // Pauzeer 'm. (Op de site zijn iframes per definitie embeds.)
+  // Vangnet voor mutual exclusion. Voor YouTube/SoundCloud/Spotify-embeds doet de
+  // registry dit al precies (echte play-events). Maar voor iframe-only embeds
+  // ZONDER JS-API (Bandcamp/Apple/Vimeo) én voor de iframe-FALLBACK (als een
+  // ad-blocker de player-API blokkeert) is er geen play-event: daar vangen we het
+  // af via focus. Klikt de gebruiker zo'n iframe aan → window 'blur' → pauzeer
+  // onze speler. (Voor de API-embeds is dit hooguit een onschadelijke dubbele
+  // pauze.)
   window.addEventListener('blur', () => {
     setTimeout(() => {
       const el = document.activeElement;
-      if (el && el.tagName === 'IFRAME' && audio.src && !audio.paused) {
+      // Alleen embed-iframes (binnen .folio-embed) pauzeren de speler — niet een
+      // willekeurig iframe (captcha/reclame/kaart) dat per ongeluk focus krijgt.
+      if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
         pause();
       }
Index: src/assets/js/embed-player.js
===================================================================
--- src/assets/js/embed-player.js	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
+++ src/assets/js/embed-player.js	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
@@ -0,0 +1,477 @@
+/**
+ * PrutCMS Embed Player — eigen, in-huisstijl media-embeds bovenop de ÉCHTE
+ * player-API's (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API).
+ *
+ * De server (AudioEmbedService) rendert per embed een placeholder:
+ *   <div class="folio-embed folio-embed--<provider> pcms-embed-card"
+ *        data-embed-provider data-embed-ref data-embed-type data-embed-url></div>
+ * Dit script bouwt daar onze eigen kaart omheen (cover/poster + onze play-knop +
+ * voortgangsbalk in huisstijl) en bestuurt de onderliggende speler via de
+ * platform-API, zodat play/pause/voortgang in ÓNZE handen liggen.
+ *
+ * Capability-eerlijkheid:
+ *  - youtube/soundcloud  → volledig eigen controls (native chrome verborgen).
+ *  - spotify             → besturing + onze frame eromheen; Spotify's eigen UI
+ *                          blijft binnenin (kan niet anders zonder Premium+OAuth).
+ *
+ * Mutual exclusion: elke speler (incl. de site-audiospeler in audio-player.js)
+ * registreert zich in window.pcmsMediaRegistry. Start er één → de vorige pauzeert.
+ * Dit vervangt de oude focus/blur-heuristiek door echte play-events.
+ *
+ * Singleton — guard tegen dubbel-init (HTMX laadt scripts soms opnieuw).
+ */
+(function () {
+  if (window.pcmsEmbedPlayer) return;
+
+  // ============================================================
+  // 0. Gedeelde playback-registry (ook door audio-player.js gebruikt)
+  // ============================================================
+  function registry() {
+    if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
+    const r = {
+      _active: null,
+      // Markeer `player` als de enige actieve; pauzeer de vorige.
+      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, gedeeld over N embeds.
+  //    We laden een platform-script PAS als er een embed van dat platform op de
+  //    pagina daadwerkelijk wordt gestart.
+  // ============================================================
+  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 faalde: ' + src));
+      document.head.appendChild(s);
+    });
+  }
+  // Belangrijk: alle loaders REJECTEN bij een script-fout (bv. een ad-blocker die
+  // het API-script blokkeert) én na een time-out — zodat de aanroeper netjes kan
+  // terugvallen op het kale platform-iframe i.p.v. eeuwig te hangen.
+  const API_TIMEOUT = 8000;
+
+  // YouTube: globale callback onYouTubeIframeAPIReady (1×) → in promise wikkelen.
+  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: geen globale ready-callback; resolve op script-onload, daarna
+  // wacht elke widget op z'n eigen 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: globale callback onSpotifyIframeApiReady(IFrameAPI) (1×) → wrappen.
+  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);
+      setTimeout(() => reject(new Error('Spotify API timeout')), API_TIMEOUT);
+    });
+    return scripts.sp;
+  }
+
+  // Kaal platform-iframe als fallback wanneer de JS-API geblokkeerd/onbereikbaar
+  // is. Ad-blockers laten de embed-iframes doorgaans wél door. Autoplay-param
+  // omdat we hier altijd in een user-gesture-context zitten (klik op play).
+  function fallbackIframe(provider, ref, url) {
+    if (provider === 'youtube') {
+      const id = ytId(ref, url);
+      return { src: 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) + '?autoplay=1&rel=0', 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;
+  }
+  function ytId(ref, url) {
+    if (ref && /^[A-Za-z0-9_-]{11}$/.test(ref)) return ref;
+    const m = (url || '').match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
+    if (m) return m[1];
+    return null;  // geen blinde slice — een ongeldige ref geeft liever niets dan een kapotte id
+  }
+  // Alleen http(s) als href toelaten (defense-in-depth tegen javascript:/data:).
+  function safeHref(u) {
+    try { const p = new URL(u, location.href); return (p.protocol === 'http:' || p.protocol === 'https:') ? u : '#'; }
+    catch (e) { return '#'; }
+  }
+  const ICON = {
+    play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>',
+    pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4h4v16H7zM13 4h4v16h-4z" fill="currentColor"/></svg>',
+  };
+  const LABEL = { youtube: 'YouTube', soundcloud: 'SoundCloud', spotify: 'Spotify' };
+
+  // ============================================================
+  // 3. Card controller — bouwt de huisstijl-kaart + delegeert naar een adapter
+  // ============================================================
+  function buildCard(el) {
+    const provider = el.dataset.embedProvider;
+    const ref = el.dataset.embedRef || '';
+    const url = el.dataset.embedUrl || '';
+    if (!provider) return;
+
+    el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider);
+    el.classList.remove('pcms-embed-loading');
+
+    // Onze speler-zijde (registry-peer). pause() wijst naar de adapter zodra die
+    // gemount is; ervoor is 't een no-op.
+    let adapter = null;
+    const self = { pause() { if (adapter && adapter.pause) { try { adapter.pause(); } catch (e) {} } } };
+
+    // Teardown-hook: aangeroepen door de MutationObserver als deze kaart uit de
+    // DOM verdwijnt (HTMX-swap) → adapter opruimen (timers/iframes) + registry
+    // vrijgeven, zodat er geen poll-timers of spelers blijven lekken.
+    el._pcmsDestroy = function () {
+      try { if (adapter && adapter.destroy) adapter.destroy(); } catch (e) {}
+      registry().release(self);
+    };
+
+    let playing = false;
+    let mounted = false;
+    let dur = 0;
+
+    // --- UI ophangen (verschilt per provider-type) ---
+    const isVideo = provider === 'youtube';
+    const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls
+    const poster = provider === 'youtube'
+      ? `https://i.ytimg.com/vi/${ytId(ref, url)}/hqdefault.jpg` : '';
+
+    el.innerHTML = ''
+      + (isVideo
+          ? `<div class="pcms-embed-stage"><div class="pcms-embed-mount"></div>`
+            + `<button type="button" class="pcms-embed-poster"${poster ? ` style="background-image:url('${poster}')"` : ''} aria-label="Afspelen">`
+            + `<span class="pcms-embed-bigplay">${ICON.play}</span></button></div>`
+          : `<div class="pcms-embed-audio">`
+            + `<button type="button" class="pcms-embed-art" aria-label="Afspelen"><span class="pcms-embed-bigplay">${ICON.play}</span></button>`
+            + `<div class="pcms-embed-info"><div class="pcms-embed-title">${LABEL[provider] || provider}</div>`
+            + `<div class="pcms-embed-sub"></div></div>`
+            + `<div class="pcms-embed-mount"></div></div>`)
+      + (custom
+          ? `<div class="pcms-embed-bar">`
+            + `<button type="button" class="pcms-embed-pp" aria-label="Afspelen/pauzeren">${ICON.play}</button>`
+            + `<span class="pcms-embed-cur mono">0:00</span>`
+            + `<div class="pcms-embed-seek" role="slider" aria-label="Voortgang" tabindex="0"><div class="pcms-embed-seek-fill"></div></div>`
+            + `<span class="pcms-embed-dur mono">0:00</span>`
+            + `<a class="pcms-embed-badge" href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">${LABEL[provider] || provider}</a>`
+            + `</div>`
+          : `<div class="pcms-embed-frame-badge"><a href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">via ${LABEL[provider] || provider}</a></div>`);
+
+    const mountEl = el.querySelector('.pcms-embed-mount');
+    const ppBtn = el.querySelector('.pcms-embed-pp');
+    const curEl = el.querySelector('.pcms-embed-cur');
+    const durEl = el.querySelector('.pcms-embed-dur');
+    const seekEl = el.querySelector('.pcms-embed-seek');
+    const seekFill = el.querySelector('.pcms-embed-seek-fill');
+    const posterBtn = el.querySelector('.pcms-embed-poster, .pcms-embed-art');
+    const subEl = el.querySelector('.pcms-embed-sub');
+
+    function setPlayingUI(on) {
+      playing = on;
+      el.classList.toggle('is-playing', on);
+      if (ppBtn) ppBtn.innerHTML = on ? ICON.pause : ICON.play;
+    }
+    function setProgress(cur, total) {
+      if (total > 0) { dur = total; if (durEl) durEl.textContent = fmt(total); }
+      if (curEl) curEl.textContent = fmt(cur);
+      if (seekFill && dur > 0) seekFill.style.width = Math.max(0, Math.min(100, (cur / dur) * 100)) + '%';
+    }
+
+    const hooks = {
+      onReady(meta) {
+        el.classList.add('is-ready');
+        el.classList.remove('pcms-embed-busy');
+        if (meta && meta.title && subEl) subEl.textContent = meta.title;
+        if (meta && meta.artwork) {
+          const art = el.querySelector('.pcms-embed-art');
+          if (art) { art.style.backgroundImage = `url('${meta.artwork}')`; art.classList.add('has-art'); }
+        }
+        if (meta && meta.duration) setProgress(0, meta.duration);
+      },
+      onPlay() { setPlayingUI(true); registry().setActive(self); },
+      onPause() { setPlayingUI(false); },
+      onEnded() { setPlayingUI(false); setProgress(0, dur); registry().release(self); },
+      onProgress(cur, total) { setProgress(cur, total); },
+    };
+
+    // API geblokkeerd/onbereikbaar → kaal platform-iframe (graceful degradation).
+    // De mutual-exclusion loopt voor deze fallback via de blur-heuristiek
+    // (audio-player.js), want de kaart krijgt geen .is-mounted.
+    function renderFallback() {
+      const fb = fallbackIframe(provider, ref, url);
+      if (!fb.src) { el.classList.remove('pcms-embed-busy'); el.classList.add('pcms-embed-error'); return; }
+      const iframe = document.createElement('iframe');
+      iframe.src = fb.src;
+      iframe.loading = 'lazy';
+      iframe.title = LABEL[provider] || provider;
+      iframe.setAttribute('allow', 'autoplay; encrypted-media; clipboard-write; picture-in-picture; fullscreen');
+      if (fb.fs) iframe.allowFullscreen = true;
+      iframe.style.cssText = fb.ratio
+        ? 'width:100%;aspect-ratio:16/9;border:0;display:block;'
+        : 'width:100%;height:' + (fb.h || '152px') + ';border:0;display:block;';
+      el.classList.remove('is-mounted', 'pcms-embed-busy');
+      el.classList.add('pcms-embed-fallback');
+      el.innerHTML = '';
+      el.appendChild(iframe);
+    }
+
+    // Eerste interactie → adapter mounten + spelen. Daarna toggelt de knop.
+    async function ensureMountedAndPlay() {
+      if (mounted) { if (adapter) adapter.play(); return; }
+      mounted = true;
+      el.classList.add('pcms-embed-busy');
+      try {
+        adapter = await MOUNTERS[provider](mountEl, { ref, url }, hooks);
+        // is-mounted: CSS verbergt de poster (video) of de Spotify-facade en
+        // toont de echte speler. Voor SoundCloud blijft onze kaart+balk staan en
+        // blijft het (functionele) iframe off-screen verborgen.
+        el.classList.add('is-mounted');
+        adapter.play();
+      } catch (err) {
+        console.warn('[pcms-embed] API niet beschikbaar, val terug op kaal iframe', provider, err);
+        renderFallback();
+      }
+    }
+
+    if (posterBtn) posterBtn.addEventListener('click', ensureMountedAndPlay);
+    if (ppBtn) ppBtn.addEventListener('click', () => {
+      if (!mounted) return ensureMountedAndPlay();
+      if (playing) { adapter && adapter.pause(); } else { adapter && adapter.play(); }
+    });
+    if (seekEl) seekEl.addEventListener('click', (e) => {
+      if (!adapter || !adapter.seek || !dur) return;
+      const rect = seekEl.getBoundingClientRect();
+      const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+      adapter.seek(ratio * dur);
+    });
+  }
+
+  function escAttr(s) {
+    return String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+  }
+
+  // ============================================================
+  // 4. Adapters — normaliseren de 3 zeer verschillende API's naar 1 vorm.
+  //    Elke mounter geeft { play, pause, seek } terug en roept hooks aan met
+  //    seconden (units worden hier rechtgetrokken).
+  // ============================================================
+  const MOUNTERS = {
+    // ---- YouTube: IFrame Player API, eigen controls (controls:0) ----
+    async youtube(mountEl, { ref, url }, hooks) {
+      const YT = await ytApi();
+      const id = ytId(ref, url);
+      return new Promise((resolve, reject) => {
+        let pollTimer = null;
+        const player = new YT.Player(mountEl, {
+          videoId: id,
+          host: 'https://www.youtube-nocookie.com',
+          playerVars: {
+            controls: 0, modestbranding: 1, rel: 0, playsinline: 1, fs: 0,
+            disablekb: 1, iv_load_policy: 3, origin: window.location.origin,
+          },
+          events: {
+            onReady() {
+              let d = 0; try { d = player.getDuration() || 0; } catch (e) {}
+              hooks.onReady({ duration: d });
+              resolve({
+                play() { try { player.playVideo(); } catch (e) {} },
+                pause() { try { player.pauseVideo(); } catch (e) {} },
+                seek(sec) { try { player.seekTo(sec, true); } catch (e) {} },
+                destroy() {
+                  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+                  try { player.destroy(); } catch (e) {}
+                },
+              });
+            },
+            onStateChange(e) {
+              // -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued
+              if (e.data === 1) {
+                hooks.onPlay();
+                if (!pollTimer) pollTimer = setInterval(() => {
+                  try { hooks.onProgress(player.getCurrentTime() || 0, player.getDuration() || 0); } catch (e) {}
+                }, 250);
+              } else if (e.data === 2) {
+                hooks.onPause();
+                if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+              } else if (e.data === 0) {
+                hooks.onEnded();
+                if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+              }
+            },
+            onError() { reject(new Error('YT error')); },
+          },
+        });
+      });
+    },
+
+    // ---- SoundCloud: Widget API, visual=false, eigen controls ----
+    async soundcloud(mountEl, { ref, url }, hooks) {
+      const SC = await scApi();
+      // Iframe zelf bouwen (kale balk) en daarna SC.Widget eraan hangen.
+      const iframe = document.createElement('iframe');
+      iframe.allow = 'autoplay';
+      iframe.title = 'SoundCloud';
+      const params = new URLSearchParams({
+        url: ref || url, visual: 'false', auto_play: 'false', hide_related: 'true',
+        show_comments: 'false', show_user: 'false', show_teaser: 'false',
+        sharing: 'false', buy: 'false', download: 'false', show_artwork: 'true',
+        single_active: 'true', color: 'ff5500',
+      });
+      iframe.src = 'https://w.soundcloud.com/player/?' + params.toString();
+      mountEl.appendChild(iframe);
+      const widget = SC.Widget(iframe);
+      const E = SC.Widget.Events;
+      return new Promise((resolve, reject) => {
+        let resolved = false;
+        widget.bind(E.READY, () => {
+          widget.getCurrentSound((sound) => {
+            const meta = sound ? {
+              title: sound.title || '',
+              artwork: (sound.artwork_url || (sound.user && sound.user.avatar_url) || '').replace('-large', '-t300x300'),
+            } : {};
+            widget.getDuration((ms) => { meta.duration = (ms || 0) / 1000; hooks.onReady(meta); });
+          });
+          resolved = true;
+          resolve({
+            play() { widget.play(); },
+            pause() { widget.pause(); },
+            seek(sec) { widget.seekTo(sec * 1000); },
+            destroy() {
+              try { ['READY', 'PLAY', 'PAUSE', 'FINISH', 'PLAY_PROGRESS', 'ERROR'].forEach((k) => E[k] && widget.unbind(E[k])); } catch (e) {}
+              try { iframe.remove(); } catch (e) {}
+            },
+          });
+        });
+        widget.bind(E.PLAY, () => hooks.onPlay());
+        widget.bind(E.PAUSE, () => hooks.onPause());
+        widget.bind(E.FINISH, () => hooks.onEnded());
+        widget.bind(E.PLAY_PROGRESS, (d) => {
+          hooks.onProgress((d.currentPosition || 0) / 1000, 0);
+        });
+        widget.bind(E.ERROR, () => { if (!resolved) reject(new Error('SC error')); });
+        setTimeout(() => { if (!resolved) reject(new Error('SC timeout')); }, 12000);
+      });
+    },
+
+    // ---- Spotify: iFrame API — besturing + onze frame; geen eigen skin ----
+    async spotify(mountEl, { ref, url }, hooks) {
+      const IFrameAPI = await spotifyApi();
+      const uri = ref || url;
+      return new Promise((resolve, reject) => {
+        let lastPaused = true, resolved = false;
+        IFrameAPI.createController(mountEl, { uri, width: '100%', height: 152 }, (controller) => {
+          controller.addListener('ready', () => {
+            hooks.onReady({});
+            resolved = true;
+            resolve({
+              play() { try { controller.resume(); } catch (e) { try { controller.play(); } catch (e2) {} } },
+              pause() { try { controller.pause(); } catch (e) {} },
+              seek(sec) { try { controller.seek(sec); } catch (e) {} },
+              destroy() { try { controller.destroy(); } catch (e) {} },
+            });
+          });
+          controller.addListener('playback_update', (e) => {
+            const d = e && e.data ? e.data : {};
+            hooks.onProgress((d.position || 0) / 1000, (d.duration || 0) / 1000);
+            // Geen betrouwbare 'ended'-event bij Spotify; we behandelen elke
+            // isPaused-overgang als play/pause. Einde = gewoon een pauze (de balk
+            // blijft op de eindpositie i.p.v. misleidend naar 0 te springen).
+            if (d.isPaused === false && lastPaused) { lastPaused = false; hooks.onPlay(); }
+            else if (d.isPaused === true && !lastPaused) { lastPaused = true; hooks.onPause(); }
+          });
+        });
+        setTimeout(() => { if (!resolved) reject(new Error('Spotify timeout')); }, 12000);
+      });
+    },
+  };
+
+  // ============================================================
+  // 5. Scan + HTMX-/DOM-mutatie-aware (de site navigeert deels via HTMX-swaps)
+  // ============================================================
+  function scan(root) {
+    (root || document).querySelectorAll('.folio-embed[data-embed-provider]').forEach((el) => {
+      if (el.dataset.embedInit) return;
+      el.dataset.embedInit = '1';
+      try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard faalde', e); }
+    });
+  }
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', () => scan(document));
+  } else {
+    scan(document);
+  }
+  // HTMX vervangt #pcms-main bij interne navigatie → opnieuw scannen.
+  document.body.addEventListener('htmx:afterSwap', (e) => scan(e.target || document));
+  document.body.addEventListener('htmx:load', (e) => scan(e.target || document));
+
+  // Teardown bij verwijdering uit de DOM (HTMX vervangt #pcms-main innerHTML, of
+  // een SPA-achtige swap). Zonder dit blijven YouTube-poll-timers + adapters/
+  // iframes hangen als je wegnavigeert terwijl een embed speelt → CPU/geheugenlek
+  // dat per navigatie opstapelt. We roepen el._pcmsDestroy() aan voor elke kaart
+  // die echt uit het document verdwijnt.
+  const teardownObserver = new MutationObserver((muts) => {
+    for (const m of muts) {
+      m.removedNodes.forEach((node) => {
+        if (node.nodeType !== 1) return;
+        const cards = [];
+        if (node.matches && node.matches('.folio-embed[data-embed-init]')) cards.push(node);
+        if (node.querySelectorAll) node.querySelectorAll('.folio-embed[data-embed-init]').forEach((c) => cards.push(c));
+        cards.forEach((c) => {
+          if (typeof c._pcmsDestroy === 'function' && !document.contains(c)) {
+            try { c._pcmsDestroy(); } catch (e) {}
+          }
+        });
+      });
+    }
+  });
+  try { teardownObserver.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
+
+  window.pcmsEmbedPlayer = { scan, registry };
+})();
Index: src/server.js
===================================================================
--- src/server.js	(revision 650b601f8a7e059896f71d96fc3de79fe99503fc)
+++ src/server.js	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
@@ -65,5 +65,15 @@
     directives: {
       defaultSrc: ["'self'"],
-      scriptSrc: ["'self'", "'unsafe-inline'"],
+      scriptSrc: [
+        "'self'",
+        "'unsafe-inline'",
+        // Eigen custom-embeds (embed-player.js) laden de OFFICIELE player-API's
+        // van deze hosts. Zonder deze whitelist blokkeert de CSP ze stil (alleen
+        // een console-fout) en faalt de embed-speler.
+        "https://www.youtube.com",   // YouTube IFrame Player API (+ www-widgetapi.js)
+        "https://s.ytimg.com",       // YouTube player-assets
+        "https://w.soundcloud.com",  // SoundCloud Widget API (api.js)
+        "https://open.spotify.com",  // Spotify iFrame API
+      ],
       // Helmet's default zet script-src-attr op 'none', wat ALLE inline event-
       // handlers (onchange/onclick/onsubmit) blokkeert — daardoor deed o.a. de
@@ -88,4 +98,5 @@
         "https://embed.music.apple.com",
         "https://www.youtube-nocookie.com",
+        "https://www.youtube.com",   // YouTube IFrame API maakt soms een www.youtube.com-iframe
         "https://player.vimeo.com",
       ],
Index: src/services/AudioEmbedService.js
===================================================================
--- src/services/AudioEmbedService.js	(revision 650b601f8a7e059896f71d96fc3de79fe99503fc)
+++ src/services/AudioEmbedService.js	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
@@ -16,8 +16,15 @@
     url = url.trim();
 
+    // Alleen http(s)-URL's embedden. De provider-regexes hieronder zijn NIET
+    // verankerd, dus zonder deze check zou bv. `javascript:alert(1)//youtu.be/x`
+    // matchen en als embed-URL belanden (stored XSS via een [[embed:...]]-
+    // shortcode — die tekst gaat niet langs de HTML-sanitizer omdat 'ie in een
+    // text-node zit). De scheme-guard sluit javascript:/data:/vbscript: enz. uit.
+    if (!/^https?:\/\//i.test(url)) return null;
+
     // Spotify
     if (/open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i.test(url)) {
       const match = url.match(/\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i);
-      return { provider: 'spotify', type: match[1], id: match[2] };
+      return { provider: 'spotify', type: match[1], id: match[2], url };
     }
 
@@ -37,8 +44,9 @@
     }
 
-    // YouTube
-    if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([A-Za-z0-9_-]{6,20})/i.test(url)) {
-      const match = url.match(/(?:v=|youtu\.be\/|embed\/)([A-Za-z0-9_-]{6,20})/i);
-      return { provider: 'youtube', id: match[1] };
+    // YouTube — video-id is altijd exact 11 tekens (lijnt uit met de client-side
+    // ytId() in embed-player.js, die ook {11} verwacht).
+    if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) {
+      const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i);
+      return { provider: 'youtube', id: match[1], url };
     }
 
@@ -46,5 +54,5 @@
     if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) {
       const match = url.match(/\d+/);
-      return { provider: 'vimeo', id: match[0] };
+      return { provider: 'vimeo', id: match[0], url };
     }
 
@@ -54,14 +62,21 @@
   static generateIframe(provider, config) {
     switch (provider) {
+      // Eigen custom-spelers (client-side via embed-player.js + de echte
+      // platform-API's). We renderen een placeholder met data-attributen i.p.v.
+      // het kale platform-iframe, zodat de embed in ÓNZE huisstijl verschijnt.
+      case 'youtube':
+        return this.embedPlaceholder('youtube', config.id, 'video',
+          config.url || `https://youtu.be/${config.id}`);
+      case 'soundcloud':
+        return this.embedPlaceholder('soundcloud', config.url, 'track', config.url);
       case 'spotify':
-        return this.spotifyIframe(config);
+        return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
+          config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
+      // Geen JS-API (Bandcamp/Apple) of niet-prioritair (Vimeo): blijven een
+      // iframe; mutual-exclusion loopt voor deze via de blur-fallback.
       case 'bandcamp':
         return this.bandcampIframe(config);
-      case 'soundcloud':
-        return this.soundcloudIframe(config);
       case 'applemusic':
         return this.applemusicIframe(config);
-      case 'youtube':
-        return this.youtubeIframe(config);
       case 'vimeo':
         return this.vimeoIframe(config);
@@ -69,4 +84,19 @@
         return null;
     }
+  }
+
+  /**
+   * Placeholder voor een eigen custom-speler. embed-player.js pikt
+   * .folio-embed[data-embed-provider] op en bouwt de kaart + speler client-side.
+   * ALLE waarden via escape() — post.content_html wordt ongeescaped uitgevoerd.
+   */
+  static embedPlaceholder(provider, ref, type, url) {
+    const attrs = [
+      `data-embed-provider="${this.escape(provider)}"`,
+      `data-embed-ref="${this.escape(ref)}"`,
+      type ? `data-embed-type="${this.escape(type)}"` : '',
+      `data-embed-url="${this.escape(url)}"`,
+    ].filter(Boolean).join(' ');
+    return `<div class="folio-embed folio-embed--${this.escape(provider)} pcms-embed pcms-embed-card pcms-embed-loading" ${attrs}></div>`;
   }
 
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 650b601f8a7e059896f71d96fc3de79fe99503fc)
+++ src/views/shell.ejs	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
@@ -169,4 +169,6 @@
      a singleton — see the script tag near </body>. -->
 <link rel="stylesheet" href="/assets/css/audio.css?v=5">
+<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) in huisstijl. -->
+<link rel="stylesheet" href="/assets/css/embed.css?v=1">
 
 <%- include('partials/shared-styles') %>
@@ -309,5 +311,8 @@
      ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
      Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
-<script src="/assets/js/audio-player.js?v=13"></script>
+<script src="/assets/js/audio-player.js?v=15"></script>
+<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte
+     player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
+<script src="/assets/js/embed-player.js?v=3" defer></script>
 <% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
   <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
