Changeset 4c9f29a in Klonkt


Ignore:
Timestamp:
06/15/2026 03:23:14 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
3e86f1c
Parents:
650b601
git-author:
roboburr <roboburr@…> (06/15/2026 03:22:47 AM)
git-committer:
roboburr <roboburr@…> (06/15/2026 03:23:14 AM)
Message:

feat: on-brand media embeds via the real player APIs

Replaces bare platform iframes with on-brand cards, powered by the
official JS APIs so play/pause/progress are in our own hands:

  • YouTube (IFrame Player API) + SoundCloud (Widget API): fully custom controls, native chrome hidden.
  • Spotify (iFrame API): our frame around it + controls (their player UI remains; restyling not possible without Premium+OAuth).
  • Shared PlaybackRegistry: mutual exclusion -- only 1 thing plays at a time (incl. the site audio player). Replaces the focus/blur heuristic with real play events (blur stays as fallback for iframe-only embeds).
  • Progressive enhancement: if an ad-blocker blocks the platform API, falls back seamlessly to the bare platform iframe (autoplay). The resting-state card is our brand for everyone.

AudioEmbedService now renders a placeholder div (data-embed-*) for YT/SC/
Spotify instead of an iframe; embed-player.js builds the card client-side.
CSP scriptSrc extended with the player API hosts.

Adversarial review (workflow) -> 6 bugs fixed: HTMX swap leak (poll timers/
adapters -> MutationObserver teardown + adapter.destroy()), javascript: URL XSS
(scheme guard in detectProvider + safeHref client-side), Spotify ended
misdetection (no more reset-to-0), ytId/server regex on exact 11, blur scope
limited to .folio-embed.

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

Location:
src
Files:
2 added
4 edited

Legend:

