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

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

Het bandje speelt als bandje: een cassette, een object, spoelen in seconden

Robins correctie (21-8): vooruit en achteruit deden hier next()/prev(), en dat
is een playlistgebaar. Dan is het een lijst met een cassetteplaatje erboven.
Een cassette kent geen "volgend nummer" -- je houdt de knop ingedrukt, de band
loopt door, en je laat los waar je bent.

Dat kan omdat audio-player.js een wachtrij al in EEN doorlopende
MediaSource-tijdlijn giet: "een trackwissel is een positie, geen omschakeling".
Een bandje IS die tijdlijn. Dus:

  • seekBy(seconden) op de speler, over de nummergrenzen heen. Op de blob-motor (iOS, geen MSE) bestaat die tijdlijn niet; daar loopt spoelen tot de rand van het nummer en stapt dan naar de buur. Grover, maar eerlijker dan doen alsof.
  • Bandmodus: de speler toont het BANDJE als titel met het lopende nummer eronder, en de teller loopt over de hele band in plaats van per nummer. Dat is wat "een object, geen losse tracks" betekent op het scherm.
  • Vasthouden versnelt (4x tot 16x), loslaten stopt -- ook als je buiten de knop loslaat. Een korte tik spoelt vijf seconden, want vasthouden is met een spatiebalk geen gebaar.

Een echte cassette, als SVG. Verhouding 100,4 x 63,8 mm, de maat van een
compact cassette, dus viewBox 314x200: behuizing, labelvlak met de titel erop,
venster, twee spoelen met tandjes die meedraaien, de bandpakketten, vier
schroefjes en de openingen voor de kop en de capstans. De vorige versie waren
twee CSS-schijfjes.

Twee dingen die alleen door het echt te draaien boven kwamen:

  • audio-player.js kiest zijn knoppen met een lijst KLASSENAMEN (PLAY_SELECTOR), niet met een regel over data-attributen. De cassetteknop droeg alle juiste data-pcms-* en deed niets. Er staat nu een test op.
  • De sessie sloeg displayTimes().cur op als trackpositie. In bandmodus is dat de bandteller, dus een hersteld bandje sprong naar een plek die in dat nummer niet bestaat. Daarvoor is trackTijd() afgesplitst.

MOD_V 53, style.css v107, audio-player v35.

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

  • Property mode set to 100644
