| 1 | /**
|
|---|
| 2 | * AudioEmbedService — Parse URLs and return embed HTML
|
|---|
| 3 | * Supports: Spotify, Bandcamp, SoundCloud, Apple Music, YouTube, Vimeo
|
|---|
| 4 | *
|
|---|
| 5 | * Usage in post content:
|
|---|
| 6 | * <p>https://open.spotify.com/track/123abc</p>
|
|---|
| 7 | * →
|
|---|
| 8 | * <figure class="folio-embed folio-embed--spotify">
|
|---|
| 9 | * <iframe src="..."></iframe>
|
|---|
| 10 | * </figure>
|
|---|
| 11 | */
|
|---|
| 12 |
|
|---|
| 13 | class AudioEmbedService {
|
|---|
| 14 | static detectProvider(url) {
|
|---|
| 15 | if (!url || typeof url !== 'string') return null;
|
|---|
| 16 | url = url.trim();
|
|---|
| 17 |
|
|---|
| 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 |
|
|---|
| 25 | // Spotify
|
|---|
| 26 | if (/open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i.test(url)) {
|
|---|
| 27 | const match = url.match(/\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i);
|
|---|
| 28 | return { provider: 'spotify', type: match[1], id: match[2], url };
|
|---|
| 29 | }
|
|---|
| 30 |
|
|---|
| 31 | // Bandcamp
|
|---|
| 32 | if (/bandcamp\.com\/(track|album)/i.test(url)) {
|
|---|
| 33 | return { provider: 'bandcamp', url };
|
|---|
| 34 | }
|
|---|
| 35 |
|
|---|
| 36 | // SoundCloud
|
|---|
| 37 | if (/soundcloud\.com/i.test(url)) {
|
|---|
| 38 | return { provider: 'soundcloud', url };
|
|---|
| 39 | }
|
|---|
| 40 |
|
|---|
| 41 | // Apple Music
|
|---|
| 42 | if (/music\.apple\.com\/([a-z]{2})\/(?:album|playlist|song)\//i.test(url)) {
|
|---|
| 43 | return { provider: 'applemusic', url };
|
|---|
| 44 | }
|
|---|
| 45 |
|
|---|
| 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 };
|
|---|
| 51 | }
|
|---|
| 52 |
|
|---|
| 53 | // Vimeo
|
|---|
| 54 | if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) {
|
|---|
| 55 | const match = url.match(/\d+/);
|
|---|
| 56 | return { provider: 'vimeo', id: match[0], url };
|
|---|
| 57 | }
|
|---|
| 58 |
|
|---|
| 59 | return null;
|
|---|
| 60 | }
|
|---|
| 61 |
|
|---|
| 62 | static generateIframe(provider, config) {
|
|---|
| 63 | 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);
|
|---|
| 72 | case 'spotify':
|
|---|
| 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.
|
|---|
| 77 | case 'bandcamp':
|
|---|
| 78 | return this.bandcampIframe(config);
|
|---|
| 79 | case 'applemusic':
|
|---|
| 80 | return this.applemusicIframe(config);
|
|---|
| 81 | case 'vimeo':
|
|---|
| 82 | return this.vimeoIframe(config);
|
|---|
| 83 | default:
|
|---|
| 84 | return null;
|
|---|
| 85 | }
|
|---|
| 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>`;
|
|---|
| 101 | }
|
|---|
| 102 |
|
|---|
| 103 | static spotifyIframe({ type, id }) {
|
|---|
| 104 | const src = `https://open.spotify.com/embed/${type}/${id}`;
|
|---|
| 105 | return `
|
|---|
| 106 | <figure class="folio-embed folio-embed--spotify">
|
|---|
| 107 | <iframe src="${this.escape(src)}"
|
|---|
| 108 | style="width:100%;height:152px;border:0;"
|
|---|
| 109 | loading="lazy"
|
|---|
| 110 | allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
|---|
| 111 | title="Spotify ${type}"></iframe>
|
|---|
| 112 | </figure>
|
|---|
| 113 | `.trim();
|
|---|
| 114 | }
|
|---|
| 115 |
|
|---|
| 116 | static bandcampIframe({ url }) {
|
|---|
| 117 | const encodedUrl = encodeURIComponent(url);
|
|---|
| 118 | const src = `https://bandcamp.com/EmbeddedPlayer/url=${encodedUrl}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/`;
|
|---|
| 119 | return `
|
|---|
| 120 | <figure class="folio-embed folio-embed--bandcamp">
|
|---|
| 121 | <iframe src="${this.escape(src)}"
|
|---|
| 122 | style="width:100%;height:470px;border:0;"
|
|---|
| 123 | loading="lazy"
|
|---|
| 124 | allow="encrypted-media"
|
|---|
| 125 | title="Bandcamp player"></iframe>
|
|---|
| 126 | </figure>
|
|---|
| 127 | `.trim();
|
|---|
| 128 | }
|
|---|
| 129 |
|
|---|
| 130 | static soundcloudIframe({ url }) {
|
|---|
| 131 | const params = {
|
|---|
| 132 | url: url,
|
|---|
| 133 | color: '#ff5500',
|
|---|
| 134 | auto_play: 'false',
|
|---|
| 135 | hide_related: 'true',
|
|---|
| 136 | show_comments: 'false',
|
|---|
| 137 | show_user: 'true',
|
|---|
| 138 | show_reposts: 'false',
|
|---|
| 139 | show_teaser: 'false',
|
|---|
| 140 | visual: 'true'
|
|---|
| 141 | };
|
|---|
| 142 | const query = new URLSearchParams(params).toString();
|
|---|
| 143 | const src = `https://w.soundcloud.com/player/?${query}`;
|
|---|
| 144 | return `
|
|---|
| 145 | <figure class="folio-embed folio-embed--soundcloud">
|
|---|
| 146 | <iframe src="${this.escape(src)}"
|
|---|
| 147 | style="width:100%;height:300px;border:0;"
|
|---|
| 148 | loading="lazy"
|
|---|
| 149 | allow="autoplay; clipboard-write; encrypted-media"
|
|---|
| 150 | title="SoundCloud player"></iframe>
|
|---|
| 151 | </figure>
|
|---|
| 152 | `.trim();
|
|---|
| 153 | }
|
|---|
| 154 |
|
|---|
| 155 | static applemusicIframe({ url }) {
|
|---|
| 156 | const match = url.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i);
|
|---|
| 157 | if (!match) return null;
|
|---|
| 158 | const src = `https://embed.music.apple.com/${match[1]}`;
|
|---|
| 159 | return `
|
|---|
| 160 | <figure class="folio-embed folio-embed--applemusic">
|
|---|
| 161 | <iframe src="${this.escape(src)}"
|
|---|
| 162 | style="width:100%;height:175px;border:0;overflow:hidden;border-radius:8px;"
|
|---|
| 163 | loading="lazy"
|
|---|
| 164 | allow="autoplay; clipboard-write; encrypted-media"
|
|---|
| 165 | title="Apple Music"></iframe>
|
|---|
| 166 | </figure>
|
|---|
| 167 | `.trim();
|
|---|
| 168 | }
|
|---|
| 169 |
|
|---|
| 170 | static youtubeIframe({ id }) {
|
|---|
| 171 | const src = `https://www.youtube-nocookie.com/embed/${id}`;
|
|---|
| 172 | return `
|
|---|
| 173 | <figure class="folio-embed folio-embed--youtube">
|
|---|
| 174 | <iframe src="${this.escape(src)}"
|
|---|
| 175 | style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
|
|---|
| 176 | loading="lazy"
|
|---|
| 177 | allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|---|
| 178 | allowfullscreen
|
|---|
| 179 | title="YouTube video"></iframe>
|
|---|
| 180 | </figure>
|
|---|
| 181 | `.trim();
|
|---|
| 182 | }
|
|---|
| 183 |
|
|---|
| 184 | static vimeoIframe({ id }) {
|
|---|
| 185 | const src = `https://player.vimeo.com/video/${id}`;
|
|---|
| 186 | return `
|
|---|
| 187 | <figure class="folio-embed folio-embed--vimeo">
|
|---|
| 188 | <iframe src="${this.escape(src)}"
|
|---|
| 189 | style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
|
|---|
| 190 | loading="lazy"
|
|---|
| 191 | allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|---|
| 192 | allowfullscreen
|
|---|
| 193 | title="Vimeo video"></iframe>
|
|---|
| 194 | </figure>
|
|---|
| 195 | `.trim();
|
|---|
| 196 | }
|
|---|
| 197 |
|
|---|
| 198 | /**
|
|---|
| 199 | * Auto-embed: Scan paragraphs containing only a URL.
|
|---|
| 200 | * Handles two markdown-rendered shapes:
|
|---|
| 201 | * <p>https://url</p> (bare URL — when GFM auto-link is off)
|
|---|
| 202 | * <p><a href="https://url">https://url</a></p> (marked GFM auto-link — what we get)
|
|---|
| 203 | * Either way → <figure class="folio-embed">...
|
|---|
| 204 | */
|
|---|
| 205 | static autoembed(html) {
|
|---|
| 206 | if (!html) return html;
|
|---|
| 207 | return html.replace(
|
|---|
| 208 | /<p>\s*(?:<a\b[^>]*?\shref="([^"]+)"[^>]*>[^<]*<\/a>|(https?:\/\/[^\s<>"']+))\s*<\/p>/gi,
|
|---|
| 209 | (match, hrefUrl, bareUrl) => {
|
|---|
| 210 | const url = hrefUrl || bareUrl;
|
|---|
| 211 | const detected = this.detectProvider(url);
|
|---|
| 212 | if (detected) {
|
|---|
| 213 | const iframe = this.generateIframe(detected.provider, detected);
|
|---|
| 214 | return iframe || match;
|
|---|
| 215 | }
|
|---|
| 216 | return match;
|
|---|
| 217 | }
|
|---|
| 218 | );
|
|---|
| 219 | }
|
|---|
| 220 |
|
|---|
| 221 | /**
|
|---|
| 222 | * Replace [[embed:<url>]] shortcodes met de platform-iframe (YouTube, Spotify,
|
|---|
| 223 | * SoundCloud, Apple Music, Bandcamp, Vimeo). De editor-knop voegt deze
|
|---|
| 224 | * shortcode in; losse URL-regels embedden ook automatisch via autoembed().
|
|---|
| 225 | * Niet-ondersteunde/ongeldige URLs krijgen een nette inline-melding.
|
|---|
| 226 | */
|
|---|
| 227 | static embedMediaShortcodes(html) {
|
|---|
| 228 | if (!html) return html;
|
|---|
| 229 | return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawUrl) => {
|
|---|
| 230 | const url = rawUrl.trim().replace(/&/g, '&');
|
|---|
| 231 | const detected = this.detectProvider(url);
|
|---|
| 232 | if (!detected) {
|
|---|
| 233 | return `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
|
|---|
| 234 | }
|
|---|
| 235 | return this.generateIframe(detected.provider, detected) || match;
|
|---|
| 236 | });
|
|---|
| 237 | }
|
|---|
| 238 |
|
|---|
| 239 | /**
|
|---|
| 240 | * Replace [[track:<id>]] shortcodes with v9-style player markup.
|
|---|
| 241 | * Caller passes a lookup function (id) -> { id, title, artist, url, cover }
|
|---|
| 242 | * where url is already a signed /audio/stream/... URL. Unknown ids → left as-is.
|
|---|
| 243 | */
|
|---|
| 244 | static embedTrackShortcodes(html, trackLookup) {
|
|---|
| 245 | if (!html || typeof trackLookup !== 'function') return html;
|
|---|
| 246 | return html.replace(/\[\[track:([A-Za-z0-9_-]+)\]\]/g, (match, id) => {
|
|---|
| 247 | const t = trackLookup(id);
|
|---|
| 248 | if (!t || !t.url) return match;
|
|---|
| 249 | const trackJson = JSON.stringify({
|
|---|
| 250 | id,
|
|---|
| 251 | url: t.url,
|
|---|
| 252 | title: t.title || 'Untitled',
|
|---|
| 253 | artist: t.artist || '',
|
|---|
| 254 | cover: t.cover || '',
|
|---|
| 255 | });
|
|---|
| 256 | const titleH = this.escape(t.title || 'Untitled');
|
|---|
| 257 | const artistH = this.escape(t.artist || '');
|
|---|
| 258 | const urlH = this.escape(t.url);
|
|---|
| 259 | const dataAttr = trackJson
|
|---|
| 260 | .replace(/&/g, '&').replace(/'/g, ''').replace(/</g, '<');
|
|---|
| 261 | // id="track-<id>" = anker zodat de mini-speler hierheen kan scrollen.
|
|---|
| 262 | return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
|
|---|
| 263 | <button type="button" class="pat-play" aria-label="Play ${titleH}">
|
|---|
| 264 | <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
|
|---|
| 265 | </button>
|
|---|
| 266 | <div class="pat-info">
|
|---|
| 267 | <div class="pat-title">${titleH}</div>
|
|---|
| 268 | ${artistH ? `<div class="pat-artist">${artistH}</div>` : ''}
|
|---|
| 269 | </div>
|
|---|
| 270 | </div>`;
|
|---|
| 271 | });
|
|---|
| 272 | }
|
|---|
| 273 |
|
|---|
| 274 | /**
|
|---|
| 275 | * Replace [[album:<name>]] shortcodes with a v9-style album block.
|
|---|
| 276 | * Caller passes a lookup function (name) -> { title, artist, cover, tracks: [{url,title,artist,cover}, ...] }
|
|---|
| 277 | * Tracks must already have signed URLs. Unknown albums → left as-is.
|
|---|
| 278 | * The wrapper carries the full album JSON so audio-player.js can queue it
|
|---|
| 279 | * when any track or the album play button is clicked.
|
|---|
| 280 | */
|
|---|
| 281 | static embedAlbumShortcodes(html, albumLookup) {
|
|---|
| 282 | if (!html || typeof albumLookup !== 'function') return html;
|
|---|
| 283 | return html.replace(/\[\[album:([^\]]+)\]\]/g, (match, rawName) => {
|
|---|
| 284 | const name = rawName.trim();
|
|---|
| 285 | const album = albumLookup(name);
|
|---|
| 286 | if (!album || !album.tracks || !album.tracks.length) return match;
|
|---|
| 287 |
|
|---|
| 288 | // Stable DOM id for this rendering — used as data-pcms-album-id on tracks
|
|---|
| 289 | const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10);
|
|---|
| 290 | const albumJson = JSON.stringify(album.tracks)
|
|---|
| 291 | .replace(/&/g, '&').replace(/'/g, ''').replace(/</g, '<');
|
|---|
| 292 | const titleH = this.escape(album.title || name);
|
|---|
| 293 | const artistH = this.escape(album.artist || '');
|
|---|
| 294 | const coverH = album.cover ? this.escape(album.cover) : '';
|
|---|
| 295 |
|
|---|
| 296 | const trackItems = album.tracks.map((t, i) => {
|
|---|
| 297 | const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
|
|---|
| 298 | const tArtist = this.escape(t.artist || '');
|
|---|
| 299 | const tUrl = this.escape(t.url);
|
|---|
| 300 | return ` <li class="post-audio-track"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''} data-pcms-track-url="${tUrl}" data-pcms-album-id="${albumDomId}">
|
|---|
| 301 | <button type="button" class="pat-play" aria-label="Play ${tTitle}">
|
|---|
| 302 | <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
|
|---|
| 303 | </button>
|
|---|
| 304 | <div class="pat-info">
|
|---|
| 305 | <span class="pat-track-num">${i + 1}.</span>
|
|---|
| 306 | <div class="pat-title">${tTitle}</div>
|
|---|
| 307 | ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
|
|---|
| 308 | </div>
|
|---|
| 309 | </li>`;
|
|---|
| 310 | }).join('\n');
|
|---|
| 311 |
|
|---|
| 312 | return `<div class="post-album" id="${albumDomId}" data-pcms-album='${albumJson}' data-pcms-album-title="${titleH}">
|
|---|
| 313 | <div class="post-album-header">
|
|---|
| 314 | <button type="button" class="post-album-cover-btn" data-pcms-album-id="${albumDomId}" aria-label="Play album ${titleH}">
|
|---|
| 315 | ${coverH
|
|---|
| 316 | ? `<img src="${coverH}" alt="" class="post-album-cover-img">`
|
|---|
| 317 | : `<svg class="post-album-cover-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>`}
|
|---|
| 318 | <span class="post-album-play-overlay" aria-hidden="true">
|
|---|
| 319 | <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 4l12 8-12 8z"/></svg>
|
|---|
| 320 | </span>
|
|---|
| 321 | </button>
|
|---|
| 322 | <div class="post-album-info">
|
|---|
| 323 | <h3 class="post-album-title">${titleH}</h3>
|
|---|
| 324 | ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
|
|---|
| 325 | <p class="post-album-count">${album.tracks.length} track${album.tracks.length === 1 ? '' : 's'}</p>
|
|---|
| 326 | </div>
|
|---|
| 327 | </div>
|
|---|
| 328 | <ol class="post-album-tracks">
|
|---|
| 329 | ${trackItems}
|
|---|
| 330 | </ol>
|
|---|
| 331 | </div>`;
|
|---|
| 332 | });
|
|---|
| 333 | }
|
|---|
| 334 |
|
|---|
| 335 | /**
|
|---|
| 336 | * Replace [[playlist:<id>]] shortcodes with a v9-style album block.
|
|---|
| 337 | * Caller passes a lookup function (id) -> hydrated playlist object from
|
|---|
| 338 | * PlaylistService.get(), or null. Unknown playlists render an inline
|
|---|
| 339 | * "niet gevonden" placeholder so the post still validates as HTML.
|
|---|
| 340 | *
|
|---|
| 341 | * Shape returned by lookup:
|
|---|
| 342 | * { id, title, artist, year, cover, kind, tracks: [{url,title,artist,cover,duration}, ...] }
|
|---|
| 343 | *
|
|---|
| 344 | * `kind` is honored:
|
|---|
| 345 | * - 'album' → ordered list with track numbers
|
|---|
| 346 | * - 'playlist' → list with per-track cover thumbnails (mixtape feel)
|
|---|
| 347 | *
|
|---|
| 348 | * opts: { isAdmin: boolean } — when true, an edit/delete action overlay
|
|---|
| 349 | * is rendered top-right of each card. The handlers are wired up in
|
|---|
| 350 | * audio-player.js via event delegation on data-pcms-playlist-delete.
|
|---|
| 351 | */
|
|---|
| 352 | static embedPlaylistShortcodes(html, playlistLookup, opts = {}) {
|
|---|
| 353 | if (!html || typeof playlistLookup !== 'function') return html;
|
|---|
| 354 | const isAdmin = !!opts.isAdmin;
|
|---|
| 355 | return html.replace(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi, (match, rawId) => {
|
|---|
| 356 | const id = rawId.toLowerCase();
|
|---|
| 357 | const pl = playlistLookup(id);
|
|---|
| 358 |
|
|---|
| 359 | if (!pl) {
|
|---|
| 360 | return `<div class="post-playlist-missing"><em>Playlist "${this.escape(id)}" niet gevonden.</em></div>`;
|
|---|
| 361 | }
|
|---|
| 362 | if (!pl.tracks || !pl.tracks.length) {
|
|---|
| 363 | return `<div class="post-playlist-empty"><em>Playlist "${this.escape(pl.title)}" heeft geen beschikbare tracks.</em></div>`;
|
|---|
| 364 | }
|
|---|
| 365 |
|
|---|
| 366 | const albumDomId = 'album-' + id;
|
|---|
| 367 | const kind = (pl.kind === 'playlist') ? 'playlist' : 'album';
|
|---|
| 368 | const kindLabel = kind === 'playlist' ? '📃 Playlist' : '💿 Album';
|
|---|
| 369 | const titleH = this.escape(pl.title || 'Naamloos');
|
|---|
| 370 | const artistH = this.escape(pl.artist || '');
|
|---|
| 371 | const coverH = pl.cover ? this.escape(pl.cover) : '';
|
|---|
| 372 |
|
|---|
| 373 | // Audio-player.js reads data-pcms-album for queue. Same shape as
|
|---|
| 374 | // embedAlbumShortcodes — keep both in sync.
|
|---|
| 375 | const tracksData = pl.tracks.map(t => ({
|
|---|
| 376 | url: t.url,
|
|---|
| 377 | title: t.title,
|
|---|
| 378 | artist: t.artist || pl.artist || '',
|
|---|
| 379 | cover: t.cover || pl.cover || '',
|
|---|
| 380 | }));
|
|---|
| 381 | const albumJson = JSON.stringify(tracksData)
|
|---|
| 382 | .replace(/&/g, '&').replace(/'/g, ''').replace(/</g, '<');
|
|---|
| 383 |
|
|---|
| 384 | // Total duration for the meta line
|
|---|
| 385 | const totalSec = pl.tracks.reduce((s, t) => s + (t.duration || 0), 0);
|
|---|
| 386 | const metaParts = [];
|
|---|
| 387 | if (pl.year) metaParts.push(String(pl.year));
|
|---|
| 388 | metaParts.push(pl.tracks.length + (pl.tracks.length === 1 ? ' track' : ' tracks'));
|
|---|
| 389 | if (totalSec > 0) {
|
|---|
| 390 | const h = Math.floor(totalSec / 3600);
|
|---|
| 391 | const m = Math.floor((totalSec % 3600) / 60);
|
|---|
| 392 | if (h > 0) metaParts.push(`${h}h ${m}m`);
|
|---|
| 393 | else metaParts.push(`${Math.max(1, m)} min`);
|
|---|
| 394 | }
|
|---|
| 395 | const metaLine = this.escape(metaParts.join(' · '));
|
|---|
| 396 | const firstUrl = this.escape(pl.tracks[0].url);
|
|---|
| 397 |
|
|---|
| 398 | // Track items — playlist-kind shows per-track cover thumbs, album-kind shows numbers
|
|---|
| 399 | const trackItems = pl.tracks.map((t, i) => {
|
|---|
| 400 | const tTitleH = this.escape(t.title || ('Track ' + (i + 1)));
|
|---|
| 401 | const tArtistH = this.escape(t.artist || '');
|
|---|
| 402 | const tUrl = this.escape(t.url);
|
|---|
| 403 | const showArtist = tArtistH && tArtistH !== artistH;
|
|---|
| 404 |
|
|---|
| 405 | // Duration cell — render even when 0 for consistent column layout
|
|---|
| 406 | const durHtml = t.duration > 0
|
|---|
| 407 | ? `<span class="pat-duration">${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}</span>`
|
|---|
| 408 | : `<span class="pat-duration pat-duration-empty">—:—</span>`;
|
|---|
| 409 |
|
|---|
| 410 | // Leader cell — number for albums, cover thumb for playlists
|
|---|
| 411 | const leader = (kind === 'playlist' && t.cover)
|
|---|
| 412 | ? `<span class="pat-cover" style="background-image:url(${this.escape(t.cover)})" aria-hidden="true"></span>`
|
|---|
| 413 | : `<span class="pat-num">${i + 1}</span>`;
|
|---|
| 414 |
|
|---|
| 415 | const trackBase = String(t.url).split('?')[0];
|
|---|
| 416 | return ` <li class="post-album-track-compact"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''}>
|
|---|
| 417 | <button type="button" class="pat-row"
|
|---|
| 418 | data-pcms-track-url="${tUrl}"
|
|---|
| 419 | data-pcms-album-id="${albumDomId}"
|
|---|
| 420 | data-pcms-track-base="${this.escape(trackBase)}"
|
|---|
| 421 | aria-label="Speel ${tTitleH}">
|
|---|
| 422 | ${leader}
|
|---|
| 423 | <span class="pat-meta">
|
|---|
| 424 | <span class="pat-title">${tTitleH}</span>
|
|---|
| 425 | ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
|
|---|
| 426 | </span>
|
|---|
| 427 | ${durHtml}
|
|---|
| 428 | </button>
|
|---|
| 429 | </li>`;
|
|---|
| 430 | }).join('\n');
|
|---|
| 431 |
|
|---|
| 432 | return `<div class="post-album" id="${albumDomId}"
|
|---|
| 433 | data-pcms-album='${albumJson}'
|
|---|
| 434 | data-pcms-album-title="${titleH}"
|
|---|
| 435 | data-pcms-album-kind="${kind}"
|
|---|
| 436 | data-pcms-playlist-id="${this.escape(id)}">
|
|---|
| 437 | ${isAdmin ? ` <div class="post-album-actions" role="group" aria-label="Playlist beheren">
|
|---|
| 438 | <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk playlist" aria-label="Bewerk playlist">
|
|---|
| 439 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
|---|
| 440 | </a>
|
|---|
| 441 | <button type="button" class="post-album-action is-danger" data-pcms-playlist-delete="${this.escape(id)}" data-pcms-playlist-title="${titleH}" title="Verwijder playlist" aria-label="Verwijder playlist">
|
|---|
| 442 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/></svg>
|
|---|
| 443 | </button>
|
|---|
| 444 | </div>
|
|---|
| 445 | ` : ''} <div class="post-album-header">
|
|---|
| 446 | <button type="button" class="post-album-cover-btn"
|
|---|
| 447 | data-pcms-track-url="${firstUrl}"
|
|---|
| 448 | data-pcms-album-id="${albumDomId}"
|
|---|
| 449 | aria-label="Speel ${kind === 'playlist' ? 'playlist' : 'album'}">
|
|---|
| 450 | ${coverH
|
|---|
| 451 | ? `<span class="post-album-cover" style="background-image:url('${coverH}')"></span>`
|
|---|
| 452 | : `<span class="post-album-cover post-album-cover-empty">
|
|---|
| 453 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3" fill="currentColor"/><circle cx="18" cy="15" r="3" fill="currentColor"/></svg>
|
|---|
| 454 | </span>`}
|
|---|
| 455 | <span class="post-album-cover-play" aria-hidden="true">
|
|---|
| 456 | <svg viewBox="0 0 24 24"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>
|
|---|
| 457 | </span>
|
|---|
| 458 | </button>
|
|---|
| 459 | <div class="post-album-info">
|
|---|
| 460 | <p class="post-album-label">${kindLabel}</p>
|
|---|
| 461 | <h3 class="post-album-title">${titleH}</h3>
|
|---|
| 462 | ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
|
|---|
| 463 | <p class="post-album-meta">${metaLine}</p>
|
|---|
| 464 | </div>
|
|---|
| 465 | </div>
|
|---|
| 466 | <ol class="post-album-tracks post-album-tracks-compact" data-album-kind="${kind}">
|
|---|
| 467 | ${trackItems}
|
|---|
| 468 | </ol>
|
|---|
| 469 | </div>`;
|
|---|
| 470 | });
|
|---|
| 471 | }
|
|---|
| 472 |
|
|---|
| 473 | /**
|
|---|
| 474 | * Human-readable label for a provider slug. Used by external-link buttons.
|
|---|
| 475 | */
|
|---|
| 476 | static platformLabel(provider) {
|
|---|
| 477 | return ({
|
|---|
| 478 | spotify: 'Spotify',
|
|---|
| 479 | bandcamp: 'Bandcamp',
|
|---|
| 480 | soundcloud: 'SoundCloud',
|
|---|
| 481 | applemusic: 'Apple Music',
|
|---|
| 482 | youtube: 'YouTube',
|
|---|
| 483 | vimeo: 'Vimeo',
|
|---|
| 484 | tidal: 'Tidal',
|
|---|
| 485 | deezer: 'Deezer',
|
|---|
| 486 | mixcloud: 'Mixcloud',
|
|---|
| 487 | })[provider] || 'External link';
|
|---|
| 488 | }
|
|---|
| 489 |
|
|---|
| 490 | /**
|
|---|
| 491 | * Detect platform from URL purely by hostname (covers more services than
|
|---|
| 492 | * detectProvider, which is embed-focused). Used for [[link:url]] rendering.
|
|---|
| 493 | */
|
|---|
| 494 | static detectLinkPlatform(url) {
|
|---|
| 495 | try {
|
|---|
| 496 | const host = new URL(url).hostname.toLowerCase();
|
|---|
| 497 | if (host.includes('open.spotify.com') || host === 'spotify.com') return 'spotify';
|
|---|
| 498 | if (host.includes('bandcamp.com')) return 'bandcamp';
|
|---|
| 499 | if (host.includes('soundcloud.com')) return 'soundcloud';
|
|---|
| 500 | if (host.includes('music.apple.com') || host.includes('itunes.apple.com')) return 'applemusic';
|
|---|
| 501 | if (host.includes('youtube.com') || host.includes('youtu.be') || host.includes('music.youtube.com')) return 'youtube';
|
|---|
| 502 | if (host.includes('vimeo.com')) return 'vimeo';
|
|---|
| 503 | if (host.includes('tidal.com')) return 'tidal';
|
|---|
| 504 | if (host.includes('deezer.com')) return 'deezer';
|
|---|
| 505 | if (host.includes('mixcloud.com')) return 'mixcloud';
|
|---|
| 506 | return 'other';
|
|---|
| 507 | } catch (e) {
|
|---|
| 508 | return null;
|
|---|
| 509 | }
|
|---|
| 510 | }
|
|---|
| 511 |
|
|---|
| 512 | /**
|
|---|
| 513 | * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
|
|---|
| 514 | * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
|
|---|
| 515 | * Per Robin's v9: "Externe link, klik = open platform (target _blank)".
|
|---|
| 516 | */
|
|---|
| 517 | static embedExternalLinkShortcodes(html) {
|
|---|
| 518 | if (!html) return html;
|
|---|
| 519 | return html.replace(/\[\[link:([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, rawUrl, customLabel) => {
|
|---|
| 520 | const url = rawUrl.trim();
|
|---|
| 521 | if (!/^https?:\/\//i.test(url)) return match;
|
|---|
| 522 | const platform = this.detectLinkPlatform(url) || 'other';
|
|---|
| 523 | const label = (customLabel || '').trim();
|
|---|
| 524 | const platformLabel = this.platformLabel(platform);
|
|---|
| 525 | const buttonText = label || `Open in ${platformLabel}`;
|
|---|
| 526 | const urlH = this.escape(url);
|
|---|
| 527 | const textH = this.escape(buttonText);
|
|---|
| 528 | return `<a class="post-audio-external post-audio-external--${platform}" href="${urlH}" target="_blank" rel="noopener noreferrer" data-platform="${platform}">
|
|---|
| 529 | <span class="pae-icon" aria-hidden="true">▶</span>
|
|---|
| 530 | <span class="pae-text">${textH}</span>
|
|---|
| 531 | <span class="pae-arrow" aria-hidden="true">↗</span>
|
|---|
| 532 | </a>`;
|
|---|
| 533 | });
|
|---|
| 534 | }
|
|---|
| 535 |
|
|---|
| 536 | static escape(str) {
|
|---|
| 537 | return str
|
|---|
| 538 | .replace(/&/g, '&')
|
|---|
| 539 | .replace(/</g, '<')
|
|---|
| 540 | .replace(/>/g, '>')
|
|---|
| 541 | .replace(/"/g, '"')
|
|---|
| 542 | .replace(/'/g, ''');
|
|---|
| 543 | }
|
|---|
| 544 | }
|
|---|
| 545 |
|
|---|
| 546 | export default AudioEmbedService; |
|---|