Unmodified
Added
Removed
  • src/assets/js/audio-player.js

    r650b601 r4c9f29a  
    386386  function close() {
    387387    pause();
     388    mediaRegistry().release(registrySelf);
    388389    root.classList.add('audio-player-hidden');
    389390    document.body.classList.remove('has-audio-player');
     
    425426
    426427  // ============================================================
     428  // 4b. Mutual exclusion — gedeelde media-registry (zie embed-player.js).
     429  // ============================================================
     430  // Alle spelers (deze site-speler + de YouTube/SoundCloud/Spotify-embeds)
     431  // registreren zich in window.pcmsMediaRegistry. Start er één, dan pauzeert de
     432  // vorige. Dit is de precieze vervanger van de oude focus/blur-heuristiek voor
     433  // de embeds met een echte JS-API. (De blur-fallback hieronder blijft staan
     434  // voor iframe-only embeds zonder API: Bandcamp/Apple Music/Vimeo.)
     435  function mediaRegistry() {
     436    if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
     437    const r = {
     438      _active: null,
     439      setActive(player) {
     440        if (this._active && this._active !== player && this._active.pause) {
     441          try { this._active.pause(); } catch (e) {}
     442        }
     443        this._active = player;
     444      },
     445      release(player) { if (this._active === player) this._active = null; },
     446    };
     447    window.pcmsMediaRegistry = r;
     448    return r;
     449  }
     450  const registrySelf = { pause() { try { audio.pause(); } catch (e) {} } };
     451
     452  // ============================================================
    427453  // 5. Audio element events → UI sync
    428454  // ============================================================
     
    434460    root.classList.add('is-playing');
    435461    root.classList.remove('audio-needs-tap');  // verstop tap-hint
     462    mediaRegistry().setActive(registrySelf);   // pauzeer eventueel spelende embeds
    436463  });
    437464  // Reset de error-teller pas bij ECHTE playback-start (`playing`), niet bij
     
    547574  });
    548575
    549   // Geen dubbel geluid: élke embed (YouTube/Vimeo/SoundCloud/Spotify/Apple Music/
    550   // Bandcamp) is een iframe. Zodra de gebruiker er een aanklikt om af te spelen,
    551   // gaat de focus naar dat iframe -> window 'blur'. Speelt onze speler dan?
    552   // Pauzeer 'm. (Op de site zijn iframes per definitie embeds.)
     576  // Vangnet voor mutual exclusion. Voor YouTube/SoundCloud/Spotify-embeds doet de
     577  // registry dit al precies (echte play-events). Maar voor iframe-only embeds
     578  // ZONDER JS-API (Bandcamp/Apple/Vimeo) én voor de iframe-FALLBACK (als een
     579  // ad-blocker de player-API blokkeert) is er geen play-event: daar vangen we het
     580  // af via focus. Klikt de gebruiker zo'n iframe aan → window 'blur' → pauzeer
     581  // onze speler. (Voor de API-embeds is dit hooguit een onschadelijke dubbele
     582  // pauze.)
    553583  window.addEventListener('blur', () => {
    554584    setTimeout(() => {
    555585      const el = document.activeElement;
    556       if (el && el.tagName === 'IFRAME' && audio.src && !audio.paused) {
     586      // Alleen embed-iframes (binnen .folio-embed) pauzeren de speler — niet een
     587      // willekeurig iframe (captcha/reclame/kaart) dat per ongeluk focus krijgt.
     588      if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
    557589        pause();
    558590      }
  • src/server.js

    r650b601 r4c9f29a  
    6565    directives: {
    6666      defaultSrc: ["'self'"],
    67       scriptSrc: ["'self'", "'unsafe-inline'"],
     67      scriptSrc: [
     68        "'self'",
     69        "'unsafe-inline'",
     70        // Eigen custom-embeds (embed-player.js) laden de OFFICIELE player-API's
     71        // van deze hosts. Zonder deze whitelist blokkeert de CSP ze stil (alleen
     72        // een console-fout) en faalt de embed-speler.
     73        "https://www.youtube.com",   // YouTube IFrame Player API (+ www-widgetapi.js)
     74        "https://s.ytimg.com",       // YouTube player-assets
     75        "https://w.soundcloud.com",  // SoundCloud Widget API (api.js)
     76        "https://open.spotify.com",  // Spotify iFrame API
     77      ],
    6878      // Helmet's default zet script-src-attr op 'none', wat ALLE inline event-
    6979      // handlers (onchange/onclick/onsubmit) blokkeert — daardoor deed o.a. de
     
    8898        "https://embed.music.apple.com",
    8999        "https://www.youtube-nocookie.com",
     100        "https://www.youtube.com",   // YouTube IFrame API maakt soms een www.youtube.com-iframe
    90101        "https://player.vimeo.com",
    91102      ],
  • src/services/AudioEmbedService.js

    r650b601 r4c9f29a  
    1616    url = url.trim();
    1717
     18    // Alleen http(s)-URL's embedden. De provider-regexes hieronder zijn NIET
     19    // verankerd, dus zonder deze check zou bv. `javascript:alert(1)//youtu.be/x`
     20    // matchen en als embed-URL belanden (stored XSS via een [[embed:...]]-
     21    // shortcode — die tekst gaat niet langs de HTML-sanitizer omdat 'ie in een
     22    // text-node zit). De scheme-guard sluit javascript:/data:/vbscript: enz. uit.
     23    if (!/^https?:\/\//i.test(url)) return null;
     24
    1825    // Spotify
    1926    if (/open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i.test(url)) {
    2027      const match = url.match(/\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i);
    21       return { provider: 'spotify', type: match[1], id: match[2] };
     28      return { provider: 'spotify', type: match[1], id: match[2], url };
    2229    }
    2330
     
    3744    }
    3845
    39     // YouTube
    40     if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([A-Za-z0-9_-]{6,20})/i.test(url)) {
    41       const match = url.match(/(?:v=|youtu\.be\/|embed\/)([A-Za-z0-9_-]{6,20})/i);
    42       return { provider: 'youtube', id: match[1] };
     46    // YouTube — video-id is altijd exact 11 tekens (lijnt uit met de client-side
     47    // ytId() in embed-player.js, die ook {11} verwacht).
     48    if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) {
     49      const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i);
     50      return { provider: 'youtube', id: match[1], url };
    4351    }
    4452
     
    4654    if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) {
    4755      const match = url.match(/\d+/);
    48       return { provider: 'vimeo', id: match[0] };
     56      return { provider: 'vimeo', id: match[0], url };
    4957    }
    5058
     
    5462  static generateIframe(provider, config) {
    5563    switch (provider) {
     64      // Eigen custom-spelers (client-side via embed-player.js + de echte
     65      // platform-API's). We renderen een placeholder met data-attributen i.p.v.
     66      // het kale platform-iframe, zodat de embed in ÓNZE huisstijl verschijnt.
     67      case 'youtube':
     68        return this.embedPlaceholder('youtube', config.id, 'video',
     69          config.url || `https://youtu.be/${config.id}`);
     70      case 'soundcloud':
     71        return this.embedPlaceholder('soundcloud', config.url, 'track', config.url);
    5672      case 'spotify':
    57         return this.spotifyIframe(config);
     73        return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
     74          config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
     75      // Geen JS-API (Bandcamp/Apple) of niet-prioritair (Vimeo): blijven een
     76      // iframe; mutual-exclusion loopt voor deze via de blur-fallback.
    5877      case 'bandcamp':
    5978        return this.bandcampIframe(config);
    60       case 'soundcloud':
    61         return this.soundcloudIframe(config);
    6279      case 'applemusic':
    6380        return this.applemusicIframe(config);
    64       case 'youtube':
    65         return this.youtubeIframe(config);
    6681      case 'vimeo':
    6782        return this.vimeoIframe(config);
     
    6984        return null;
    7085    }
     86  }
     87
     88  /**
     89   * Placeholder voor een eigen custom-speler. embed-player.js pikt
     90   * .folio-embed[data-embed-provider] op en bouwt de kaart + speler client-side.
     91   * ALLE waarden via escape() — post.content_html wordt ongeescaped uitgevoerd.
     92   */
     93  static embedPlaceholder(provider, ref, type, url) {
     94    const attrs = [
     95      `data-embed-provider="${this.escape(provider)}"`,
     96      `data-embed-ref="${this.escape(ref)}"`,
     97      type ? `data-embed-type="${this.escape(type)}"` : '',
     98      `data-embed-url="${this.escape(url)}"`,
     99    ].filter(Boolean).join(' ');
     100    return `<div class="folio-embed folio-embed--${this.escape(provider)} pcms-embed pcms-embed-card pcms-embed-loading" ${attrs}></div>`;
    71101  }
    72102
  • src/views/shell.ejs

    r650b601 r4c9f29a  
    169169     a singleton — see the script tag near </body>. -->
    170170<link rel="stylesheet" href="/assets/css/audio.css?v=5">
     171<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) in huisstijl. -->
     172<link rel="stylesheet" href="/assets/css/embed.css?v=1">
    171173
    172174<%- include('partials/shared-styles') %>
     
    309311     ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
    310312     Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
    311 <script src="/assets/js/audio-player.js?v=13"></script>
     313<script src="/assets/js/audio-player.js?v=15"></script>
     314<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte
     315     player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
     316<script src="/assets/js/embed-player.js?v=3" defer></script>
    312317<% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
    313318  <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
Note: See TracChangeset for help on using the changeset viewer.