source: Klonkt/src/services/AudioEmbedService.js@ e854ace

main
Last change on this file since e854ace was e854ace, checked in by Bart <bart@…>, 3 weeks ago

Een Apple Music-afspeellijst is geen album: het id is geen getal

Barts melding (17-8): het concept "The Mixtape" op boiert.eu toonde in preview
letterlijk [[embed:https://music.apple.com/nl/playlist/romance/pl.u-...]].

Twee fouten achter elkaar. detectProvider herkent Apple Music aan /playlist/ en
kijkt niet verder, maar applemusicIframe eiste [0-9]+ als id. Dat klopt voor een
album of een nummer; een afspeellijst heet pl.u-LdbqzVvI3go5g. Dus null, en
embedMediaShortcodes viel terug op de shortcode zelf.

Die terugval is de tweede fout, en hij maakte de eerste onbegrijpelijk. Een
lezer ziet geen "dit kan ik niet", hij ziet rommel op de pagina. Onherkend gaf
al netjes een melding; herkend-maar-niet-te-bouwen geeft nu dezelfde, want voor
de lezer is het hetzelfde geval.

De vangst blijft krap -- geen slash, vraagteken of hekje -- want hij gaat
rechtstreeks achter embed.music.apple.com/ aan. Een test loopt drie
ontsnappingspogingen langs.

Co-Authored-By: Claude Opus 5 <claude@…>

  • Property mode set to 100644
File size: 33.4 KB
Line 
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// "Open in" icons (brand-colored via CSS .pat-link--).
14const OPEN_IN_SVG = {
15 spotify: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm4.6 14.42a.62.62 0 01-.86.21c-2.35-1.44-5.3-1.76-8.79-.96a.62.62 0 11-.28-1.21c3.8-.87 7.07-.5 9.71 1.11.3.18.39.57.22.85zm1.23-2.73a.78.78 0 01-1.07.26c-2.69-1.66-6.79-2.14-9.97-1.17a.78.78 0 11-.45-1.49c3.63-1.1 8.15-.56 11.24 1.33.36.22.48.7.25 1.07zm.1-2.85C14.66 8.95 9.4 8.78 6.3 9.72a.93.93 0 11-.54-1.79c3.56-1.08 9.37-.87 13.07 1.33a.94.94 0 01-.96 1.61z"/></svg>',
16 youtube: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 7.1a3 3 0 00-2.1-2.12C19.04 4.5 12 4.5 12 4.5s-7.04 0-8.9.48A3 3 0 001 7.1 31.2 31.2 0 00.5 12 31.2 31.2 0 001 16.9a3 3 0 002.1 2.12c1.86.48 8.9.48 8.9.48s7.04 0 8.9-.48A3 3 0 0023 16.9 31.2 31.2 0 0023.5 12 31.2 31.2 0 0023 7.1zM9.75 15.5v-7l6 3.5-6 3.5z"/></svg>',
17 soundcloud: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 14v4M7.5 11v7M11 9v9"/><path d="M14.5 9.5V18h4a3 3 0 100-6 4 4 0 00-4-2.5z"/></svg>',
18};
19
20class AudioEmbedService {
21 // Small "open in" links for a track (Spotify/YouTube/SoundCloud). The hrefs
22 // are already validated server-side (https + correct host only). Returns ''
23 // when no links exist. Placed next to the play button (outside the button →
24 // no conflict with playback).
25 static openInLinks(t) {
26 if (!t) return '';
27 const out = [];
28 const add = (url, key, label) => {
29 if (!url) return;
30 out.push(`<a class="pat-link pat-link--${key}" href="${this.escape(url)}" target="_blank" rel="noopener noreferrer" title="Open in ${label}" aria-label="Open in ${label}">${OPEN_IN_SVG[key]}</a>`);
31 };
32 add(t.link_spotify, 'spotify', 'Spotify');
33 add(t.link_youtube, 'youtube', 'YouTube');
34 add(t.link_soundcloud, 'soundcloud', 'SoundCloud');
35 return out.length ? `<span class="pat-links">${out.join('')}</span>` : '';
36 }
37
38 static detectProvider(url) {
39 if (!url || typeof url !== 'string') return null;
40 url = url.trim();
41
42 // Only embed http(s) URLs. The provider regexes below are NOT anchored,
43 // so without this check e.g. `javascript:alert(1)//youtu.be/x` would match
44 // and land as an embed URL (stored XSS via an [[embed:...]] shortcode —
45 // that text never passes through the HTML sanitizer because it lives in a
46 // text node). The scheme guard excludes javascript:/data:/vbscript: etc.
47 if (!/^https?:\/\//i.test(url)) return null;
48
49 // Spotify
50 if (/open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i.test(url)) {
51 const match = url.match(/\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i);
52 return { provider: 'spotify', type: match[1], id: match[2], url };
53 }
54
55 // Bandcamp
56 if (/bandcamp\.com\/(track|album)/i.test(url)) {
57 return { provider: 'bandcamp', url };
58 }
59
60 // SoundCloud
61 if (/soundcloud\.com/i.test(url)) {
62 return { provider: 'soundcloud', url };
63 }
64
65 // Apple Music
66 if (/music\.apple\.com\/([a-z]{2})\/(?:album|playlist|song)\//i.test(url)) {
67 return { provider: 'applemusic', url };
68 }
69
70 // YouTube — a video id is always exactly 11 characters (aligns with the
71 // client-side ytId() in embed-player.js, which also expects {11}).
72 //
73 // A link may carry a video, a playlist, or both, and until now we kept only
74 // the video and threw `list=` away -- so a link to an album played its first
75 // song and stopped. The ref now keeps whichever is there, in the same three
76 // shapes the Klonkt hub uses, so one ref travels between the two unchanged:
77 //
78 // "<video>" one video
79 // "<video>?list=<L>" that video, and on through the list
80 // "list:<L>" the whole playlist (YouTube's `videoseries`)
81 //
82 // `list` may sit before or after `v=` and is often entity-encoded (&amp;)
83 // in a baked href, hence the scan over the whole URL rather than a fixed
84 // order. A list id is 10-60 chars: longer and looser than a video id.
85 if (/(?:youtube(?:-nocookie)?\.com\/(?:watch\?|playlist\?|embed\/|shorts\/|live\/)|youtu\.be\/)/i.test(url)) {
86 const vm = url.match(/(?:[?&](?:amp;)?v=|youtu\.be\/|\/embed\/|\/shorts\/|\/live\/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])/i);
87 const lm = url.match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/i);
88 // `videoseries` is a marker, not a video: a bare playlist embed URL reads
89 // /embed/videoseries?list=..., and taking that for an id gives a dead
90 // frame. It is EXACTLY eleven characters, so no length rule catches it --
91 // it has to be named. (Measured, not assumed: it slipped through a
92 // boundary check that looked like it covered this.)
93 const id = vm && vm[1] !== 'videoseries' ? vm[1] : null;
94 const list = lm ? lm[1] : null;
95 if (id || list) {
96 const ref = id ? (list ? `${id}?list=${list}` : id) : `list:${list}`;
97 // `id` stays exactly what it was for every caller that only wants a
98 // video; `list` and `ref` are additions.
99 return { provider: 'youtube', id, list, ref, url };
100 }
101 }
102
103 // Vimeo
104 if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) {
105 const match = url.match(/\d+/);
106 return { provider: 'vimeo', id: match[0], url };
107 }
108
109 return null;
110 }
111
112 // Direct media files (video/audio) hosted anywhere → a native <video>/<audio>
113 // player. Kept OUT of detectProvider() on purpose: the timeline/cover callers
114 // switch on provider slugs (youtube/spotify/…) and a bare file has none, so
115 // overloading detectProvider would suppress e.g. a PeerTube fallback. Only
116 // autoembed() and [[embed:…]] use this.
117 static MEDIA_FILE_EXT = {
118 video: ['mp4', 'webm', 'm4v', 'mov', 'ogv'],
119 audio: ['mp3', 'ogg', 'oga', 'wav', 'm4a', 'flac', 'opus', 'aac'],
120 };
121
122 static detectMediaFile(url) {
123 if (!url || typeof url !== 'string') return null;
124 if (!/^https?:\/\//i.test(url)) return null;
125 let pathname;
126 try { pathname = new URL(url).pathname.toLowerCase(); } catch { return null; }
127 const ext = (pathname.match(/\.([a-z0-9]+)$/) || [])[1];
128 if (!ext) return null;
129 if (this.MEDIA_FILE_EXT.video.includes(ext)) return { kind: 'video', url };
130 if (this.MEDIA_FILE_EXT.audio.includes(ext)) return { kind: 'audio', url };
131 return null;
132 }
133
134 static mediaFileEmbed(url) {
135 const m = this.detectMediaFile(url);
136 if (!m) return null;
137 const src = this.escape(m.url);
138 if (m.kind === 'video') {
139 return `<figure class="folio-embed folio-embed--video"><video src="${src}" controls preload="metadata" playsinline></video></figure>`;
140 }
141 return `<figure class="folio-embed folio-embed--audio"><audio src="${src}" controls preload="metadata"></audio></figure>`;
142 }
143
144 static generateIframe(provider, config) {
145 switch (provider) {
146 // Custom players (client-side via embed-player.js + the real platform APIs).
147 // We render a placeholder with data attributes instead of the bare platform
148 // iframe, so the embed appears in OUR brand style.
149 case 'youtube':
150 // The ref carries the list when there is one; `id` alone would drop it
151 // and play a single song out of an album.
152 return this.embedPlaceholder('youtube', config.ref || config.id, 'video',
153 config.url || (config.id ? `https://youtu.be/${config.id}`
154 : `https://www.youtube.com/playlist?list=${config.list}`));
155 case 'soundcloud':
156 return this.embedPlaceholder('soundcloud', config.url, 'track', config.url);
157 case 'spotify':
158 return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
159 config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
160 // No JS API (Bandcamp/Apple) or low priority (Vimeo): remain as iframes;
161 // mutual exclusion for these runs via the blur fallback.
162 case 'bandcamp':
163 return this.bandcampIframe(config);
164 case 'applemusic':
165 return this.applemusicIframe(config);
166 case 'vimeo':
167 return this.vimeoIframe(config);
168 default:
169 return null;
170 }
171 }
172
173 /**
174 * Placeholder for a custom player. embed-player.js picks up
175 * .folio-embed[data-embed-provider] and builds the card + player client-side.
176 * ALL values go through escape() — post.content_html is executed unescaped.
177 */
178 static embedPlaceholder(provider, ref, type, url) {
179 const attrs = [
180 `data-embed-provider="${this.escape(provider)}"`,
181 `data-embed-ref="${this.escape(ref)}"`,
182 type ? `data-embed-type="${this.escape(type)}"` : '',
183 `data-embed-url="${this.escape(url)}"`,
184 ].filter(Boolean).join(' ');
185 return `<div class="folio-embed folio-embed--${this.escape(provider)} pcms-embed pcms-embed-card pcms-embed-loading" ${attrs}></div>`;
186 }
187
188 static spotifyIframe({ type, id }) {
189 const src = `https://open.spotify.com/embed/${type}/${id}`;
190 return `
191 <figure class="folio-embed folio-embed--spotify">
192 <iframe src="${this.escape(src)}"
193 style="width:100%;height:152px;border:0;"
194 loading="lazy"
195 allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
196 title="Spotify ${type}"></iframe>
197 </figure>
198 `.trim();
199 }
200
201 static bandcampIframe({ url }) {
202 const encodedUrl = encodeURIComponent(url);
203 const src = `https://bandcamp.com/EmbeddedPlayer/url=${encodedUrl}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/`;
204 return `
205 <figure class="folio-embed folio-embed--bandcamp">
206 <iframe src="${this.escape(src)}"
207 style="width:100%;height:470px;border:0;"
208 loading="lazy"
209 allow="encrypted-media"
210 title="Bandcamp player"></iframe>
211 </figure>
212 `.trim();
213 }
214
215 static soundcloudIframe({ url }) {
216 const params = {
217 url: url,
218 color: '#ff5500',
219 auto_play: 'false',
220 hide_related: 'true',
221 show_comments: 'false',
222 show_user: 'true',
223 show_reposts: 'false',
224 show_teaser: 'false',
225 visual: 'true'
226 };
227 const query = new URLSearchParams(params).toString();
228 const src = `https://w.soundcloud.com/player/?${query}`;
229 return `
230 <figure class="folio-embed folio-embed--soundcloud">
231 <iframe src="${this.escape(src)}"
232 style="width:100%;height:300px;border:0;"
233 loading="lazy"
234 allow="autoplay; clipboard-write; encrypted-media"
235 title="SoundCloud player"></iframe>
236 </figure>
237 `.trim();
238 }
239
240 static applemusicIframe({ url }) {
241 // Een album of nummer heeft een NUMMER als id, een afspeellijst niet: die
242 // heet `pl.u-LdbqzVvI3go5g`. Met alleen [0-9]+ viel elke playlist hier af
243 // en gaf deze functie null -- waarna de shortcode zelf op de pagina kwam.
244 // Barts melding (17-8): het concept "The Mixtape" toonde in preview
245 // letterlijk [[embed:https://music.apple.com/nl/playlist/...]].
246 //
247 // Bewust krap: geen slash, vraagteken of hekje in het id, want wat hier
248 // gevangen wordt gaat rechtstreeks achter https://embed.music.apple.com/ aan.
249 const match = url.match(
250 /music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/(?:[0-9]+|pl\.[A-Za-z0-9_-]+))/i,
251 );
252 if (!match) return null;
253 const src = `https://embed.music.apple.com/${match[1]}`;
254 return `
255 <figure class="folio-embed folio-embed--applemusic">
256 <iframe src="${this.escape(src)}"
257 style="width:100%;height:175px;border:0;overflow:hidden;border-radius:8px;"
258 loading="lazy"
259 allow="autoplay; clipboard-write; encrypted-media"
260 title="Apple Music"></iframe>
261 </figure>
262 `.trim();
263 }
264
265 /**
266 * The plain provider iframe. Takes the same ref shapes as the placeholder:
267 * "<video>", "<video>?list=<L>" and "list:<L>" -- a bare playlist embeds as
268 * `videoseries`. Kept in step with the placeholder path on purpose: this is
269 * the fallback, and a fallback that silently drops the playlist is the worst
270 * kind, because it looks like it worked.
271 */
272 static youtubeIframe({ id, ref }) {
273 const r = ref || id || '';
274 const base = 'https://www.youtube-nocookie.com/embed/';
275 let src;
276 if (r.startsWith('list:')) {
277 src = `${base}videoseries?list=${encodeURIComponent(r.slice(5))}`;
278 } else if (r.includes('?list=')) {
279 const [v, l] = r.split('?list=');
280 src = `${base}${encodeURIComponent(v)}?list=${encodeURIComponent(l)}`;
281 } else {
282 src = base + encodeURIComponent(r);
283 }
284 return `
285 <figure class="folio-embed folio-embed--youtube">
286 <iframe src="${this.escape(src)}"
287 style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
288 loading="lazy"
289 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
290 allowfullscreen
291 title="YouTube video"></iframe>
292 </figure>
293 `.trim();
294 }
295
296 static vimeoIframe({ id }) {
297 const src = `https://player.vimeo.com/video/${id}`;
298 return `
299 <figure class="folio-embed folio-embed--vimeo">
300 <iframe src="${this.escape(src)}"
301 style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
302 loading="lazy"
303 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
304 allowfullscreen
305 title="Vimeo video"></iframe>
306 </figure>
307 `.trim();
308 }
309
310 /**
311 * Auto-embed: Scan paragraphs containing only a URL.
312 * Handles two markdown-rendered shapes:
313 * <p>https://url</p> (bare URL — when GFM auto-link is off)
314 * <p><a href="https://url">https://url</a></p> (marked GFM auto-link — what we get)
315 * Either way → <figure class="folio-embed">...
316 */
317 static autoembed(html) {
318 if (!html) return html;
319 return html.replace(
320 /<p>\s*(?:<a\b[^>]*?\shref="([^"]+)"[^>]*>[^<]*<\/a>|(https?:\/\/[^\s<>"']+))\s*<\/p>/gi,
321 (match, hrefUrl, bareUrl) => {
322 const url = hrefUrl || bareUrl;
323 const detected = this.detectProvider(url);
324 if (detected) {
325 const iframe = this.generateIframe(detected.provider, detected);
326 return iframe || match;
327 }
328 // Bare media file (…/clip.webm, …/song.mp3) → native player.
329 const media = this.mediaFileEmbed(url);
330 if (media) return media;
331 return match;
332 }
333 );
334 }
335
336 /**
337 * Replace [[embed:<url>]] shortcodes with the platform iframe (YouTube, Spotify,
338 * SoundCloud, Apple Music, Bandcamp, Vimeo). The editor button inserts this
339 * shortcode; bare URL lines also embed automatically via autoembed().
340 * Unsupported/invalid URLs get a clean inline notice.
341 */
342 static embedMediaShortcodes(html) {
343 if (!html) return html;
344 return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawUrl) => {
345 const url = rawUrl.trim().replace(/&amp;/g, '&');
346 const detected = this.detectProvider(url);
347 if (!detected) {
348 // Bare media file (…/clip.webm, …/song.mp3) → native player.
349 const media = this.mediaFileEmbed(url);
350 if (media) return media;
351 return `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
352 }
353 // HERKEND maar niet te bouwen is geen reden om de shortcode zelf te
354 // tonen. Dat deed het wel, en dan leest een bezoeker "[[embed:https://...]]"
355 // op de pagina en denkt hij dat er iets stuk is. Onherkend gaf hierboven
356 // al een nette melding; herkend-maar-mislukt hoort dezelfde te geven,
357 // want voor de lezer is het hetzelfde geval.
358 return this.generateIframe(detected.provider, detected)
359 || `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
360 });
361 }
362
363 /**
364 * Replace [[track:<id>]] shortcodes with v9-style player markup.
365 * Caller passes a lookup function (id) -> { id, title, artist, url, cover }
366 * where url is already a signed /audio/stream/... URL. Unknown ids → left as-is.
367 */
368 static embedTrackShortcodes(html, trackLookup) {
369 if (!html || typeof trackLookup !== 'function') return html;
370 return html.replace(/\[\[track:([A-Za-z0-9_-]+)\]\]/g, (match, id) => {
371 const t = trackLookup(id);
372 if (!t) return match;
373 const titleH0 = this.escape(t.title || 'Untitled');
374 const artistH0 = this.escape(t.artist || '');
375 const creditBits0 = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
376 // Link-only track (no audio file): no play button, but info + open-in links.
377 if (!t.url) {
378 const coverH0 = this.escape(t.cover || '');
379 const leader0 = coverH0
380 ? `<span class="pat-noplay pat-noplay--cover" style="background-image:url('${coverH0}')" aria-hidden="true"></span>`
381 : `<span class="pat-noplay" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>`;
382 return `<div class="post-audio-track post-audio-track--static" id="track-${id}">
383 ${leader0}
384 <div class="pat-info">
385 <div class="pat-title">${titleH0}</div>
386 ${artistH0 ? `<div class="pat-artist">${artistH0}</div>` : ''}
387 ${creditBits0 ? `<div class="pat-credit">${creditBits0}</div>` : ''}
388 </div>
389 ${this.openInLinks(t)}
390</div>`;
391 }
392 const trackJson = JSON.stringify({
393 id,
394 url: t.url,
395 title: t.title || 'Untitled',
396 artist: t.artist || '',
397 cover: t.cover || '',
398 credit: t.credit || '',
399 license: t.license || '',
400 });
401 const titleH = this.escape(t.title || 'Untitled');
402 const artistH = this.escape(t.artist || '');
403 const urlH = this.escape(t.url);
404 // Visible owner/license line below the track.
405 const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
406 const dataAttr = trackJson
407 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
408 // id="track-<id>" = anchor so the mini-player can scroll to this element.
409 return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
410 <button type="button" class="pat-play" aria-label="Play ${titleH}">
411 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
412 </button>
413 <div class="pat-info">
414 <div class="pat-title">${titleH}</div>
415 ${artistH ? `<div class="pat-artist">${artistH}</div>` : ''}
416 ${creditBits ? `<div class="pat-credit">${creditBits}</div>` : ''}
417 </div>
418 ${this.openInLinks(t)}
419</div>`;
420 });
421 }
422
423 /**
424 * Replace [[album:<name>]] shortcodes with a v9-style album block.
425 * Caller passes a lookup function (name) -> { title, artist, cover, tracks: [{url,title,artist,cover}, ...] }
426 * Tracks must already have signed URLs. Unknown albums → left as-is.
427 * The wrapper carries the full album JSON so audio-player.js can queue it
428 * when any track or the album play button is clicked.
429 */
430 static embedAlbumShortcodes(html, albumLookup) {
431 if (!html || typeof albumLookup !== 'function') return html;
432 return html.replace(/\[\[album:([^\]]+)\]\]/g, (match, rawName) => {
433 const name = rawName.trim();
434 const album = albumLookup(name);
435 if (!album || !album.tracks || !album.tracks.length) return match;
436
437 // Stable DOM id for this rendering — used as data-pcms-album-id on tracks
438 const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10);
439 // Only playable tracks (with url) in the queue; link-only tracks appear
440 // in the list but not in the playback JSON.
441 const albumJson = JSON.stringify(album.tracks.filter((t) => t.url))
442 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
443 const titleH = this.escape(album.title || name);
444 const artistH = this.escape(album.artist || '');
445 const coverH = album.cover ? this.escape(album.cover) : '';
446
447 const trackItems = album.tracks.map((t, i) => {
448 const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
449 const tArtist = this.escape(t.artist || '');
450 // Link-only track: no play button, but track number + info + open-in links.
451 if (!t.url) {
452 return ` <li class="post-audio-track post-audio-track--static"${t.id ? ` id="track-${t.id}"` : ''}>
453 <span class="pat-track-num">${i + 1}.</span>
454 <div class="pat-info">
455 <div class="pat-title">${tTitle}</div>
456 ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
457 </div>
458 ${this.openInLinks(t)}
459 </li>`;
460 }
461 const tUrl = this.escape(t.url);
462 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}">
463 <button type="button" class="pat-play" aria-label="Play ${tTitle}">
464 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
465 </button>
466 <div class="pat-info">
467 <span class="pat-track-num">${i + 1}.</span>
468 <div class="pat-title">${tTitle}</div>
469 ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
470 </div>
471 ${this.openInLinks(t)}
472 </li>`;
473 }).join('\n');
474
475 return `<div class="post-album" id="${albumDomId}" data-pcms-album='${albumJson}' data-pcms-album-title="${titleH}">
476 <div class="post-album-header">
477 <button type="button" class="post-album-cover-btn" data-pcms-album-id="${albumDomId}" aria-label="Play album ${titleH}">
478 ${coverH
479 ? `<img src="${coverH}" alt="" class="post-album-cover-img">`
480 : `<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>`}
481 <span class="post-album-play-overlay" aria-hidden="true">
482 <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 4l12 8-12 8z"/></svg>
483 </span>
484 </button>
485 <div class="post-album-info">
486 <h3 class="post-album-title">${titleH}</h3>
487 ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
488 <p class="post-album-count">${album.tracks.length} track${album.tracks.length === 1 ? '' : 's'}</p>
489 </div>
490 </div>
491 <ol class="post-album-tracks">
492${trackItems}
493 </ol>
494</div>`;
495 });
496 }
497
498 /**
499 * Replace [[playlist:<id>]] shortcodes with a v9-style album block.
500 * Caller passes a lookup function (id) -> hydrated playlist object from
501 * PlaylistService.get(), or null. Unknown playlists render an inline
502 * "niet gevonden" placeholder so the post still validates as HTML.
503 *
504 * Shape returned by lookup:
505 * { id, title, artist, year, cover, kind, tracks: [{url,title,artist,cover,duration}, ...] }
506 *
507 * `kind` is honored:
508 * - 'album' → ordered list with track numbers
509 * - 'playlist' → list with per-track cover thumbnails (mixtape feel)
510 *
511 * opts: { isAdmin: boolean } — when true, an edit/delete action overlay
512 * is rendered top-right of each card. The handlers are wired up in
513 * audio-player.js via event delegation on data-pcms-playlist-delete.
514 */
515 static embedPlaylistShortcodes(html, playlistLookup, opts = {}) {
516 if (!html || typeof playlistLookup !== 'function') return html;
517 const isAdmin = !!opts.isAdmin;
518 return html.replace(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi, (match, rawId) => {
519 const id = rawId.toLowerCase();
520 const pl = playlistLookup(id);
521
522 if (!pl) {
523 return `<div class="post-playlist-missing"><em>Playlist "${this.escape(id)}" niet gevonden.</em></div>`;
524 }
525 if (!pl.tracks || !pl.tracks.length) {
526 return `<div class="post-playlist-empty"><em>Playlist "${this.escape(pl.title)}" heeft geen beschikbare tracks.</em></div>`;
527 }
528
529 const albumDomId = 'album-' + id;
530 const kind = (pl.kind === 'playlist') ? 'playlist' : 'album';
531 const kindLabel = kind === 'playlist' ? '📃 Playlist' : '💿 Album';
532 const titleH = this.escape(pl.title || 'Naamloos');
533 const artistH = this.escape(pl.artist || '');
534 const coverH = pl.cover ? this.escape(pl.cover) : '';
535
536 // Audio-player.js reads data-pcms-album for queue. Same shape as
537 // embedAlbumShortcodes — keep both in sync.
538 // Only playable tracks in the queue; link-only tracks appear in the list
539 // but not in the playback JSON.
540 const tracksData = pl.tracks.filter(t => t.url).map(t => ({
541 id: t.id,
542 url: t.url,
543 title: t.title,
544 artist: t.artist || pl.artist || '',
545 cover: t.cover || pl.cover || '',
546 }));
547 const albumJson = JSON.stringify(tracksData)
548 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
549
550 // Total duration for the meta line
551 const totalSec = pl.tracks.reduce((s, t) => s + (t.duration || 0), 0);
552 const metaParts = [];
553 if (pl.year) metaParts.push(String(pl.year));
554 metaParts.push(pl.tracks.length + (pl.tracks.length === 1 ? ' track' : ' tracks'));
555 if (totalSec > 0) {
556 const h = Math.floor(totalSec / 3600);
557 const m = Math.floor((totalSec % 3600) / 60);
558 if (h > 0) metaParts.push(`${h}h ${m}m`);
559 else metaParts.push(`${Math.max(1, m)} min`);
560 }
561 const metaLine = this.escape(metaParts.join(' · '));
562 const firstUrl = this.escape((pl.tracks.find(t => t.url) || {}).url || '');
563
564 // Track items — playlist-kind shows per-track cover thumbs, album-kind shows numbers
565 const trackItems = pl.tracks.map((t, i) => {
566 const tTitleH = this.escape(t.title || ('Track ' + (i + 1)));
567 const tArtistH = this.escape(t.artist || '');
568 const tUrl = this.escape(t.url);
569 const showArtist = tArtistH && tArtistH !== artistH;
570
571 // Duration cell — render even when 0 for consistent column layout
572 const durHtml = t.duration > 0
573 ? `<span class="pat-duration">${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}</span>`
574 : `<span class="pat-duration pat-duration-empty">—:—</span>`;
575
576 // Leader cell — number for albums, cover thumb for playlists
577 const leader = (kind === 'playlist' && t.cover)
578 ? `<span class="pat-cover" style="background-image:url(${this.escape(t.cover)})" aria-hidden="true"></span>`
579 : `<span class="pat-num">${i + 1}</span>`;
580
581 // Link-only track: no clickable play row (static div), but open-in links.
582 if (!t.url) {
583 return ` <li class="post-album-track-compact post-album-track-compact--static"${t.id ? ` id="track-${t.id}"` : ''}>
584 <div class="pat-row pat-static">
585 ${leader}
586 <span class="pat-meta">
587 <span class="pat-title">${tTitleH}</span>
588 ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
589 </span>
590 ${durHtml}
591 </div>
592 ${this.openInLinks(t)}
593 </li>`;
594 }
595 const trackBase = String(t.url).split('?')[0];
596 return ` <li class="post-album-track-compact"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''}>
597 <button type="button" class="pat-row"
598 data-pcms-track-url="${tUrl}"
599 data-pcms-album-id="${albumDomId}"
600 data-pcms-track-base="${this.escape(trackBase)}"
601 aria-label="Speel ${tTitleH}">
602 ${leader}
603 <span class="pat-meta">
604 <span class="pat-title">${tTitleH}</span>
605 ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
606 </span>
607 ${durHtml}
608 </button>
609 ${this.openInLinks(t)}
610 </li>`;
611 }).join('\n');
612
613 return `<div class="post-album" id="${albumDomId}"
614 data-pcms-album='${albumJson}'
615 data-pcms-album-title="${titleH}"
616 data-pcms-album-kind="${kind}"
617 data-pcms-playlist-id="${this.escape(id)}">
618${isAdmin ? ` <div class="post-album-actions" role="group" aria-label="Playlist beheren">
619 <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk playlist" aria-label="Bewerk playlist">
620 <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>
621 </a>
622 <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">
623 <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>
624 </button>
625 </div>
626` : ''} <div class="post-album-header">
627 <button type="button" class="post-album-cover-btn"
628 data-pcms-track-url="${firstUrl}"
629 data-pcms-album-id="${albumDomId}"
630 aria-label="Speel ${kind === 'playlist' ? 'playlist' : 'album'}">
631 ${coverH
632 ? `<span class="post-album-cover" style="background-image:url('${coverH}')"></span>`
633 : `<span class="post-album-cover post-album-cover-empty">
634 <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>
635 </span>`}
636 <span class="post-album-cover-play" aria-hidden="true">
637 <svg viewBox="0 0 24 24"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>
638 </span>
639 </button>
640 <div class="post-album-info">
641 <p class="post-album-label">${kindLabel}</p>
642 <h3 class="post-album-title">${titleH}</h3>
643 ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
644 <p class="post-album-meta">${metaLine}</p>
645 </div>
646 </div>
647 <ol class="post-album-tracks post-album-tracks-compact" data-album-kind="${kind}">
648${trackItems}
649 </ol>
650</div>`;
651 });
652 }
653
654 /**
655 * Human-readable label for a provider slug. Used by external-link buttons.
656 */
657 static platformLabel(provider) {
658 return ({
659 spotify: 'Spotify',
660 bandcamp: 'Bandcamp',
661 soundcloud: 'SoundCloud',
662 applemusic: 'Apple Music',
663 youtube: 'YouTube',
664 vimeo: 'Vimeo',
665 tidal: 'Tidal',
666 deezer: 'Deezer',
667 mixcloud: 'Mixcloud',
668 })[provider] || 'External link';
669 }
670
671 /**
672 * Detect platform from URL purely by hostname (covers more services than
673 * detectProvider, which is embed-focused). Used for [[link:url]] rendering.
674 */
675 static detectLinkPlatform(url) {
676 try {
677 const host = new URL(url).hostname.toLowerCase();
678 if (host.includes('open.spotify.com') || host === 'spotify.com') return 'spotify';
679 if (host.includes('bandcamp.com')) return 'bandcamp';
680 if (host.includes('soundcloud.com')) return 'soundcloud';
681 if (host.includes('music.apple.com') || host.includes('itunes.apple.com')) return 'applemusic';
682 if (host.includes('youtube.com') || host.includes('youtu.be') || host.includes('music.youtube.com')) return 'youtube';
683 if (host.includes('vimeo.com')) return 'vimeo';
684 if (host.includes('tidal.com')) return 'tidal';
685 if (host.includes('deezer.com')) return 'deezer';
686 if (host.includes('mixcloud.com')) return 'mixcloud';
687 return 'other';
688 } catch (e) {
689 return null;
690 }
691 }
692
693 /**
694 * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
695 * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
696 * Per Robin's v9: "External link, click = open platform (target _blank)".
697 */
698 static embedExternalLinkShortcodes(html) {
699 if (!html) return html;
700 return html.replace(/\[\[link:([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, rawUrl, customLabel) => {
701 const url = rawUrl.trim();
702 if (!/^https?:\/\//i.test(url)) return match;
703 const platform = this.detectLinkPlatform(url) || 'other';
704 const label = (customLabel || '').trim();
705 const platformLabel = this.platformLabel(platform);
706 const buttonText = label || `Open in ${platformLabel}`;
707 const urlH = this.escape(url);
708 const textH = this.escape(buttonText);
709 return `<a class="post-audio-external post-audio-external--${platform}" href="${urlH}" target="_blank" rel="noopener noreferrer" data-platform="${platform}">
710 <span class="pae-icon" aria-hidden="true">▶</span>
711 <span class="pae-text">${textH}</span>
712 <span class="pae-arrow" aria-hidden="true">↗</span>
713</a>`;
714 });
715 }
716
717 static escape(str) {
718 return str
719 .replace(/&/g, '&amp;')
720 .replace(/</g, '&lt;')
721 .replace(/>/g, '&gt;')
722 .replace(/"/g, '&quot;')
723 .replace(/'/g, '&#39;');
724 }
725}
726
727export default AudioEmbedService;
Note: See TracBrowser for help on using the repository browser.