source: Klonkt/src/services/AudioEmbedService.js@ 7d932ce

main
Last change on this file since 7d932ce was 834bcc3, checked in by Robin Genis <roboburr@…>, 3 months ago

i18n: translate Dutch code comments to English across src/

Comments in routes/services/views/config/middleware/assets translated to
English for the public repo. A few dev-facing throw/console message strings
were Englished too. No user-facing UI strings or i18n dictionary values changed
(src/services/i18n.js untouched). Logic unchanged.

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

  • Property mode set to 100644
File size: 28.2 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 — video id is always exactly 11 characters (aligns with the client-side
71 // ytId() in embed-player.js, which also expects {11}).
72 if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) {
73 const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i);
74 return { provider: 'youtube', id: match[1], url };
75 }
76
77 // Vimeo
78 if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) {
79 const match = url.match(/\d+/);
80 return { provider: 'vimeo', id: match[0], url };
81 }
82
83 return null;
84 }
85
86 static generateIframe(provider, config) {
87 switch (provider) {
88 // Custom players (client-side via embed-player.js + the real platform APIs).
89 // We render a placeholder with data attributes instead of the bare platform
90 // iframe, so the embed appears in OUR brand style.
91 case 'youtube':
92 return this.embedPlaceholder('youtube', config.id, 'video',
93 config.url || `https://youtu.be/${config.id}`);
94 case 'soundcloud':
95 return this.embedPlaceholder('soundcloud', config.url, 'track', config.url);
96 case 'spotify':
97 return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
98 config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
99 // No JS API (Bandcamp/Apple) or low priority (Vimeo): remain as iframes;
100 // mutual exclusion for these runs via the blur fallback.
101 case 'bandcamp':
102 return this.bandcampIframe(config);
103 case 'applemusic':
104 return this.applemusicIframe(config);
105 case 'vimeo':
106 return this.vimeoIframe(config);
107 default:
108 return null;
109 }
110 }
111
112 /**
113 * Placeholder for a custom player. embed-player.js picks up
114 * .folio-embed[data-embed-provider] and builds the card + player client-side.
115 * ALL values go through escape() — post.content_html is executed unescaped.
116 */
117 static embedPlaceholder(provider, ref, type, url) {
118 const attrs = [
119 `data-embed-provider="${this.escape(provider)}"`,
120 `data-embed-ref="${this.escape(ref)}"`,
121 type ? `data-embed-type="${this.escape(type)}"` : '',
122 `data-embed-url="${this.escape(url)}"`,
123 ].filter(Boolean).join(' ');
124 return `<div class="folio-embed folio-embed--${this.escape(provider)} pcms-embed pcms-embed-card pcms-embed-loading" ${attrs}></div>`;
125 }
126
127 static spotifyIframe({ type, id }) {
128 const src = `https://open.spotify.com/embed/${type}/${id}`;
129 return `
130 <figure class="folio-embed folio-embed--spotify">
131 <iframe src="${this.escape(src)}"
132 style="width:100%;height:152px;border:0;"
133 loading="lazy"
134 allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
135 title="Spotify ${type}"></iframe>
136 </figure>
137 `.trim();
138 }
139
140 static bandcampIframe({ url }) {
141 const encodedUrl = encodeURIComponent(url);
142 const src = `https://bandcamp.com/EmbeddedPlayer/url=${encodedUrl}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/`;
143 return `
144 <figure class="folio-embed folio-embed--bandcamp">
145 <iframe src="${this.escape(src)}"
146 style="width:100%;height:470px;border:0;"
147 loading="lazy"
148 allow="encrypted-media"
149 title="Bandcamp player"></iframe>
150 </figure>
151 `.trim();
152 }
153
154 static soundcloudIframe({ url }) {
155 const params = {
156 url: url,
157 color: '#ff5500',
158 auto_play: 'false',
159 hide_related: 'true',
160 show_comments: 'false',
161 show_user: 'true',
162 show_reposts: 'false',
163 show_teaser: 'false',
164 visual: 'true'
165 };
166 const query = new URLSearchParams(params).toString();
167 const src = `https://w.soundcloud.com/player/?${query}`;
168 return `
169 <figure class="folio-embed folio-embed--soundcloud">
170 <iframe src="${this.escape(src)}"
171 style="width:100%;height:300px;border:0;"
172 loading="lazy"
173 allow="autoplay; clipboard-write; encrypted-media"
174 title="SoundCloud player"></iframe>
175 </figure>
176 `.trim();
177 }
178
179 static applemusicIframe({ url }) {
180 const match = url.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i);
181 if (!match) return null;
182 const src = `https://embed.music.apple.com/${match[1]}`;
183 return `
184 <figure class="folio-embed folio-embed--applemusic">
185 <iframe src="${this.escape(src)}"
186 style="width:100%;height:175px;border:0;overflow:hidden;border-radius:8px;"
187 loading="lazy"
188 allow="autoplay; clipboard-write; encrypted-media"
189 title="Apple Music"></iframe>
190 </figure>
191 `.trim();
192 }
193
194 static youtubeIframe({ id }) {
195 const src = `https://www.youtube-nocookie.com/embed/${id}`;
196 return `
197 <figure class="folio-embed folio-embed--youtube">
198 <iframe src="${this.escape(src)}"
199 style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
200 loading="lazy"
201 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
202 allowfullscreen
203 title="YouTube video"></iframe>
204 </figure>
205 `.trim();
206 }
207
208 static vimeoIframe({ id }) {
209 const src = `https://player.vimeo.com/video/${id}`;
210 return `
211 <figure class="folio-embed folio-embed--vimeo">
212 <iframe src="${this.escape(src)}"
213 style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
214 loading="lazy"
215 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
216 allowfullscreen
217 title="Vimeo video"></iframe>
218 </figure>
219 `.trim();
220 }
221
222 /**
223 * Auto-embed: Scan paragraphs containing only a URL.
224 * Handles two markdown-rendered shapes:
225 * <p>https://url</p> (bare URL — when GFM auto-link is off)
226 * <p><a href="https://url">https://url</a></p> (marked GFM auto-link — what we get)
227 * Either way → <figure class="folio-embed">...
228 */
229 static autoembed(html) {
230 if (!html) return html;
231 return html.replace(
232 /<p>\s*(?:<a\b[^>]*?\shref="([^"]+)"[^>]*>[^<]*<\/a>|(https?:\/\/[^\s<>"']+))\s*<\/p>/gi,
233 (match, hrefUrl, bareUrl) => {
234 const url = hrefUrl || bareUrl;
235 const detected = this.detectProvider(url);
236 if (detected) {
237 const iframe = this.generateIframe(detected.provider, detected);
238 return iframe || match;
239 }
240 return match;
241 }
242 );
243 }
244
245 /**
246 * Replace [[embed:<url>]] shortcodes with the platform iframe (YouTube, Spotify,
247 * SoundCloud, Apple Music, Bandcamp, Vimeo). The editor button inserts this
248 * shortcode; bare URL lines also embed automatically via autoembed().
249 * Unsupported/invalid URLs get a clean inline notice.
250 */
251 static embedMediaShortcodes(html) {
252 if (!html) return html;
253 return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawUrl) => {
254 const url = rawUrl.trim().replace(/&amp;/g, '&');
255 const detected = this.detectProvider(url);
256 if (!detected) {
257 return `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
258 }
259 return this.generateIframe(detected.provider, detected) || match;
260 });
261 }
262
263 /**
264 * Replace [[track:<id>]] shortcodes with v9-style player markup.
265 * Caller passes a lookup function (id) -> { id, title, artist, url, cover }
266 * where url is already a signed /audio/stream/... URL. Unknown ids → left as-is.
267 */
268 static embedTrackShortcodes(html, trackLookup) {
269 if (!html || typeof trackLookup !== 'function') return html;
270 return html.replace(/\[\[track:([A-Za-z0-9_-]+)\]\]/g, (match, id) => {
271 const t = trackLookup(id);
272 if (!t) return match;
273 const titleH0 = this.escape(t.title || 'Untitled');
274 const artistH0 = this.escape(t.artist || '');
275 const creditBits0 = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
276 // Link-only track (no audio file): no play button, but info + open-in links.
277 if (!t.url) {
278 const coverH0 = this.escape(t.cover || '');
279 const leader0 = coverH0
280 ? `<span class="pat-noplay pat-noplay--cover" style="background-image:url('${coverH0}')" aria-hidden="true"></span>`
281 : `<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>`;
282 return `<div class="post-audio-track post-audio-track--static" id="track-${id}">
283 ${leader0}
284 <div class="pat-info">
285 <div class="pat-title">${titleH0}</div>
286 ${artistH0 ? `<div class="pat-artist">${artistH0}</div>` : ''}
287 ${creditBits0 ? `<div class="pat-credit">${creditBits0}</div>` : ''}
288 </div>
289 ${this.openInLinks(t)}
290</div>`;
291 }
292 const trackJson = JSON.stringify({
293 id,
294 url: t.url,
295 title: t.title || 'Untitled',
296 artist: t.artist || '',
297 cover: t.cover || '',
298 credit: t.credit || '',
299 license: t.license || '',
300 });
301 const titleH = this.escape(t.title || 'Untitled');
302 const artistH = this.escape(t.artist || '');
303 const urlH = this.escape(t.url);
304 // Visible owner/license line below the track.
305 const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
306 const dataAttr = trackJson
307 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
308 // id="track-<id>" = anchor so the mini-player can scroll to this element.
309 return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
310 <button type="button" class="pat-play" aria-label="Play ${titleH}">
311 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
312 </button>
313 <div class="pat-info">
314 <div class="pat-title">${titleH}</div>
315 ${artistH ? `<div class="pat-artist">${artistH}</div>` : ''}
316 ${creditBits ? `<div class="pat-credit">${creditBits}</div>` : ''}
317 </div>
318 ${this.openInLinks(t)}
319</div>`;
320 });
321 }
322
323 /**
324 * Replace [[album:<name>]] shortcodes with a v9-style album block.
325 * Caller passes a lookup function (name) -> { title, artist, cover, tracks: [{url,title,artist,cover}, ...] }
326 * Tracks must already have signed URLs. Unknown albums → left as-is.
327 * The wrapper carries the full album JSON so audio-player.js can queue it
328 * when any track or the album play button is clicked.
329 */
330 static embedAlbumShortcodes(html, albumLookup) {
331 if (!html || typeof albumLookup !== 'function') return html;
332 return html.replace(/\[\[album:([^\]]+)\]\]/g, (match, rawName) => {
333 const name = rawName.trim();
334 const album = albumLookup(name);
335 if (!album || !album.tracks || !album.tracks.length) return match;
336
337 // Stable DOM id for this rendering — used as data-pcms-album-id on tracks
338 const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10);
339 // Only playable tracks (with url) in the queue; link-only tracks appear
340 // in the list but not in the playback JSON.
341 const albumJson = JSON.stringify(album.tracks.filter((t) => t.url))
342 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
343 const titleH = this.escape(album.title || name);
344 const artistH = this.escape(album.artist || '');
345 const coverH = album.cover ? this.escape(album.cover) : '';
346
347 const trackItems = album.tracks.map((t, i) => {
348 const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
349 const tArtist = this.escape(t.artist || '');
350 // Link-only track: no play button, but track number + info + open-in links.
351 if (!t.url) {
352 return ` <li class="post-audio-track post-audio-track--static"${t.id ? ` id="track-${t.id}"` : ''}>
353 <span class="pat-track-num">${i + 1}.</span>
354 <div class="pat-info">
355 <div class="pat-title">${tTitle}</div>
356 ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
357 </div>
358 ${this.openInLinks(t)}
359 </li>`;
360 }
361 const tUrl = this.escape(t.url);
362 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}">
363 <button type="button" class="pat-play" aria-label="Play ${tTitle}">
364 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
365 </button>
366 <div class="pat-info">
367 <span class="pat-track-num">${i + 1}.</span>
368 <div class="pat-title">${tTitle}</div>
369 ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
370 </div>
371 ${this.openInLinks(t)}
372 </li>`;
373 }).join('\n');
374
375 return `<div class="post-album" id="${albumDomId}" data-pcms-album='${albumJson}' data-pcms-album-title="${titleH}">
376 <div class="post-album-header">
377 <button type="button" class="post-album-cover-btn" data-pcms-album-id="${albumDomId}" aria-label="Play album ${titleH}">
378 ${coverH
379 ? `<img src="${coverH}" alt="" class="post-album-cover-img">`
380 : `<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>`}
381 <span class="post-album-play-overlay" aria-hidden="true">
382 <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 4l12 8-12 8z"/></svg>
383 </span>
384 </button>
385 <div class="post-album-info">
386 <h3 class="post-album-title">${titleH}</h3>
387 ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
388 <p class="post-album-count">${album.tracks.length} track${album.tracks.length === 1 ? '' : 's'}</p>
389 </div>
390 </div>
391 <ol class="post-album-tracks">
392${trackItems}
393 </ol>
394</div>`;
395 });
396 }
397
398 /**
399 * Replace [[playlist:<id>]] shortcodes with a v9-style album block.
400 * Caller passes a lookup function (id) -> hydrated playlist object from
401 * PlaylistService.get(), or null. Unknown playlists render an inline
402 * "niet gevonden" placeholder so the post still validates as HTML.
403 *
404 * Shape returned by lookup:
405 * { id, title, artist, year, cover, kind, tracks: [{url,title,artist,cover,duration}, ...] }
406 *
407 * `kind` is honored:
408 * - 'album' → ordered list with track numbers
409 * - 'playlist' → list with per-track cover thumbnails (mixtape feel)
410 *
411 * opts: { isAdmin: boolean } — when true, an edit/delete action overlay
412 * is rendered top-right of each card. The handlers are wired up in
413 * audio-player.js via event delegation on data-pcms-playlist-delete.
414 */
415 static embedPlaylistShortcodes(html, playlistLookup, opts = {}) {
416 if (!html || typeof playlistLookup !== 'function') return html;
417 const isAdmin = !!opts.isAdmin;
418 return html.replace(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi, (match, rawId) => {
419 const id = rawId.toLowerCase();
420 const pl = playlistLookup(id);
421
422 if (!pl) {
423 return `<div class="post-playlist-missing"><em>Playlist "${this.escape(id)}" niet gevonden.</em></div>`;
424 }
425 if (!pl.tracks || !pl.tracks.length) {
426 return `<div class="post-playlist-empty"><em>Playlist "${this.escape(pl.title)}" heeft geen beschikbare tracks.</em></div>`;
427 }
428
429 const albumDomId = 'album-' + id;
430 const kind = (pl.kind === 'playlist') ? 'playlist' : 'album';
431 const kindLabel = kind === 'playlist' ? '📃 Playlist' : '💿 Album';
432 const titleH = this.escape(pl.title || 'Naamloos');
433 const artistH = this.escape(pl.artist || '');
434 const coverH = pl.cover ? this.escape(pl.cover) : '';
435
436 // Audio-player.js reads data-pcms-album for queue. Same shape as
437 // embedAlbumShortcodes — keep both in sync.
438 // Only playable tracks in the queue; link-only tracks appear in the list
439 // but not in the playback JSON.
440 const tracksData = pl.tracks.filter(t => t.url).map(t => ({
441 id: t.id,
442 url: t.url,
443 title: t.title,
444 artist: t.artist || pl.artist || '',
445 cover: t.cover || pl.cover || '',
446 }));
447 const albumJson = JSON.stringify(tracksData)
448 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
449
450 // Total duration for the meta line
451 const totalSec = pl.tracks.reduce((s, t) => s + (t.duration || 0), 0);
452 const metaParts = [];
453 if (pl.year) metaParts.push(String(pl.year));
454 metaParts.push(pl.tracks.length + (pl.tracks.length === 1 ? ' track' : ' tracks'));
455 if (totalSec > 0) {
456 const h = Math.floor(totalSec / 3600);
457 const m = Math.floor((totalSec % 3600) / 60);
458 if (h > 0) metaParts.push(`${h}h ${m}m`);
459 else metaParts.push(`${Math.max(1, m)} min`);
460 }
461 const metaLine = this.escape(metaParts.join(' · '));
462 const firstUrl = this.escape((pl.tracks.find(t => t.url) || {}).url || '');
463
464 // Track items — playlist-kind shows per-track cover thumbs, album-kind shows numbers
465 const trackItems = pl.tracks.map((t, i) => {
466 const tTitleH = this.escape(t.title || ('Track ' + (i + 1)));
467 const tArtistH = this.escape(t.artist || '');
468 const tUrl = this.escape(t.url);
469 const showArtist = tArtistH && tArtistH !== artistH;
470
471 // Duration cell — render even when 0 for consistent column layout
472 const durHtml = t.duration > 0
473 ? `<span class="pat-duration">${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}</span>`
474 : `<span class="pat-duration pat-duration-empty">—:—</span>`;
475
476 // Leader cell — number for albums, cover thumb for playlists
477 const leader = (kind === 'playlist' && t.cover)
478 ? `<span class="pat-cover" style="background-image:url(${this.escape(t.cover)})" aria-hidden="true"></span>`
479 : `<span class="pat-num">${i + 1}</span>`;
480
481 // Link-only track: no clickable play row (static div), but open-in links.
482 if (!t.url) {
483 return ` <li class="post-album-track-compact post-album-track-compact--static"${t.id ? ` id="track-${t.id}"` : ''}>
484 <div class="pat-row pat-static">
485 ${leader}
486 <span class="pat-meta">
487 <span class="pat-title">${tTitleH}</span>
488 ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
489 </span>
490 ${durHtml}
491 </div>
492 ${this.openInLinks(t)}
493 </li>`;
494 }
495 const trackBase = String(t.url).split('?')[0];
496 return ` <li class="post-album-track-compact"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''}>
497 <button type="button" class="pat-row"
498 data-pcms-track-url="${tUrl}"
499 data-pcms-album-id="${albumDomId}"
500 data-pcms-track-base="${this.escape(trackBase)}"
501 aria-label="Speel ${tTitleH}">
502 ${leader}
503 <span class="pat-meta">
504 <span class="pat-title">${tTitleH}</span>
505 ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
506 </span>
507 ${durHtml}
508 </button>
509 ${this.openInLinks(t)}
510 </li>`;
511 }).join('\n');
512
513 return `<div class="post-album" id="${albumDomId}"
514 data-pcms-album='${albumJson}'
515 data-pcms-album-title="${titleH}"
516 data-pcms-album-kind="${kind}"
517 data-pcms-playlist-id="${this.escape(id)}">
518${isAdmin ? ` <div class="post-album-actions" role="group" aria-label="Playlist beheren">
519 <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk playlist" aria-label="Bewerk playlist">
520 <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>
521 </a>
522 <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">
523 <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>
524 </button>
525 </div>
526` : ''} <div class="post-album-header">
527 <button type="button" class="post-album-cover-btn"
528 data-pcms-track-url="${firstUrl}"
529 data-pcms-album-id="${albumDomId}"
530 aria-label="Speel ${kind === 'playlist' ? 'playlist' : 'album'}">
531 ${coverH
532 ? `<span class="post-album-cover" style="background-image:url('${coverH}')"></span>`
533 : `<span class="post-album-cover post-album-cover-empty">
534 <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>
535 </span>`}
536 <span class="post-album-cover-play" aria-hidden="true">
537 <svg viewBox="0 0 24 24"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>
538 </span>
539 </button>
540 <div class="post-album-info">
541 <p class="post-album-label">${kindLabel}</p>
542 <h3 class="post-album-title">${titleH}</h3>
543 ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
544 <p class="post-album-meta">${metaLine}</p>
545 </div>
546 </div>
547 <ol class="post-album-tracks post-album-tracks-compact" data-album-kind="${kind}">
548${trackItems}
549 </ol>
550</div>`;
551 });
552 }
553
554 /**
555 * Human-readable label for a provider slug. Used by external-link buttons.
556 */
557 static platformLabel(provider) {
558 return ({
559 spotify: 'Spotify',
560 bandcamp: 'Bandcamp',
561 soundcloud: 'SoundCloud',
562 applemusic: 'Apple Music',
563 youtube: 'YouTube',
564 vimeo: 'Vimeo',
565 tidal: 'Tidal',
566 deezer: 'Deezer',
567 mixcloud: 'Mixcloud',
568 })[provider] || 'External link';
569 }
570
571 /**
572 * Detect platform from URL purely by hostname (covers more services than
573 * detectProvider, which is embed-focused). Used for [[link:url]] rendering.
574 */
575 static detectLinkPlatform(url) {
576 try {
577 const host = new URL(url).hostname.toLowerCase();
578 if (host.includes('open.spotify.com') || host === 'spotify.com') return 'spotify';
579 if (host.includes('bandcamp.com')) return 'bandcamp';
580 if (host.includes('soundcloud.com')) return 'soundcloud';
581 if (host.includes('music.apple.com') || host.includes('itunes.apple.com')) return 'applemusic';
582 if (host.includes('youtube.com') || host.includes('youtu.be') || host.includes('music.youtube.com')) return 'youtube';
583 if (host.includes('vimeo.com')) return 'vimeo';
584 if (host.includes('tidal.com')) return 'tidal';
585 if (host.includes('deezer.com')) return 'deezer';
586 if (host.includes('mixcloud.com')) return 'mixcloud';
587 return 'other';
588 } catch (e) {
589 return null;
590 }
591 }
592
593 /**
594 * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
595 * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
596 * Per Robin's v9: "External link, click = open platform (target _blank)".
597 */
598 static embedExternalLinkShortcodes(html) {
599 if (!html) return html;
600 return html.replace(/\[\[link:([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, rawUrl, customLabel) => {
601 const url = rawUrl.trim();
602 if (!/^https?:\/\//i.test(url)) return match;
603 const platform = this.detectLinkPlatform(url) || 'other';
604 const label = (customLabel || '').trim();
605 const platformLabel = this.platformLabel(platform);
606 const buttonText = label || `Open in ${platformLabel}`;
607 const urlH = this.escape(url);
608 const textH = this.escape(buttonText);
609 return `<a class="post-audio-external post-audio-external--${platform}" href="${urlH}" target="_blank" rel="noopener noreferrer" data-platform="${platform}">
610 <span class="pae-icon" aria-hidden="true">▶</span>
611 <span class="pae-text">${textH}</span>
612 <span class="pae-arrow" aria-hidden="true">↗</span>
613</a>`;
614 });
615 }
616
617 static escape(str) {
618 return str
619 .replace(/&/g, '&amp;')
620 .replace(/</g, '&lt;')
621 .replace(/>/g, '&gt;')
622 .replace(/"/g, '&quot;')
623 .replace(/'/g, '&#39;');
624 }
625}
626
627export default AudioEmbedService;
Note: See TracBrowser for help on using the repository browser.