/** * AudioEmbedService — Parse URLs and return embed HTML * Supports: Spotify, Bandcamp, SoundCloud, Apple Music, YouTube, Vimeo * * Usage in post content: *

https://open.spotify.com/track/123abc

* → *
* *
*/ // "Open in"-iconen (brand-gekleurd via CSS .pat-link--*). const OPEN_IN_SVG = { spotify: '', youtube: '', soundcloud: '', }; class AudioEmbedService { // Kleine "open in"-links voor een track (Spotify/YouTube/SoundCloud). De hrefs // zijn server-side al gevalideerd (alleen https + juiste host). Geeft '' als er // geen links zijn. Wordt naast de play-knop gezet (buiten de knop → geen // conflict met afspelen). static openInLinks(t) { if (!t) return ''; const out = []; const add = (url, key, label) => { if (!url) return; out.push(`${OPEN_IN_SVG[key]}`); }; add(t.link_spotify, 'spotify', 'Spotify'); add(t.link_youtube, 'youtube', 'YouTube'); add(t.link_soundcloud, 'soundcloud', 'SoundCloud'); return out.length ? `${out.join('')}` : ''; } static detectProvider(url) { if (!url || typeof url !== 'string') return null; 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], url }; } // Bandcamp if (/bandcamp\.com\/(track|album)/i.test(url)) { return { provider: 'bandcamp', url }; } // SoundCloud if (/soundcloud\.com/i.test(url)) { return { provider: 'soundcloud', url }; } // Apple Music if (/music\.apple\.com\/([a-z]{2})\/(?:album|playlist|song)\//i.test(url)) { return { provider: 'applemusic', url }; } // 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 }; } // Vimeo if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) { const match = url.match(/\d+/); return { provider: 'vimeo', id: match[0], url }; } return null; } 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.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 'applemusic': return this.applemusicIframe(config); case 'vimeo': return this.vimeoIframe(config); default: 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 `
`; } static spotifyIframe({ type, id }) { const src = `https://open.spotify.com/embed/${type}/${id}`; return `
`.trim(); } static bandcampIframe({ url }) { const encodedUrl = encodeURIComponent(url); const src = `https://bandcamp.com/EmbeddedPlayer/url=${encodedUrl}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/`; return `
`.trim(); } static soundcloudIframe({ url }) { const params = { url: url, color: '#ff5500', auto_play: 'false', hide_related: 'true', show_comments: 'false', show_user: 'true', show_reposts: 'false', show_teaser: 'false', visual: 'true' }; const query = new URLSearchParams(params).toString(); const src = `https://w.soundcloud.com/player/?${query}`; return `
`.trim(); } static applemusicIframe({ url }) { const match = url.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i); if (!match) return null; const src = `https://embed.music.apple.com/${match[1]}`; return `
`.trim(); } static youtubeIframe({ id }) { const src = `https://www.youtube-nocookie.com/embed/${id}`; return `
`.trim(); } static vimeoIframe({ id }) { const src = `https://player.vimeo.com/video/${id}`; return `
`.trim(); } /** * Auto-embed: Scan paragraphs containing only a URL. * Handles two markdown-rendered shapes: *

https://url

(bare URL — when GFM auto-link is off) *

https://url

(marked GFM auto-link — what we get) * Either way →
... */ static autoembed(html) { if (!html) return html; return html.replace( /

\s*(?:]*?\shref="([^"]+)"[^>]*>[^<]*<\/a>|(https?:\/\/[^\s<>"']+))\s*<\/p>/gi, (match, hrefUrl, bareUrl) => { const url = hrefUrl || bareUrl; const detected = this.detectProvider(url); if (detected) { const iframe = this.generateIframe(detected.provider, detected); return iframe || match; } return match; } ); } /** * Replace [[embed:]] shortcodes met de platform-iframe (YouTube, Spotify, * SoundCloud, Apple Music, Bandcamp, Vimeo). De editor-knop voegt deze * shortcode in; losse URL-regels embedden ook automatisch via autoembed(). * Niet-ondersteunde/ongeldige URLs krijgen een nette inline-melding. */ static embedMediaShortcodes(html) { if (!html) return html; return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawUrl) => { const url = rawUrl.trim().replace(/&/g, '&'); const detected = this.detectProvider(url); if (!detected) { return `