File size: 41.0 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
[8e1af9c]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
[834bcc3]17// "Open in" icons (brand-colored via CSS .pat-link--).
[183875b]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
[7bc636b]24class AudioEmbedService {
[834bcc3]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).
[183875b]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
[7bc636b]42 static detectProvider(url) {
43 if (!url || typeof url !== 'string') return null;
44 url = url.trim();
45
[834bcc3]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.
[4c9f29a]51 if (!/^https?:\/\//i.test(url)) return null;
52
[7bc636b]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);
[4c9f29a]56 return { provider: 'spotify', type: match[1], id: match[2], url };
[7bc636b]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
[995b100]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 }
[7bc636b]105 }
106
107 // Vimeo
108 if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) {
109 const match = url.match(/\d+/);
[4c9f29a]110 return { provider: 'vimeo', id: match[0], url };
[7bc636b]111 }
112
113 return null;
114 }
115
[a85f539]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
[7bc636b]148 static generateIframe(provider, config) {
149 switch (provider) {
[834bcc3]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.
[4c9f29a]153 case 'youtube':
[995b100]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}`));
[4c9f29a]159 case 'soundcloud':
160 return this.embedPlaceholder('soundcloud', config.url, 'track', config.url);
[7bc636b]161 case 'spotify':
[4c9f29a]162 return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
163 config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
[834bcc3]164 // No JS API (Bandcamp/Apple) or low priority (Vimeo): remain as iframes;
165 // mutual exclusion for these runs via the blur fallback.
[7bc636b]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
[4c9f29a]177 /**
[834bcc3]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.
[4c9f29a]181 */
[fef781e]182 static embedPlaceholder(provider, ref, type, url) {
[4c9f29a]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
[7bc636b]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 }) {
[e854ace]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(
[48481cd]254 /music\.apple\.com\/([a-z]{2}\/(album|playlist|song)\/[^/?#]+\/(?:[0-9]+|pl\.[A-Za-z0-9_-]+))/i,
[e854ace]255 );
[7bc636b]256 if (!match) return null;
257 const src = `https://embed.music.apple.com/${match[1]}`;
[48481cd]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;
[7bc636b]268 return `
269 <figure class="folio-embed folio-embed--applemusic">
270 <iframe src="${this.escape(src)}"
[48481cd]271 style="width:100%;height:${hoogte}px;border:0;overflow:hidden;border-radius:8px;"
[7bc636b]272 loading="lazy"
273 allow="autoplay; clipboard-write; encrypted-media"
274 title="Apple Music"></iframe>
275 </figure>
276 `.trim();
277 }
278
[995b100]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 }
[7bc636b]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 }
[a85f539]342 // Bare media file (…/clip.webm, …/song.mp3) → native player.
343 const media = this.mediaFileEmbed(url);
344 if (media) return media;
[7bc636b]345 return match;
346 }
347 );
348 }
349
[1907a18]350 /**
[834bcc3]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.
[1907a18]355 */
356 static embedMediaShortcodes(html) {
357 if (!html) return html;
[fef781e]358 return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawUrl) => {
359 const url = rawUrl.trim().replace(/&amp;/g, '&');
[1907a18]360 const detected = this.detectProvider(url);
361 if (!detected) {
[a85f539]362 // Bare media file (…/clip.webm, …/song.mp3) → native player.
363 const media = this.mediaFileEmbed(url);
364 if (media) return media;
[1907a18]365 return `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
366 }
[e854ace]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>`;
[1907a18]374 });
375 }
376
[7bc636b]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);
[d727e92]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(' · ');
[834bcc3]390 // Link-only track (no audio file): no play button, but info + open-in links.
[d727e92]391 if (!t.url) {
[a66f691]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>`;
[d727e92]396 return `<div class="post-audio-track post-audio-track--static" id="track-${id}">
[a66f691]397 ${leader0}
[d727e92]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 }
[7bc636b]406 const trackJson = JSON.stringify({
[230e446]407 id,
[7bc636b]408 url: t.url,
409 title: t.title || 'Untitled',
410 artist: t.artist || '',
411 cover: t.cover || '',
[0d7acdf]412 credit: t.credit || '',
413 license: t.license || '',
[7bc636b]414 });
415 const titleH = this.escape(t.title || 'Untitled');
416 const artistH = this.escape(t.artist || '');
417 const urlH = this.escape(t.url);
[834bcc3]418 // Visible owner/license line below the track.
[0d7acdf]419 const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
[7bc636b]420 const dataAttr = trackJson
421 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
[834bcc3]422 // id="track-<id>" = anchor so the mini-player can scroll to this element.
[230e446]423 return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
[7bc636b]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>` : ''}
[0d7acdf]430 ${creditBits ? `<div class="pat-credit">${creditBits}</div>` : ''}
[7bc636b]431 </div>
[183875b]432 ${this.openInLinks(t)}
[7bc636b]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);
[834bcc3]453 // Only playable tracks (with url) in the queue; link-only tracks appear
454 // in the list but not in the playback JSON.
[d727e92]455 const albumJson = JSON.stringify(album.tracks.filter((t) => t.url))
[7bc636b]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 || '');
[834bcc3]464 // Link-only track: no play button, but track number + info + open-in links.
[d727e92]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 }
[7bc636b]475 const tUrl = this.escape(t.url);
[359b9ae]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}">
[7bc636b]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>
[183875b]485 ${this.openInLinks(t)}
[7bc636b]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;
[8e1af9c]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 const kind = SOORTEN.includes(pl.kind) ? pl.kind : 'album';
548 const KIND_LABEL = { album: '💿 Album', playlist: '📃 Playlist', mixtape: '📼 Mixtape' };
549 const kindLabel = KIND_LABEL[kind] || KIND_LABEL.album;
[7bc636b]550 const titleH = this.escape(pl.title || 'Naamloos');
551 const artistH = this.escape(pl.artist || '');
552 const coverH = pl.cover ? this.escape(pl.cover) : '';
553
554 // Audio-player.js reads data-pcms-album for queue. Same shape as
555 // embedAlbumShortcodes — keep both in sync.
[834bcc3]556 // Only playable tracks in the queue; link-only tracks appear in the list
557 // but not in the playback JSON.
[d727e92]558 const tracksData = pl.tracks.filter(t => t.url).map(t => ({
[ddf549f]559 id: t.id,
[7bc636b]560 url: t.url,
561 title: t.title,
562 artist: t.artist || pl.artist || '',
563 cover: t.cover || pl.cover || '',
564 }));
565 const albumJson = JSON.stringify(tracksData)
566 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
567
568 // Total duration for the meta line
569 const totalSec = pl.tracks.reduce((s, t) => s + (t.duration || 0), 0);
570 const metaParts = [];
571 if (pl.year) metaParts.push(String(pl.year));
572 metaParts.push(pl.tracks.length + (pl.tracks.length === 1 ? ' track' : ' tracks'));
573 if (totalSec > 0) {
574 const h = Math.floor(totalSec / 3600);
575 const m = Math.floor((totalSec % 3600) / 60);
576 if (h > 0) metaParts.push(`${h}h ${m}m`);
577 else metaParts.push(`${Math.max(1, m)} min`);
578 }
579 const metaLine = this.escape(metaParts.join(' · '));
[d727e92]580 const firstUrl = this.escape((pl.tracks.find(t => t.url) || {}).url || '');
[7bc636b]581
582 // Track items — playlist-kind shows per-track cover thumbs, album-kind shows numbers
583 const trackItems = pl.tracks.map((t, i) => {
584 const tTitleH = this.escape(t.title || ('Track ' + (i + 1)));
585 const tArtistH = this.escape(t.artist || '');
586 const tUrl = this.escape(t.url);
587 const showArtist = tArtistH && tArtistH !== artistH;
588
589 // Duration cell — render even when 0 for consistent column layout
590 const durHtml = t.duration > 0
591 ? `<span class="pat-duration">${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}</span>`
592 : `<span class="pat-duration pat-duration-empty">—:—</span>`;
593
594 // Leader cell — number for albums, cover thumb for playlists
595 const leader = (kind === 'playlist' && t.cover)
596 ? `<span class="pat-cover" style="background-image:url(${this.escape(t.cover)})" aria-hidden="true"></span>`
597 : `<span class="pat-num">${i + 1}</span>`;
598
[834bcc3]599 // Link-only track: no clickable play row (static div), but open-in links.
[d727e92]600 if (!t.url) {
601 return ` <li class="post-album-track-compact post-album-track-compact--static"${t.id ? ` id="track-${t.id}"` : ''}>
602 <div class="pat-row pat-static">
603 ${leader}
604 <span class="pat-meta">
605 <span class="pat-title">${tTitleH}</span>
606 ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
607 </span>
608 ${durHtml}
609 </div>
610 ${this.openInLinks(t)}
611 </li>`;
612 }
[7bc636b]613 const trackBase = String(t.url).split('?')[0];
[359b9ae]614 return ` <li class="post-album-track-compact"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''}>
[7bc636b]615 <button type="button" class="pat-row"
616 data-pcms-track-url="${tUrl}"
617 data-pcms-album-id="${albumDomId}"
618 data-pcms-track-base="${this.escape(trackBase)}"
619 aria-label="Speel ${tTitleH}">
620 ${leader}
621 <span class="pat-meta">
622 <span class="pat-title">${tTitleH}</span>
623 ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
624 </span>
625 ${durHtml}
626 </button>
[183875b]627 ${this.openInLinks(t)}
[7bc636b]628 </li>`;
629 }).join('\n');
630
[16fd4fe]631 // HET BANDJE HEEFT ZIJN EIGEN VORM. Een album toont een genummerde lijst
632 // waar je in kunt prikken; een cassette is juist het tegenovergestelde --
633 // je hoort wat er komt, in de volgorde waarin het is opgenomen. Alles
634 // hierboven (de wachtrij, de metaregel, de duur) is gedeeld; alleen de
635 // opmaak splitst hier.
636 if (kind === 'mixtape') {
637 return this.renderTape({
638 domId: albumDomId, id, titleH, artistH, coverH, metaLine, firstUrl,
639 albumJson, tracks: pl.tracks, isAdmin,
640 });
641 }
642
[7bc636b]643 return `<div class="post-album" id="${albumDomId}"
644 data-pcms-album='${albumJson}'
645 data-pcms-album-title="${titleH}"
646 data-pcms-album-kind="${kind}"
647 data-pcms-playlist-id="${this.escape(id)}">
648${isAdmin ? ` <div class="post-album-actions" role="group" aria-label="Playlist beheren">
649 <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk playlist" aria-label="Bewerk playlist">
650 <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>
651 </a>
652 <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">
653 <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>
654 </button>
655 </div>
656` : ''} <div class="post-album-header">
657 <button type="button" class="post-album-cover-btn"
658 data-pcms-track-url="${firstUrl}"
659 data-pcms-album-id="${albumDomId}"
[8e1af9c]660 aria-label="Speel ${kind}">
[7bc636b]661 ${coverH
662 ? `<span class="post-album-cover" style="background-image:url('${coverH}')"></span>`
663 : `<span class="post-album-cover post-album-cover-empty">
664 <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>
665 </span>`}
666 <span class="post-album-cover-play" aria-hidden="true">
667 <svg viewBox="0 0 24 24"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>
668 </span>
669 </button>
670 <div class="post-album-info">
671 <p class="post-album-label">${kindLabel}</p>
672 <h3 class="post-album-title">${titleH}</h3>
673 ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
674 <p class="post-album-meta">${metaLine}</p>
675 </div>
676 </div>
677 <ol class="post-album-tracks post-album-tracks-compact" data-album-kind="${kind}">
678${trackItems}
679 </ol>
680</div>`;
681 });
682 }
683
[16fd4fe]684 /**
685 * Het bandje (Robins idee, 21-8).
686 *
687 * WAT HET ANDERS MAAKT DAN EEN ALBUM, en dat is de hele reden dat dit een
688 * eigen vorm heeft: bij een album prik je in een genummerde lijst en spring
689 * je naar nummer zeven. Op een cassette kan dat niet. Je spoelt vooruit of
690 * terug, en wat er komt hoor je in de volgorde waarin het is opgenomen. De
691 * lijst staat er dus wel -- je mag zien wat erop staat -- maar hij is geen
692 * knoppenrij.
693 *
694 * DE SPELER IS DE BESTAANDE SPELER. De knop hieronder draagt exact dezelfde
695 * data-attributen als de albumhoes (data-pcms-track-url + data-pcms-album-id),
696 * dus audio-player.js pakt hem op zonder dat hier iets nieuws bij komt. Vooruit
697 * en terug lopen via window.pcmsAudioPlayer.next()/prev(), en dat is meteen de
698 * reden dat spoelen per NUMMER gaat en niet per seconde: die speler denkt in
699 * een wachtrij, en een tweede speler ernaast bouwen om een band na te doen zou
700 * twee dingen tegelijk laten afspelen.
701 */
702 static renderTape({ domId, id, titleH, artistH, coverH, metaLine, firstUrl, albumJson, tracks, isAdmin }) {
703 // De nummers als tekst, niet als knoppen. Bewust geen data-pcms-track-url:
704 // een aanklikbaar nummer is precies wat een bandje niet heeft.
705 const lijst = tracks.map((t, i) => {
706 const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
707 const dur = t.duration > 0
708 ? `${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}`
709 : '—:—';
710 return ` <li class="tape-track" data-tape-index="${i}"><span class="tape-track-title">${tTitle}</span><span class="tape-track-dur">${dur}</span></li>`;
711 }).join('\n');
712
713 const spoel = (richting, label, pad) => ` <button type="button" class="tape-btn tape-btn--${richting}" data-tape-go="${richting}" aria-label="${label}">
714 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">${pad}</svg>
715 </button>`;
716
717 return `<div class="post-tape" id="${domId}"
718 data-pcms-album='${albumJson}'
719 data-pcms-album-title="${titleH}"
720 data-pcms-album-kind="mixtape"
721 data-pcms-playlist-id="${this.escape(id)}">
722${isAdmin ? ` <div class="post-album-actions" role="group" aria-label="Mixtape beheren">
723 <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk mixtape" aria-label="Bewerk mixtape">
724 <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>
725 </a>
726 </div>
727` : ''} <div class="tape-shell">
[e679bce]728 <svg class="tape-svg" viewBox="0 0 314 200" role="img" aria-label="Cassette" preserveAspectRatio="xMidYMid meet">
729 <defs>
730 <clipPath id="tapewin-${domId}"><rect x="72" y="96" width="170" height="62" rx="6"/></clipPath>
731 </defs>
732 <!-- de behuizing -->
733 <rect class="tape-body" x="3" y="3" width="308" height="194" rx="9"/>
734 <rect class="tape-body-inner" x="12" y="12" width="290" height="176" rx="6"/>
735 <!-- het labelvlak: hier komt de HTML-tekst overheen te staan -->
736 <rect class="tape-labelplate" x="24" y="22" width="266" height="62" rx="3"/>
737 <!-- het venster waardoor je de band ziet lopen -->
738 <rect class="tape-glass" x="72" y="96" width="170" height="62" rx="6"/>
739 <g clip-path="url(#tapewin-${domId})">
740 <!-- de bandpakketten om de spoelen; links loopt vol terwijl rechts leegloopt -->
741 <circle class="tape-pack tape-pack--left" cx="112" cy="127" r="30"/>
742 <circle class="tape-pack tape-pack--right" cx="202" cy="127" r="22"/>
743 <rect class="tape-ribbon" x="112" y="150" width="90" height="3"/>
744 </g>
745 <!-- de spoelen zelf: deze twee draaien -->
746 <g class="tape-reel tape-reel--left" style="transform-origin:112px 127px">
747 <circle class="tape-hub" cx="112" cy="127" r="15"/>
748 ${[0, 60, 120, 180, 240, 300].map((a) => `<rect class="tape-tooth" x="109.5" y="112" width="5" height="9" rx="1" transform="rotate(${a} 112 127)"/>`).join('')}
749 </g>
750 <g class="tape-reel tape-reel--right" style="transform-origin:202px 127px">
751 <circle class="tape-hub" cx="202" cy="127" r="15"/>
752 ${[0, 60, 120, 180, 240, 300].map((a) => `<rect class="tape-tooth" x="199.5" y="112" width="5" height="9" rx="1" transform="rotate(${a} 202 127)"/>`).join('')}
753 </g>
754 <!-- de schroefjes in de hoeken, en de openingen voor de kop onderin -->
755 <circle class="tape-screw" cx="22" cy="22" r="3.5"/>
756 <circle class="tape-screw" cx="292" cy="22" r="3.5"/>
757 <circle class="tape-screw" cx="22" cy="178" r="3.5"/>
758 <circle class="tape-screw" cx="292" cy="178" r="3.5"/>
759 <rect class="tape-slot" x="128" y="168" width="26" height="14" rx="2"/>
760 <rect class="tape-slot" x="160" y="168" width="26" height="14" rx="2"/>
761 <rect class="tape-slot tape-slot--capstan" x="98" y="170" width="10" height="12" rx="2"/>
762 <rect class="tape-slot tape-slot--capstan" x="206" y="170" width="10" height="12" rx="2"/>
763 </svg>
764 <div class="tape-label">
765 <p class="tape-kind">Mixtape</p>
[16fd4fe]766 <h3 class="tape-title">${titleH}</h3>
767 ${artistH ? `<p class="tape-artist">${artistH}</p>` : ''}
768 <p class="tape-meta">${metaLine}</p>
769 </div>
770 </div>
771 <div class="tape-controls" role="group" aria-label="Bandje bedienen">
772${spoel('back', 'Terugspoelen', '<path d="M11 12l9-7v14zM2 12l9-7v14z"/>')}
773 <button type="button" class="tape-btn tape-btn--play"
774 data-pcms-track-url="${firstUrl}"
775 data-pcms-album-id="${domId}"
776 data-tape-play
777 aria-label="Afspelen">
778 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
779 </button>
780${spoel('fwd', 'Vooruitspoelen', '<path d="M13 12L4 5v14zM22 12l-9-7v14z"/>')}
781 </div>
782 <p class="tape-now" data-tape-now aria-live="polite"></p>
783 <ol class="tape-tracks">
784${lijst}
785 </ol>
786</div>`;
787 }
788
[7bc636b]789 /**
790 * Human-readable label for a provider slug. Used by external-link buttons.
791 */
792 static platformLabel(provider) {
793 return ({
794 spotify: 'Spotify',
795 bandcamp: 'Bandcamp',
796 soundcloud: 'SoundCloud',
797 applemusic: 'Apple Music',
798 youtube: 'YouTube',
799 vimeo: 'Vimeo',
800 tidal: 'Tidal',
801 deezer: 'Deezer',
802 mixcloud: 'Mixcloud',
803 })[provider] || 'External link';
804 }
805
806 /**
807 * Detect platform from URL purely by hostname (covers more services than
808 * detectProvider, which is embed-focused). Used for [[link:url]] rendering.
809 */
810 static detectLinkPlatform(url) {
811 try {
812 const host = new URL(url).hostname.toLowerCase();
813 if (host.includes('open.spotify.com') || host === 'spotify.com') return 'spotify';
814 if (host.includes('bandcamp.com')) return 'bandcamp';
815 if (host.includes('soundcloud.com')) return 'soundcloud';
816 if (host.includes('music.apple.com') || host.includes('itunes.apple.com')) return 'applemusic';
817 if (host.includes('youtube.com') || host.includes('youtu.be') || host.includes('music.youtube.com')) return 'youtube';
818 if (host.includes('vimeo.com')) return 'vimeo';
819 if (host.includes('tidal.com')) return 'tidal';
820 if (host.includes('deezer.com')) return 'deezer';
821 if (host.includes('mixcloud.com')) return 'mixcloud';
822 return 'other';
823 } catch (e) {
824 return null;
825 }
826 }
827
828 /**
829 * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
830 * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
[834bcc3]831 * Per Robin's v9: "External link, click = open platform (target _blank)".
[7bc636b]832 */
833 static embedExternalLinkShortcodes(html) {
834 if (!html) return html;
835 return html.replace(/\[\[link:([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, rawUrl, customLabel) => {
836 const url = rawUrl.trim();
837 if (!/^https?:\/\//i.test(url)) return match;
838 const platform = this.detectLinkPlatform(url) || 'other';
839 const label = (customLabel || '').trim();
840 const platformLabel = this.platformLabel(platform);
841 const buttonText = label || `Open in ${platformLabel}`;
842 const urlH = this.escape(url);
843 const textH = this.escape(buttonText);
844 return `<a class="post-audio-external post-audio-external--${platform}" href="${urlH}" target="_blank" rel="noopener noreferrer" data-platform="${platform}">
845 <span class="pae-icon" aria-hidden="true">▶</span>
846 <span class="pae-text">${textH}</span>
847 <span class="pae-arrow" aria-hidden="true">↗</span>
848</a>`;
849 });
850 }
851
852 static escape(str) {
853 return str
854 .replace(/&/g, '&amp;')
855 .replace(/</g, '&lt;')
856 .replace(/>/g, '&gt;')
857 .replace(/"/g, '&quot;')
858 .replace(/'/g, '&#39;');
859 }
860}
861
862export default AudioEmbedService;
Note: See TracBrowser for help on using the repository browser.