source: Klonkt/src/services/AudioEmbedService.js@ 8e1af9c

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

Mixtape als posttype, kiesbaar in de composer

Het bandje bestond wel als soort playlist, maar je kon er geen post van maken.
Nu wel: een eigen knop met een eigen teken, en het muziekpaneel deelt hij met
album en playlist -- het verschil zit in de playlist die je insluit, niet in
wat je uploadt.

KEUZE_TYPES en MUZIEK_TYPES in config/post-types.js waren de goede plek; dat
bestand bestaat precies omdat die lijst eerder drie keer los rondslingerde.

Nog twee keer dezelfde binaire vorm opgeruimd, en de tweede was een echte:

  • VOLGBAAR kende geen mixtape, dus het type volgde de muziek niet.
  • De editor zocht de soort op met de vorm p.kind === 'playlist' ? 'playlist' : 'album'. Het scherm zag dus album terwijl de server mixtape opsloeg: het type verspringt onder je handen bij het bewaren.
  • De renderer koos teken en woord met dezelfde tweewegkeuze, waardoor een ingesloten mixtape het jasje van een album droeg.

De SPELER is er nog niet -- een mixtape rendert voorlopig als de gewone lijst.
Wat af is, is dat hij overal zichzelf noemt.

Zes tests, waaronder de opslagweg door de echte route: kent de opslag een type
niet, dan wordt de post zonder melding een gewone post, en dat is precies de
fout waar config/post-types.js voor waarschuwt. Een van de tests riep eerst een
methode aan die niet bestaat en sloeg zichzelf over; die gaat nu door
embedPlaylistShortcodes heen en valt om als je de tweewegkeuze terugzet.

MOD_V naar 51.

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

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