Embed: niet-ondersteunde of ongeldige URL.
`; } return this.generateIframe(detected.provider, detected) || match; }); } /** * Replace [[track:]] shortcodes with v9-style player markup. * Caller passes a lookup function (id) -> { id, title, artist, url, cover } * where url is already a signed /audio/stream/... URL. Unknown ids → left as-is. */ static embedTrackShortcodes(html, trackLookup) { if (!html || typeof trackLookup !== 'function') return html; return html.replace(/\[\[track:([A-Za-z0-9_-]+)\]\]/g, (match, id) => { const t = trackLookup(id); if (!t) return match; const titleH0 = this.escape(t.title || 'Untitled'); const artistH0 = this.escape(t.artist || ''); const creditBits0 = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · '); // Link-only track (geen audiobestand): geen afspeelknop, wel info + open-in. if (!t.url) { const coverH0 = this.escape(t.cover || ''); const leader0 = coverH0 ? `` : ``; return `
${leader0}
${titleH0}
${artistH0 ? `
${artistH0}
` : ''} ${creditBits0 ? `
${creditBits0}
` : ''}
${this.openInLinks(t)}
`; } const trackJson = JSON.stringify({ id, url: t.url, title: t.title || 'Untitled', artist: t.artist || '', cover: t.cover || '', credit: t.credit || '', license: t.license || '', }); const titleH = this.escape(t.title || 'Untitled'); const artistH = this.escape(t.artist || ''); const urlH = this.escape(t.url); // Zichtbare eigenaar/licentie-regel onder de track. const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · '); const dataAttr = trackJson .replace(/&/g, '&').replace(/'/g, ''').replace(/
${titleH}
${artistH ? `
${artistH}
` : ''} ${creditBits ? `
${creditBits}
` : ''}
${this.openInLinks(t)} `; }); } /** * Replace [[album:]] shortcodes with a v9-style album block. * Caller passes a lookup function (name) -> { title, artist, cover, tracks: [{url,title,artist,cover}, ...] } * Tracks must already have signed URLs. Unknown albums → left as-is. * The wrapper carries the full album JSON so audio-player.js can queue it * when any track or the album play button is clicked. */ static embedAlbumShortcodes(html, albumLookup) { if (!html || typeof albumLookup !== 'function') return html; return html.replace(/\[\[album:([^\]]+)\]\]/g, (match, rawName) => { const name = rawName.trim(); const album = albumLookup(name); if (!album || !album.tracks || !album.tracks.length) return match; // Stable DOM id for this rendering — used as data-pcms-album-id on tracks const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10); // Alleen afspeelbare tracks (met url) in de queue; link-only tracks staan // wel in de lijst maar niet in de afspeel-JSON. const albumJson = JSON.stringify(album.tracks.filter((t) => t.url)) .replace(/&/g, '&').replace(/'/g, ''').replace(/ { const tTitle = this.escape(t.title || ('Track ' + (i + 1))); const tArtist = this.escape(t.artist || ''); // Link-only track: geen afspeelknop, wel nummer + info + open-in. if (!t.url) { return `
  • ${i + 1}.
    ${tTitle}
    ${tArtist && tArtist !== artistH ? `
    ${tArtist}
    ` : ''}
    ${this.openInLinks(t)}
  • `; } const tUrl = this.escape(t.url); return `
  • ${i + 1}.
    ${tTitle}
    ${tArtist && tArtist !== artistH ? `
    ${tArtist}
    ` : ''}
    ${this.openInLinks(t)}
  • `; }).join('\n'); return `

    ${titleH}

    ${artistH ? `

    ${artistH}

    ` : ''}

    ${album.tracks.length} track${album.tracks.length === 1 ? '' : 's'}

      ${trackItems}
    `; }); } /** * Replace [[playlist:]] shortcodes with a v9-style album block. * Caller passes a lookup function (id) -> hydrated playlist object from * PlaylistService.get(), or null. Unknown playlists render an inline * "niet gevonden" placeholder so the post still validates as HTML. * * Shape returned by lookup: * { id, title, artist, year, cover, kind, tracks: [{url,title,artist,cover,duration}, ...] } * * `kind` is honored: * - 'album' → ordered list with track numbers * - 'playlist' → list with per-track cover thumbnails (mixtape feel) * * opts: { isAdmin: boolean } — when true, an edit/delete action overlay * is rendered top-right of each card. The handlers are wired up in * audio-player.js via event delegation on data-pcms-playlist-delete. */ static embedPlaylistShortcodes(html, playlistLookup, opts = {}) { if (!html || typeof playlistLookup !== 'function') return html; const isAdmin = !!opts.isAdmin; return html.replace(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi, (match, rawId) => { const id = rawId.toLowerCase(); const pl = playlistLookup(id); if (!pl) { return `
    Playlist "${this.escape(id)}" niet gevonden.
    `; } if (!pl.tracks || !pl.tracks.length) { return `
    Playlist "${this.escape(pl.title)}" heeft geen beschikbare tracks.
    `; } const albumDomId = 'album-' + id; const kind = (pl.kind === 'playlist') ? 'playlist' : 'album'; const kindLabel = kind === 'playlist' ? '📃 Playlist' : '💿 Album'; const titleH = this.escape(pl.title || 'Naamloos'); const artistH = this.escape(pl.artist || ''); const coverH = pl.cover ? this.escape(pl.cover) : ''; // Audio-player.js reads data-pcms-album for queue. Same shape as // embedAlbumShortcodes — keep both in sync. // Alleen afspeelbare tracks in de queue; link-only tracks staan wel in de // lijst maar niet in de afspeel-JSON. const tracksData = pl.tracks.filter(t => t.url).map(t => ({ id: t.id, url: t.url, title: t.title, artist: t.artist || pl.artist || '', cover: t.cover || pl.cover || '', })); const albumJson = JSON.stringify(tracksData) .replace(/&/g, '&').replace(/'/g, ''').replace(/ s + (t.duration || 0), 0); const metaParts = []; if (pl.year) metaParts.push(String(pl.year)); metaParts.push(pl.tracks.length + (pl.tracks.length === 1 ? ' track' : ' tracks')); if (totalSec > 0) { const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); if (h > 0) metaParts.push(`${h}h ${m}m`); else metaParts.push(`${Math.max(1, m)} min`); } const metaLine = this.escape(metaParts.join(' · ')); const firstUrl = this.escape((pl.tracks.find(t => t.url) || {}).url || ''); // Track items — playlist-kind shows per-track cover thumbs, album-kind shows numbers const trackItems = pl.tracks.map((t, i) => { const tTitleH = this.escape(t.title || ('Track ' + (i + 1))); const tArtistH = this.escape(t.artist || ''); const tUrl = this.escape(t.url); const showArtist = tArtistH && tArtistH !== artistH; // Duration cell — render even when 0 for consistent column layout const durHtml = t.duration > 0 ? `${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}` : `—:—`; // Leader cell — number for albums, cover thumb for playlists const leader = (kind === 'playlist' && t.cover) ? `` : `${i + 1}`; // Link-only track: geen klikbare afspeel-rij (statische div), wel open-in. if (!t.url) { return `
  • ${leader} ${tTitleH} ${showArtist ? `${tArtistH}` : ''} ${durHtml}
    ${this.openInLinks(t)}
  • `; } const trackBase = String(t.url).split('?')[0]; return `
  • ${this.openInLinks(t)}
  • `; }).join('\n'); return `
    ${isAdmin ? `
    ` : ''}

    ${kindLabel}

    ${titleH}

    ${artistH ? `

    ${artistH}

    ` : ''}

    ${metaLine}

      ${trackItems}
    `; }); } /** * Human-readable label for a provider slug. Used by external-link buttons. */ static platformLabel(provider) { return ({ spotify: 'Spotify', bandcamp: 'Bandcamp', soundcloud: 'SoundCloud', applemusic: 'Apple Music', youtube: 'YouTube', vimeo: 'Vimeo', tidal: 'Tidal', deezer: 'Deezer', mixcloud: 'Mixcloud', })[provider] || 'External link'; } /** * Detect platform from URL purely by hostname (covers more services than * detectProvider, which is embed-focused). Used for [[link:url]] rendering. */ static detectLinkPlatform(url) { try { const host = new URL(url).hostname.toLowerCase(); if (host.includes('open.spotify.com') || host === 'spotify.com') return 'spotify'; if (host.includes('bandcamp.com')) return 'bandcamp'; if (host.includes('soundcloud.com')) return 'soundcloud'; if (host.includes('music.apple.com') || host.includes('itunes.apple.com')) return 'applemusic'; if (host.includes('youtube.com') || host.includes('youtu.be') || host.includes('music.youtube.com')) return 'youtube'; if (host.includes('vimeo.com')) return 'vimeo'; if (host.includes('tidal.com')) return 'tidal'; if (host.includes('deezer.com')) return 'deezer'; if (host.includes('mixcloud.com')) return 'mixcloud'; return 'other'; } catch (e) { return null; } } /** * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a * branded "Open in " anchor (no iframe). Opens in new tab. * Per Robin's v9: "Externe link, klik = open platform (target _blank)". */ static embedExternalLinkShortcodes(html) { if (!html) return html; return html.replace(/\[\[link:([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, rawUrl, customLabel) => { const url = rawUrl.trim(); if (!/^https?:\/\//i.test(url)) return match; const platform = this.detectLinkPlatform(url) || 'other'; const label = (customLabel || '').trim(); const platformLabel = this.platformLabel(platform); const buttonText = label || `Open in ${platformLabel}`; const urlH = this.escape(url); const textH = this.escape(buttonText); return ` ${textH} `; }); } static escape(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } } export default AudioEmbedService;