source: Klonkt/src/services/AudioEmbedService.js@ 995b100

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

YouTube-playlists in een post, met dezelfde parsing als de hub

Een link naar een YouTube-album speelde het eerste nummer en stopte, en
een kale playlist-link werd helemaal niet als YouTube herkend.
detectProvider hield alleen de video-id vast en gooide list= weg.

De ref kent nu drie vormen, exact die van de Klonkt hub, zodat één ref
tussen de twee heen en weer kan zonder vertaling:

"<video>" een video
"<video>?list=<L>" die video, en door de lijst heen
"list:<L>" de hele playlist (YouTube's videoseries)

list mag voor of na v= staan en is in een gebakken href vaak
entity-gecodeerd (&amp;), dus er wordt over de hele URL gezocht in plaats
van op een vaste volgorde. youtube-nocookie.com telt mee als host: dat is
wat een embed zelf uitzendt en dus wat mensen terugplakken.

videoseries is EXACT elf tekens, net als een video-id. Geen lengte- of
grensregel vangt hem -- hij moet bij naam uitgesloten worden. Ik liep er
tijdens het bouwen zelf in met een grenscontrole die eroverheen leek te
gaan; vandaar de test die hem apart vastlegt.

Aan de clientkant (embed-player.js) kennen ytId/ytList/ytEmbedSrc dezelfde
drie vormen. Twee dingen die daar meekomen:

  • De poster interpoleerde een null video-id tot i.ytimg.com/vi/null/hqdefault.jpg -- een 404 als achtergrond. Een kale playlist heeft geen video, en krijgt nu gewoon geen poster.
  • YouTube meldt binnen een playlist "ended" TUSSEN elk tweetal nummers. Daar meteen onEnded op vuren geeft de wachtrij door na nummer één en kapt het album af. Op een lijst wachten we daarom 2,5 seconde, en telt alleen een stilte die niet door het volgende nummer wordt onderbroken -- dezelfde regel als in de hub.

De dode youtubeIframe() is meegegaan: hij wordt nergens aangeroepen, maar
een terugval die de playlist stil laat vallen is de ergste soort, want
die ziet eruit alsof het werkte.

Wat hier NIET in zit: de hub haalt ook de nummerlijst van een playlist op
(Data API met sleutel, anders keyless via de RSS-feed). Dat vraagt een
sleutel en een bewaarplek en is een aparte keuze.

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

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