/**
* 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 || !t.url) return match;
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(/" = anker zodat de mini-speler hierheen kan scrollen.
return `
${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);
const albumJson = JSON.stringify(album.tracks)
.replace(/&/g, '&').replace(/'/g, ''').replace(/ {
const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
const tArtist = this.escape(t.artist || '');
const tUrl = this.escape(t.url);
return `
${i + 1}.
${tTitle}
${tArtist && tArtist !== artistH ? `
${tArtist}
` : ''}
${this.openInLinks(t)}
`;
}).join('\n');
return ``;
});
}
/**
* 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.
const tracksData = pl.tracks.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[0].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}`;
const trackBase = String(t.url).split('?')[0];
return `
${this.openInLinks(t)}
`;
}).join('\n');
return `
${isAdmin ? `
` : ''}
${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;