source: Klonkt/src/services/AudioEmbedService.js@ 48481cd

main
Last change on this file since 48481cd was 48481cd, checked in by Robin <roboburr@…>, 3 weeks ago

Apple Music: de hoogte hoort bij wat je insluit

Barts melding (20-8) over boiert.eu/the-mixtape: de afspeellijst daar stond
in een venster van 175px. Dat is de maat van een LOS NUMMER, dus je zag er
ongeveer een derde van -- en met overflow:hidden eroverheen viel de rest
ook niet te bereiken.

De hoogte stond vast voor alle drie de soorten. Nu volgt hij het pad:

song 175px
album, playlist 450px

De regex ving (album|playlist|song) al, maar gooide die groep weg; nu is
het een echte capture en beslist het woord in het pad. Geraden wordt er niet.

450 is niet uit de documentatie overgenomen maar nagemeten: de embed-pagina
van pl.u-LdbqzVvI3go5g geeft zijn <main> EN zijn <body> allebei precies
450px. Dat is ook de hoogte die Apple's eigen insluitcode gebruikt.

Vier tests erbij; drie ervan vallen om als je de hoogte weer vastzet.

Twee dingen die ik zag maar heb laten staan, want ze horen niet bij deze
melding: overflow:hidden maakt afgesneden inhoud onbereikbaar in plaats
van scrollbaar, en Apple's eigen code zet er ook een sandbox-attribuut op
dat wij niet hebben.

Suite 1169/1169.

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

  • Property mode set to 100644
