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

main
Last change on this file since fef781e was fef781e, checked in by roboburr <roboburr@…>, 3 months ago

revert: remove YouTube audio functionality (doesn't work)

YouTube blocks audio-only/hidden embed playback with a "Sign in to confirm
you're not a bot" gate → not reliably usable. All YT-audio reverted:

  • embed-player.js + embed.css → state before the feature (audio-only card, YT-album, buildYtAlbum, prewarm, mode-routing removed). Video embeds + the YT/SC volume button remain.
  • audio-player.js → YT backend (ytMode/ensureYT/playYouTube/playYouTubeList/…) removed; only the blob-<audio> player + existing features.
  • AudioEmbedService.js → audio:/video: mode parsing + data-embed-mode removed.
  • audio.js → /yt-title oEmbed proxy removed.
  • post-edit.ejs → audio/video choice on the embed button removed ([[embed:url]]

normal).

busters audio-player.js?v=28, embed-player.js?v=16, embed.css?v=8.

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

  • Property mode set to 100644
File size: 23.4 KB
RevLine 
[7bc636b]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
13class AudioEmbedService {
14 static detectProvider(url) {
15 if (!url || typeof url !== 'string') return null;
16 url = url.trim();
17
[4c9f29a]18 // Alleen http(s)-URL's embedden. De provider-regexes hieronder zijn NIET
19 // verankerd, dus zonder deze check zou bv. `javascript:alert(1)//youtu.be/x`
20 // matchen en als embed-URL belanden (stored XSS via een [[embed:...]]-
21 // shortcode — die tekst gaat niet langs de HTML-sanitizer omdat 'ie in een
22 // text-node zit). De scheme-guard sluit javascript:/data:/vbscript: enz. uit.
23 if (!/^https?:\/\//i.test(url)) return null;
24
[7bc636b]25 // Spotify
26 if (/open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i.test(url)) {
27 const match = url.match(/\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i);
[4c9f29a]28 return { provider: 'spotify', type: match[1], id: match[2], url };
[7bc636b]29 }
30
31 // Bandcamp
32 if (/bandcamp\.com\/(track|album)/i.test(url)) {
33 return { provider: 'bandcamp', url };
34 }
35
36 // SoundCloud
37 if (/soundcloud\.com/i.test(url)) {
38 return { provider: 'soundcloud', url };
39 }
40
41 // Apple Music
42 if (/music\.apple\.com\/([a-z]{2})\/(?:album|playlist|song)\//i.test(url)) {
43 return { provider: 'applemusic', url };
44 }
45
[4c9f29a]46 // YouTube — video-id is altijd exact 11 tekens (lijnt uit met de client-side
47 // ytId() in embed-player.js, die ook {11} verwacht).
48 if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) {
49 const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i);
50 return { provider: 'youtube', id: match[1], url };
[7bc636b]51 }
52
53 // Vimeo
54 if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) {
55 const match = url.match(/\d+/);
[4c9f29a]56 return { provider: 'vimeo', id: match[0], url };
[7bc636b]57 }
58
59 return null;
60 }
61
62 static generateIframe(provider, config) {
63 switch (provider) {
[4c9f29a]64 // Eigen custom-spelers (client-side via embed-player.js + de echte
65 // platform-API's). We renderen een placeholder met data-attributen i.p.v.
66 // het kale platform-iframe, zodat de embed in ÓNZE huisstijl verschijnt.
67 case 'youtube':
68 return this.embedPlaceholder('youtube', config.id, 'video',
[fef781e]69 config.url || `https://youtu.be/${config.id}`);
[4c9f29a]70 case 'soundcloud':
71 return this.embedPlaceholder('soundcloud', config.url, 'track', config.url);
[7bc636b]72 case 'spotify':
[4c9f29a]73 return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
74 config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
75 // Geen JS-API (Bandcamp/Apple) of niet-prioritair (Vimeo): blijven een
76 // iframe; mutual-exclusion loopt voor deze via de blur-fallback.
[7bc636b]77 case 'bandcamp':
78 return this.bandcampIframe(config);
79 case 'applemusic':
80 return this.applemusicIframe(config);
81 case 'vimeo':
82 return this.vimeoIframe(config);
83 default:
84 return null;
85 }
86 }
87
[4c9f29a]88 /**
89 * Placeholder voor een eigen custom-speler. embed-player.js pikt
90 * .folio-embed[data-embed-provider] op en bouwt de kaart + speler client-side.
91 * ALLE waarden via escape() — post.content_html wordt ongeescaped uitgevoerd.
92 */
[fef781e]93 static embedPlaceholder(provider, ref, type, url) {
[4c9f29a]94 const attrs = [
95 `data-embed-provider="${this.escape(provider)}"`,
96 `data-embed-ref="${this.escape(ref)}"`,
97 type ? `data-embed-type="${this.escape(type)}"` : '',
98 `data-embed-url="${this.escape(url)}"`,
99 ].filter(Boolean).join(' ');
100 return `<div class="folio-embed folio-embed--${this.escape(provider)} pcms-embed pcms-embed-card pcms-embed-loading" ${attrs}></div>`;
101 }
102
[7bc636b]103 static spotifyIframe({ type, id }) {
104 const src = `https://open.spotify.com/embed/${type}/${id}`;
105 return `
106 <figure class="folio-embed folio-embed--spotify">
107 <iframe src="${this.escape(src)}"
108 style="width:100%;height:152px;border:0;"
109 loading="lazy"
110 allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
111 title="Spotify ${type}"></iframe>
112 </figure>
113 `.trim();
114 }
115
116 static bandcampIframe({ url }) {
117 const encodedUrl = encodeURIComponent(url);
118 const src = `https://bandcamp.com/EmbeddedPlayer/url=${encodedUrl}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/`;
119 return `
120 <figure class="folio-embed folio-embed--bandcamp">
121 <iframe src="${this.escape(src)}"
122 style="width:100%;height:470px;border:0;"
123 loading="lazy"
124 allow="encrypted-media"
125 title="Bandcamp player"></iframe>
126 </figure>
127 `.trim();
128 }
129
130 static soundcloudIframe({ url }) {
131 const params = {
132 url: url,
133 color: '#ff5500',
134 auto_play: 'false',
135 hide_related: 'true',
136 show_comments: 'false',
137 show_user: 'true',
138 show_reposts: 'false',
139 show_teaser: 'false',
140 visual: 'true'
141 };
142 const query = new URLSearchParams(params).toString();
143 const src = `https://w.soundcloud.com/player/?${query}`;
144 return `
145 <figure class="folio-embed folio-embed--soundcloud">
146 <iframe src="${this.escape(src)}"
147 style="width:100%;height:300px;border:0;"
148 loading="lazy"
149 allow="autoplay; clipboard-write; encrypted-media"
150 title="SoundCloud player"></iframe>
151 </figure>
152 `.trim();
153 }
154
155 static applemusicIframe({ url }) {
156 const match = url.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i);
157 if (!match) return null;
158 const src = `https://embed.music.apple.com/${match[1]}`;
159 return `
160 <figure class="folio-embed folio-embed--applemusic">
161 <iframe src="${this.escape(src)}"
162 style="width:100%;height:175px;border:0;overflow:hidden;border-radius:8px;"
163 loading="lazy"
164 allow="autoplay; clipboard-write; encrypted-media"
165 title="Apple Music"></iframe>
166 </figure>
167 `.trim();
168 }
169
170 static youtubeIframe({ id }) {
171 const src = `https://www.youtube-nocookie.com/embed/${id}`;
172 return `
173 <figure class="folio-embed folio-embed--youtube">
174 <iframe src="${this.escape(src)}"
175 style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
176 loading="lazy"
177 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
178 allowfullscreen
179 title="YouTube video"></iframe>
180 </figure>
181 `.trim();
182 }
183
184 static vimeoIframe({ id }) {
185 const src = `https://player.vimeo.com/video/${id}`;
186 return `
187 <figure class="folio-embed folio-embed--vimeo">
188 <iframe src="${this.escape(src)}"
189 style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
190 loading="lazy"
191 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
192 allowfullscreen
193 title="Vimeo video"></iframe>
194 </figure>
195 `.trim();
196 }
197
198 /**
199 * Auto-embed: Scan paragraphs containing only a URL.
200 * Handles two markdown-rendered shapes:
201 * <p>https://url</p> (bare URL — when GFM auto-link is off)
202 * <p><a href="https://url">https://url</a></p> (marked GFM auto-link — what we get)
203 * Either way → <figure class="folio-embed">...
204 */
205 static autoembed(html) {
206 if (!html) return html;
207 return html.replace(
208 /<p>\s*(?:<a\b[^>]*?\shref="([^"]+)"[^>]*>[^<]*<\/a>|(https?:\/\/[^\s<>"']+))\s*<\/p>/gi,
209 (match, hrefUrl, bareUrl) => {
210 const url = hrefUrl || bareUrl;
211 const detected = this.detectProvider(url);
212 if (detected) {
213 const iframe = this.generateIframe(detected.provider, detected);
214 return iframe || match;
215 }
216 return match;
217 }
218 );
219 }
220
[1907a18]221 /**
222 * Replace [[embed:<url>]] shortcodes met de platform-iframe (YouTube, Spotify,
223 * SoundCloud, Apple Music, Bandcamp, Vimeo). De editor-knop voegt deze
224 * shortcode in; losse URL-regels embedden ook automatisch via autoembed().
225 * Niet-ondersteunde/ongeldige URLs krijgen een nette inline-melding.
226 */
227 static embedMediaShortcodes(html) {
228 if (!html) return html;
[fef781e]229 return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawUrl) => {
230 const url = rawUrl.trim().replace(/&amp;/g, '&');
[1907a18]231 const detected = this.detectProvider(url);
232 if (!detected) {
233 return `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
234 }
[fef781e]235 return this.generateIframe(detected.provider, detected) || match;
[1907a18]236 });
237 }
238
[7bc636b]239 /**
240 * Replace [[track:<id>]] shortcodes with v9-style player markup.
241 * Caller passes a lookup function (id) -> { id, title, artist, url, cover }
242 * where url is already a signed /audio/stream/... URL. Unknown ids → left as-is.
243 */
244 static embedTrackShortcodes(html, trackLookup) {
245 if (!html || typeof trackLookup !== 'function') return html;
246 return html.replace(/\[\[track:([A-Za-z0-9_-]+)\]\]/g, (match, id) => {
247 const t = trackLookup(id);
248 if (!t || !t.url) return match;
249 const trackJson = JSON.stringify({
[230e446]250 id,
[7bc636b]251 url: t.url,
252 title: t.title || 'Untitled',
253 artist: t.artist || '',
254 cover: t.cover || '',
255 });
256 const titleH = this.escape(t.title || 'Untitled');
257 const artistH = this.escape(t.artist || '');
258 const urlH = this.escape(t.url);
259 const dataAttr = trackJson
260 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
[230e446]261 // id="track-<id>" = anker zodat de mini-speler hierheen kan scrollen.
262 return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
[7bc636b]263 <button type="button" class="pat-play" aria-label="Play ${titleH}">
264 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
265 </button>
266 <div class="pat-info">
267 <div class="pat-title">${titleH}</div>
268 ${artistH ? `<div class="pat-artist">${artistH}</div>` : ''}
269 </div>
270</div>`;
271 });
272 }
273
274 /**
275 * Replace [[album:<name>]] shortcodes with a v9-style album block.
276 * Caller passes a lookup function (name) -> { title, artist, cover, tracks: [{url,title,artist,cover}, ...] }
277 * Tracks must already have signed URLs. Unknown albums → left as-is.
278 * The wrapper carries the full album JSON so audio-player.js can queue it
279 * when any track or the album play button is clicked.
280 */
281 static embedAlbumShortcodes(html, albumLookup) {
282 if (!html || typeof albumLookup !== 'function') return html;
283 return html.replace(/\[\[album:([^\]]+)\]\]/g, (match, rawName) => {
284 const name = rawName.trim();
285 const album = albumLookup(name);
286 if (!album || !album.tracks || !album.tracks.length) return match;
287
288 // Stable DOM id for this rendering — used as data-pcms-album-id on tracks
289 const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10);
290 const albumJson = JSON.stringify(album.tracks)
291 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
292 const titleH = this.escape(album.title || name);
293 const artistH = this.escape(album.artist || '');
294 const coverH = album.cover ? this.escape(album.cover) : '';
295
296 const trackItems = album.tracks.map((t, i) => {
297 const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
298 const tArtist = this.escape(t.artist || '');
299 const tUrl = this.escape(t.url);
[359b9ae]300 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}">
[7bc636b]301 <button type="button" class="pat-play" aria-label="Play ${tTitle}">
302 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
303 </button>
304 <div class="pat-info">
305 <span class="pat-track-num">${i + 1}.</span>
306 <div class="pat-title">${tTitle}</div>
307 ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
308 </div>
309 </li>`;
310 }).join('\n');
311
312 return `<div class="post-album" id="${albumDomId}" data-pcms-album='${albumJson}' data-pcms-album-title="${titleH}">
313 <div class="post-album-header">
314 <button type="button" class="post-album-cover-btn" data-pcms-album-id="${albumDomId}" aria-label="Play album ${titleH}">
315 ${coverH
316 ? `<img src="${coverH}" alt="" class="post-album-cover-img">`
317 : `<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>`}
318 <span class="post-album-play-overlay" aria-hidden="true">
319 <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 4l12 8-12 8z"/></svg>
320 </span>
321 </button>
322 <div class="post-album-info">
323 <h3 class="post-album-title">${titleH}</h3>
324 ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
325 <p class="post-album-count">${album.tracks.length} track${album.tracks.length === 1 ? '' : 's'}</p>
326 </div>
327 </div>
328 <ol class="post-album-tracks">
329${trackItems}
330 </ol>
331</div>`;
332 });
333 }
334
335 /**
336 * Replace [[playlist:<id>]] shortcodes with a v9-style album block.
337 * Caller passes a lookup function (id) -> hydrated playlist object from
338 * PlaylistService.get(), or null. Unknown playlists render an inline
339 * "niet gevonden" placeholder so the post still validates as HTML.
340 *
341 * Shape returned by lookup:
342 * { id, title, artist, year, cover, kind, tracks: [{url,title,artist,cover,duration}, ...] }
343 *
344 * `kind` is honored:
345 * - 'album' → ordered list with track numbers
346 * - 'playlist' → list with per-track cover thumbnails (mixtape feel)
347 *
348 * opts: { isAdmin: boolean } — when true, an edit/delete action overlay
349 * is rendered top-right of each card. The handlers are wired up in
350 * audio-player.js via event delegation on data-pcms-playlist-delete.
351 */
352 static embedPlaylistShortcodes(html, playlistLookup, opts = {}) {
353 if (!html || typeof playlistLookup !== 'function') return html;
354 const isAdmin = !!opts.isAdmin;
355 return html.replace(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi, (match, rawId) => {
356 const id = rawId.toLowerCase();
357 const pl = playlistLookup(id);
358
359 if (!pl) {
360 return `<div class="post-playlist-missing"><em>Playlist "${this.escape(id)}" niet gevonden.</em></div>`;
361 }
362 if (!pl.tracks || !pl.tracks.length) {
363 return `<div class="post-playlist-empty"><em>Playlist "${this.escape(pl.title)}" heeft geen beschikbare tracks.</em></div>`;
364 }
365
366 const albumDomId = 'album-' + id;
367 const kind = (pl.kind === 'playlist') ? 'playlist' : 'album';
368 const kindLabel = kind === 'playlist' ? '📃 Playlist' : '💿 Album';
369 const titleH = this.escape(pl.title || 'Naamloos');
370 const artistH = this.escape(pl.artist || '');
371 const coverH = pl.cover ? this.escape(pl.cover) : '';
372
373 // Audio-player.js reads data-pcms-album for queue. Same shape as
374 // embedAlbumShortcodes — keep both in sync.
375 const tracksData = pl.tracks.map(t => ({
[ddf549f]376 id: t.id,
[7bc636b]377 url: t.url,
378 title: t.title,
379 artist: t.artist || pl.artist || '',
380 cover: t.cover || pl.cover || '',
381 }));
382 const albumJson = JSON.stringify(tracksData)
383 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
384
385 // Total duration for the meta line
386 const totalSec = pl.tracks.reduce((s, t) => s + (t.duration || 0), 0);
387 const metaParts = [];
388 if (pl.year) metaParts.push(String(pl.year));
389 metaParts.push(pl.tracks.length + (pl.tracks.length === 1 ? ' track' : ' tracks'));
390 if (totalSec > 0) {
391 const h = Math.floor(totalSec / 3600);
392 const m = Math.floor((totalSec % 3600) / 60);
393 if (h > 0) metaParts.push(`${h}h ${m}m`);
394 else metaParts.push(`${Math.max(1, m)} min`);
395 }
396 const metaLine = this.escape(metaParts.join(' · '));
397 const firstUrl = this.escape(pl.tracks[0].url);
398
399 // Track items — playlist-kind shows per-track cover thumbs, album-kind shows numbers
400 const trackItems = pl.tracks.map((t, i) => {
401 const tTitleH = this.escape(t.title || ('Track ' + (i + 1)));
402 const tArtistH = this.escape(t.artist || '');
403 const tUrl = this.escape(t.url);
404 const showArtist = tArtistH && tArtistH !== artistH;
405
406 // Duration cell — render even when 0 for consistent column layout
407 const durHtml = t.duration > 0
408 ? `<span class="pat-duration">${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}</span>`
409 : `<span class="pat-duration pat-duration-empty">—:—</span>`;
410
411 // Leader cell — number for albums, cover thumb for playlists
412 const leader = (kind === 'playlist' && t.cover)
413 ? `<span class="pat-cover" style="background-image:url(${this.escape(t.cover)})" aria-hidden="true"></span>`
414 : `<span class="pat-num">${i + 1}</span>`;
415
416 const trackBase = String(t.url).split('?')[0];
[359b9ae]417 return ` <li class="post-album-track-compact"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''}>
[7bc636b]418 <button type="button" class="pat-row"
419 data-pcms-track-url="${tUrl}"
420 data-pcms-album-id="${albumDomId}"
421 data-pcms-track-base="${this.escape(trackBase)}"
422 aria-label="Speel ${tTitleH}">
423 ${leader}
424 <span class="pat-meta">
425 <span class="pat-title">${tTitleH}</span>
426 ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
427 </span>
428 ${durHtml}
429 </button>
430 </li>`;
431 }).join('\n');
432
433 return `<div class="post-album" id="${albumDomId}"
434 data-pcms-album='${albumJson}'
435 data-pcms-album-title="${titleH}"
436 data-pcms-album-kind="${kind}"
437 data-pcms-playlist-id="${this.escape(id)}">
438${isAdmin ? ` <div class="post-album-actions" role="group" aria-label="Playlist beheren">
439 <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk playlist" aria-label="Bewerk playlist">
440 <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>
441 </a>
442 <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">
443 <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>
444 </button>
445 </div>
446` : ''} <div class="post-album-header">
447 <button type="button" class="post-album-cover-btn"
448 data-pcms-track-url="${firstUrl}"
449 data-pcms-album-id="${albumDomId}"
450 aria-label="Speel ${kind === 'playlist' ? 'playlist' : 'album'}">
451 ${coverH
452 ? `<span class="post-album-cover" style="background-image:url('${coverH}')"></span>`
453 : `<span class="post-album-cover post-album-cover-empty">
454 <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>
455 </span>`}
456 <span class="post-album-cover-play" aria-hidden="true">
457 <svg viewBox="0 0 24 24"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>
458 </span>
459 </button>
460 <div class="post-album-info">
461 <p class="post-album-label">${kindLabel}</p>
462 <h3 class="post-album-title">${titleH}</h3>
463 ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
464 <p class="post-album-meta">${metaLine}</p>
465 </div>
466 </div>
467 <ol class="post-album-tracks post-album-tracks-compact" data-album-kind="${kind}">
468${trackItems}
469 </ol>
470</div>`;
471 });
472 }
473
474 /**
475 * Human-readable label for a provider slug. Used by external-link buttons.
476 */
477 static platformLabel(provider) {
478 return ({
479 spotify: 'Spotify',
480 bandcamp: 'Bandcamp',
481 soundcloud: 'SoundCloud',
482 applemusic: 'Apple Music',
483 youtube: 'YouTube',
484 vimeo: 'Vimeo',
485 tidal: 'Tidal',
486 deezer: 'Deezer',
487 mixcloud: 'Mixcloud',
488 })[provider] || 'External link';
489 }
490
491 /**
492 * Detect platform from URL purely by hostname (covers more services than
493 * detectProvider, which is embed-focused). Used for [[link:url]] rendering.
494 */
495 static detectLinkPlatform(url) {
496 try {
497 const host = new URL(url).hostname.toLowerCase();
498 if (host.includes('open.spotify.com') || host === 'spotify.com') return 'spotify';
499 if (host.includes('bandcamp.com')) return 'bandcamp';
500 if (host.includes('soundcloud.com')) return 'soundcloud';
501 if (host.includes('music.apple.com') || host.includes('itunes.apple.com')) return 'applemusic';
502 if (host.includes('youtube.com') || host.includes('youtu.be') || host.includes('music.youtube.com')) return 'youtube';
503 if (host.includes('vimeo.com')) return 'vimeo';
504 if (host.includes('tidal.com')) return 'tidal';
505 if (host.includes('deezer.com')) return 'deezer';
506 if (host.includes('mixcloud.com')) return 'mixcloud';
507 return 'other';
508 } catch (e) {
509 return null;
510 }
511 }
512
513 /**
514 * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
515 * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
516 * Per Robin's v9: "Externe link, klik = open platform (target _blank)".
517 */
518 static embedExternalLinkShortcodes(html) {
519 if (!html) return html;
520 return html.replace(/\[\[link:([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, rawUrl, customLabel) => {
521 const url = rawUrl.trim();
522 if (!/^https?:\/\//i.test(url)) return match;
523 const platform = this.detectLinkPlatform(url) || 'other';
524 const label = (customLabel || '').trim();
525 const platformLabel = this.platformLabel(platform);
526 const buttonText = label || `Open in ${platformLabel}`;
527 const urlH = this.escape(url);
528 const textH = this.escape(buttonText);
529 return `<a class="post-audio-external post-audio-external--${platform}" href="${urlH}" target="_blank" rel="noopener noreferrer" data-platform="${platform}">
530 <span class="pae-icon" aria-hidden="true">▶</span>
531 <span class="pae-text">${textH}</span>
532 <span class="pae-arrow" aria-hidden="true">↗</span>
533</a>`;
534 });
535 }
536
537 static escape(str) {
538 return str
539 .replace(/&/g, '&amp;')
540 .replace(/</g, '&lt;')
541 .replace(/>/g, '&gt;')
542 .replace(/"/g, '&quot;')
543 .replace(/'/g, '&#39;');
544 }
545}
546
547export default AudioEmbedService;
Note: See TracBrowser for help on using the repository browser.