File size: 34.0 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 // De hoogte hangt af van WAT je insluit, en dat stond hier op een vaste
255 // 175px -- de maat van een LOS NUMMER. Een album of afspeellijst is 450px,
256 // dus daarvan zag je ongeveer een derde, met `overflow:hidden` eroverheen
257 // zodat de rest ook niet te bereiken viel. Barts melding (20-8) over
258 // boiert.eu/the-mixtape.
259 //
260 // Nagemeten en niet overgenomen: de embed-pagina van die lijst
261 // (pl.u-LdbqzVvI3go5g) geeft zijn <main> EN zijn <body> allebei precies
262 // 450px. Dat is ook de hoogte in Apple's eigen insluitcode.
263 const hoogte = String(match[2]).toLowerCase() === 'song' ? 175 : 450;
264 return `
265 <figure class="folio-embed folio-embed--applemusic">
266 <iframe src="${this.escape(src)}"
267 style="width:100%;height:${hoogte}px;border:0;overflow:hidden;border-radius:8px;"
268 loading="lazy"
269 allow="autoplay; clipboard-write; encrypted-media"
270 title="Apple Music"></iframe>
271 </figure>
272 `.trim();
273 }
274
275 /**
276 * The plain provider iframe. Takes the same ref shapes as the placeholder:
277 * "<video>", "<video>?list=<L>" and "list:<L>" -- a bare playlist embeds as
278 * `videoseries`. Kept in step with the placeholder path on purpose: this is
279 * the fallback, and a fallback that silently drops the playlist is the worst
280 * kind, because it looks like it worked.
281 */
282 static youtubeIframe({ id, ref }) {
283 const r = ref || id || '';
284 const base = 'https://www.youtube-nocookie.com/embed/';
285 let src;
286 if (r.startsWith('list:')) {
287 src = `${base}videoseries?list=${encodeURIComponent(r.slice(5))}`;
288 } else if (r.includes('?list=')) {
289 const [v, l] = r.split('?list=');
290 src = `${base}${encodeURIComponent(v)}?list=${encodeURIComponent(l)}`;
291 } else {
292 src = base + encodeURIComponent(r);
293 }
294 return `
295 <figure class="folio-embed folio-embed--youtube">
296 <iframe src="${this.escape(src)}"
297 style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
298 loading="lazy"
299 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
300 allowfullscreen
301 title="YouTube video"></iframe>
302 </figure>
303 `.trim();
304 }
305
306 static vimeoIframe({ id }) {
307 const src = `https://player.vimeo.com/video/${id}`;
308 return `
309 <figure class="folio-embed folio-embed--vimeo">
310 <iframe src="${this.escape(src)}"
311 style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
312 loading="lazy"
313 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
314 allowfullscreen
315 title="Vimeo video"></iframe>
316 </figure>
317 `.trim();
318 }
319
320 /**
321 * Auto-embed: Scan paragraphs containing only a URL.
322 * Handles two markdown-rendered shapes:
323 * <p>https://url</p> (bare URL — when GFM auto-link is off)
324 * <p><a href="https://url">https://url</a></p> (marked GFM auto-link — what we get)
325 * Either way → <figure class="folio-embed">...
326 */
327 static autoembed(html) {
328 if (!html) return html;
329 return html.replace(
330 /<p>\s*(?:<a\b[^>]*?\shref="([^"]+)"[^>]*>[^<]*<\/a>|(https?:\/\/[^\s<>"']+))\s*<\/p>/gi,
331 (match, hrefUrl, bareUrl) => {
332 const url = hrefUrl || bareUrl;
333 const detected = this.detectProvider(url);
334 if (detected) {
335 const iframe = this.generateIframe(detected.provider, detected);
336 return iframe || match;
337 }
338 // Bare media file (…/clip.webm, …/song.mp3) → native player.
339 const media = this.mediaFileEmbed(url);
340 if (media) return media;
341 return match;
342 }
343 );
344 }
345
346 /**
347 * Replace [[embed:<url>]] shortcodes with the platform iframe (YouTube, Spotify,
348 * SoundCloud, Apple Music, Bandcamp, Vimeo). The editor button inserts this
349 * shortcode; bare URL lines also embed automatically via autoembed().
350 * Unsupported/invalid URLs get a clean inline notice.
351 */
352 static embedMediaShortcodes(html) {
353 if (!html) return html;
354 return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawUrl) => {
355 const url = rawUrl.trim().replace(/&amp;/g, '&');
356 const detected = this.detectProvider(url);
357 if (!detected) {
358 // Bare media file (…/clip.webm, …/song.mp3) → native player.
359 const media = this.mediaFileEmbed(url);
360 if (media) return media;
361 return `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
362 }
363 // HERKEND maar niet te bouwen is geen reden om de shortcode zelf te
364 // tonen. Dat deed het wel, en dan leest een bezoeker "[[embed:https://...]]"
365 // op de pagina en denkt hij dat er iets stuk is. Onherkend gaf hierboven
366 // al een nette melding; herkend-maar-mislukt hoort dezelfde te geven,
367 // want voor de lezer is het hetzelfde geval.
368 return this.generateIframe(detected.provider, detected)
369 || `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
370 });
371 }
372
373 /**
374 * Replace [[track:<id>]] shortcodes with v9-style player markup.
375 * Caller passes a lookup function (id) -> { id, title, artist, url, cover }
376 * where url is already a signed /audio/stream/... URL. Unknown ids → left as-is.
377 */
378 static embedTrackShortcodes(html, trackLookup) {
379 if (!html || typeof trackLookup !== 'function') return html;
380 return html.replace(/\[\[track:([A-Za-z0-9_-]+)\]\]/g, (match, id) => {
381 const t = trackLookup(id);
382 if (!t) return match;
383 const titleH0 = this.escape(t.title || 'Untitled');
384 const artistH0 = this.escape(t.artist || '');
385 const creditBits0 = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
386 // Link-only track (no audio file): no play button, but info + open-in links.
387 if (!t.url) {
388 const coverH0 = this.escape(t.cover || '');
389 const leader0 = coverH0
390 ? `<span class="pat-noplay pat-noplay--cover" style="background-image:url('${coverH0}')" aria-hidden="true"></span>`
391 : `<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>`;
392 return `<div class="post-audio-track post-audio-track--static" id="track-${id}">
393 ${leader0}
394 <div class="pat-info">
395 <div class="pat-title">${titleH0}</div>
396 ${artistH0 ? `<div class="pat-artist">${artistH0}</div>` : ''}
397 ${creditBits0 ? `<div class="pat-credit">${creditBits0}</div>` : ''}
398 </div>
399 ${this.openInLinks(t)}
400</div>`;
401 }
402 const trackJson = JSON.stringify({
403 id,
404 url: t.url,
405 title: t.title || 'Untitled',
406 artist: t.artist || '',
407 cover: t.cover || '',
408 credit: t.credit || '',
409 license: t.license || '',
410 });
411 const titleH = this.escape(t.title || 'Untitled');
412 const artistH = this.escape(t.artist || '');
413 const urlH = this.escape(t.url);
414 // Visible owner/license line below the track.
415 const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
416 const dataAttr = trackJson
417 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
418 // id="track-<id>" = anchor so the mini-player can scroll to this element.
419 return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
420 <button type="button" class="pat-play" aria-label="Play ${titleH}">
421 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
422 </button>
423 <div class="pat-info">
424 <div class="pat-title">${titleH}</div>
425 ${artistH ? `<div class="pat-artist">${artistH}</div>` : ''}
426 ${creditBits ? `<div class="pat-credit">${creditBits}</div>` : ''}
427 </div>
428 ${this.openInLinks(t)}
429</div>`;
430 });
431 }
432
433 /**
434 * Replace [[album:<name>]] shortcodes with a v9-style album block.
435 * Caller passes a lookup function (name) -> { title, artist, cover, tracks: [{url,title,artist,cover}, ...] }
436 * Tracks must already have signed URLs. Unknown albums → left as-is.
437 * The wrapper carries the full album JSON so audio-player.js can queue it
438 * when any track or the album play button is clicked.
439 */
440 static embedAlbumShortcodes(html, albumLookup) {
441 if (!html || typeof albumLookup !== 'function') return html;
442 return html.replace(/\[\[album:([^\]]+)\]\]/g, (match, rawName) => {
443 const name = rawName.trim();
444 const album = albumLookup(name);
445 if (!album || !album.tracks || !album.tracks.length) return match;
446
447 // Stable DOM id for this rendering — used as data-pcms-album-id on tracks
448 const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10);
449 // Only playable tracks (with url) in the queue; link-only tracks appear
450 // in the list but not in the playback JSON.
451 const albumJson = JSON.stringify(album.tracks.filter((t) => t.url))
452 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
453 const titleH = this.escape(album.title || name);
454 const artistH = this.escape(album.artist || '');
455 const coverH = album.cover ? this.escape(album.cover) : '';
456
457 const trackItems = album.tracks.map((t, i) => {
458 const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
459 const tArtist = this.escape(t.artist || '');
460 // Link-only track: no play button, but track number + info + open-in links.
461 if (!t.url) {
462 return ` <li class="post-audio-track post-audio-track--static"${t.id ? ` id="track-${t.id}"` : ''}>
463 <span class="pat-track-num">${i + 1}.</span>
464 <div class="pat-info">
465 <div class="pat-title">${tTitle}</div>
466 ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
467 </div>
468 ${this.openInLinks(t)}
469 </li>`;
470 }
471 const tUrl = this.escape(t.url);
472 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}">
473 <button type="button" class="pat-play" aria-label="Play ${tTitle}">
474 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
475 </button>
476 <div class="pat-info">
477 <span class="pat-track-num">${i + 1}.</span>
478 <div class="pat-title">${tTitle}</div>
479 ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
480 </div>
481 ${this.openInLinks(t)}
482 </li>`;
483 }).join('\n');
484
485 return `<div class="post-album" id="${albumDomId}" data-pcms-album='${albumJson}' data-pcms-album-title="${titleH}">
486 <div class="post-album-header">
487 <button type="button" class="post-album-cover-btn" data-pcms-album-id="${albumDomId}" aria-label="Play album ${titleH}">
488 ${coverH
489 ? `<img src="${coverH}" alt="" class="post-album-cover-img">`
490 : `<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>`}
491 <span class="post-album-play-overlay" aria-hidden="true">
492 <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 4l12 8-12 8z"/></svg>
493 </span>
494 </button>
495 <div class="post-album-info">
496 <h3 class="post-album-title">${titleH}</h3>
497 ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
498 <p class="post-album-count">${album.tracks.length} track${album.tracks.length === 1 ? '' : 's'}</p>
499 </div>
500 </div>
501 <ol class="post-album-tracks">
502${trackItems}
503 </ol>
504</div>`;
505 });
506 }
507
508 /**
509 * Replace [[playlist:<id>]] shortcodes with a v9-style album block.
510 * Caller passes a lookup function (id) -> hydrated playlist object from
511 * PlaylistService.get(), or null. Unknown playlists render an inline
512 * "niet gevonden" placeholder so the post still validates as HTML.
513 *
514 * Shape returned by lookup:
515 * { id, title, artist, year, cover, kind, tracks: [{url,title,artist,cover,duration}, ...] }
516 *
517 * `kind` is honored:
518 * - 'album' → ordered list with track numbers
519 * - 'playlist' → list with per-track cover thumbnails (mixtape feel)
520 *
521 * opts: { isAdmin: boolean } — when true, an edit/delete action overlay
522 * is rendered top-right of each card. The handlers are wired up in
523 * audio-player.js via event delegation on data-pcms-playlist-delete.
524 */
525 static embedPlaylistShortcodes(html, playlistLookup, opts = {}) {
526 if (!html || typeof playlistLookup !== 'function') return html;
527 const isAdmin = !!opts.isAdmin;
528 return html.replace(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi, (match, rawId) => {
529 const id = rawId.toLowerCase();
530 const pl = playlistLookup(id);
531
532 if (!pl) {
533 return `<div class="post-playlist-missing"><em>Playlist "${this.escape(id)}" niet gevonden.</em></div>`;
534 }
535 if (!pl.tracks || !pl.tracks.length) {
536 return `<div class="post-playlist-empty"><em>Playlist "${this.escape(pl.title)}" heeft geen beschikbare tracks.</em></div>`;
537 }
538
539 const albumDomId = 'album-' + id;
540 const kind = (pl.kind === 'playlist') ? 'playlist' : 'album';
541 const kindLabel = kind === 'playlist' ? '📃 Playlist' : '💿 Album';
542 const titleH = this.escape(pl.title || 'Naamloos');
543 const artistH = this.escape(pl.artist || '');
544 const coverH = pl.cover ? this.escape(pl.cover) : '';
545
546 // Audio-player.js reads data-pcms-album for queue. Same shape as
547 // embedAlbumShortcodes — keep both in sync.
548 // Only playable tracks in the queue; link-only tracks appear in the list
549 // but not in the playback JSON.
550 const tracksData = pl.tracks.filter(t => t.url).map(t => ({
551 id: t.id,
552 url: t.url,
553 title: t.title,
554 artist: t.artist || pl.artist || '',
555 cover: t.cover || pl.cover || '',
556 }));
557 const albumJson = JSON.stringify(tracksData)
558 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
559
560 // Total duration for the meta line
561 const totalSec = pl.tracks.reduce((s, t) => s + (t.duration || 0), 0);
562 const metaParts = [];
563 if (pl.year) metaParts.push(String(pl.year));
564 metaParts.push(pl.tracks.length + (pl.tracks.length === 1 ? ' track' : ' tracks'));
565 if (totalSec > 0) {
566 const h = Math.floor(totalSec / 3600);
567 const m = Math.floor((totalSec % 3600) / 60);
568 if (h > 0) metaParts.push(`${h}h ${m}m`);
569 else metaParts.push(`${Math.max(1, m)} min`);
570 }
571 const metaLine = this.escape(metaParts.join(' · '));
572 const firstUrl = this.escape((pl.tracks.find(t => t.url) || {}).url || '');
573
574 // Track items — playlist-kind shows per-track cover thumbs, album-kind shows numbers
575 const trackItems = pl.tracks.map((t, i) => {
576 const tTitleH = this.escape(t.title || ('Track ' + (i + 1)));
577 const tArtistH = this.escape(t.artist || '');
578 const tUrl = this.escape(t.url);
579 const showArtist = tArtistH && tArtistH !== artistH;
580
581 // Duration cell — render even when 0 for consistent column layout
582 const durHtml = t.duration > 0
583 ? `<span class="pat-duration">${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}</span>`
584 : `<span class="pat-duration pat-duration-empty">—:—</span>`;
585
586 // Leader cell — number for albums, cover thumb for playlists
587 const leader = (kind === 'playlist' && t.cover)
588 ? `<span class="pat-cover" style="background-image:url(${this.escape(t.cover)})" aria-hidden="true"></span>`
589 : `<span class="pat-num">${i + 1}</span>`;
590
591 // Link-only track: no clickable play row (static div), but open-in links.
592 if (!t.url) {
593 return ` <li class="post-album-track-compact post-album-track-compact--static"${t.id ? ` id="track-${t.id}"` : ''}>
594 <div class="pat-row pat-static">
595 ${leader}
596 <span class="pat-meta">
597 <span class="pat-title">${tTitleH}</span>
598 ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
599 </span>
600 ${durHtml}
601 </div>
602 ${this.openInLinks(t)}
603 </li>`;
604 }
605 const trackBase = String(t.url).split('?')[0];
606 return ` <li class="post-album-track-compact"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''}>
607 <button type="button" class="pat-row"
608 data-pcms-track-url="${tUrl}"
609 data-pcms-album-id="${albumDomId}"
610 data-pcms-track-base="${this.escape(trackBase)}"
611 aria-label="Speel ${tTitleH}">
612 ${leader}
613 <span class="pat-meta">
614 <span class="pat-title">${tTitleH}</span>
615 ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
616 </span>
617 ${durHtml}
618 </button>
619 ${this.openInLinks(t)}
620 </li>`;
621 }).join('\n');
622
623 return `<div class="post-album" id="${albumDomId}"
624 data-pcms-album='${albumJson}'
625 data-pcms-album-title="${titleH}"
626 data-pcms-album-kind="${kind}"
627 data-pcms-playlist-id="${this.escape(id)}">
628${isAdmin ? ` <div class="post-album-actions" role="group" aria-label="Playlist beheren">
629 <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk playlist" aria-label="Bewerk playlist">
630 <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>
631 </a>
632 <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">
633 <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>
634 </button>
635 </div>
636` : ''} <div class="post-album-header">
637 <button type="button" class="post-album-cover-btn"
638 data-pcms-track-url="${firstUrl}"
639 data-pcms-album-id="${albumDomId}"
640 aria-label="Speel ${kind === 'playlist' ? 'playlist' : 'album'}">
641 ${coverH
642 ? `<span class="post-album-cover" style="background-image:url('${coverH}')"></span>`
643 : `<span class="post-album-cover post-album-cover-empty">
644 <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>
645 </span>`}
646 <span class="post-album-cover-play" aria-hidden="true">
647 <svg viewBox="0 0 24 24"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>
648 </span>
649 </button>
650 <div class="post-album-info">
651 <p class="post-album-label">${kindLabel}</p>
652 <h3 class="post-album-title">${titleH}</h3>
653 ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
654 <p class="post-album-meta">${metaLine}</p>
655 </div>
656 </div>
657 <ol class="post-album-tracks post-album-tracks-compact" data-album-kind="${kind}">
658${trackItems}
659 </ol>
660</div>`;
661 });
662 }
663
664 /**
665 * Human-readable label for a provider slug. Used by external-link buttons.
666 */
667 static platformLabel(provider) {
668 return ({
669 spotify: 'Spotify',
670 bandcamp: 'Bandcamp',
671 soundcloud: 'SoundCloud',
672 applemusic: 'Apple Music',
673 youtube: 'YouTube',
674 vimeo: 'Vimeo',
675 tidal: 'Tidal',
676 deezer: 'Deezer',
677 mixcloud: 'Mixcloud',
678 })[provider] || 'External link';
679 }
680
681 /**
682 * Detect platform from URL purely by hostname (covers more services than
683 * detectProvider, which is embed-focused). Used for [[link:url]] rendering.
684 */
685 static detectLinkPlatform(url) {
686 try {
687 const host = new URL(url).hostname.toLowerCase();
688 if (host.includes('open.spotify.com') || host === 'spotify.com') return 'spotify';
689 if (host.includes('bandcamp.com')) return 'bandcamp';
690 if (host.includes('soundcloud.com')) return 'soundcloud';
691 if (host.includes('music.apple.com') || host.includes('itunes.apple.com')) return 'applemusic';
692 if (host.includes('youtube.com') || host.includes('youtu.be') || host.includes('music.youtube.com')) return 'youtube';
693 if (host.includes('vimeo.com')) return 'vimeo';
694 if (host.includes('tidal.com')) return 'tidal';
695 if (host.includes('deezer.com')) return 'deezer';
696 if (host.includes('mixcloud.com')) return 'mixcloud';
697 return 'other';
698 } catch (e) {
699 return null;
700 }
701 }
702
703 /**
704 * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
705 * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
706 * Per Robin's v9: "External link, click = open platform (target _blank)".
707 */
708 static embedExternalLinkShortcodes(html) {
709 if (!html) return html;
710 return html.replace(/\[\[link:([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, rawUrl, customLabel) => {
711 const url = rawUrl.trim();
712 if (!/^https?:\/\//i.test(url)) return match;
713 const platform = this.detectLinkPlatform(url) || 'other';
714 const label = (customLabel || '').trim();
715 const platformLabel = this.platformLabel(platform);
716 const buttonText = label || `Open in ${platformLabel}`;
717 const urlH = this.escape(url);
718 const textH = this.escape(buttonText);
719 return `<a class="post-audio-external post-audio-external--${platform}" href="${urlH}" target="_blank" rel="noopener noreferrer" data-platform="${platform}">
720 <span class="pae-icon" aria-hidden="true">▶</span>
721 <span class="pae-text">${textH}</span>
722 <span class="pae-arrow" aria-hidden="true">↗</span>
723</a>`;
724 });
725 }
726
727 static escape(str) {
728 return str
729 .replace(/&/g, '&amp;')
730 .replace(/</g, '&lt;')
731 .replace(/>/g, '&gt;')
732 .replace(/"/g, '&quot;')
733 .replace(/'/g, '&#39;');
734 }
735}
736
737export default AudioEmbedService;
Note: See TracBrowser for help on using the repository browser.