source: Klonkt/src/services/AudioEmbedService.js@ 5e72413

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

Speler-UI in bandmodus: spoelknoppen, eerlijke teller, lijst zonder keuze (shaer-tmyn)

Speelt er een bandje, dan hoort de speler onderin zich als een cassettedeck te
gedragen en niet als een playlist-speler. Drie van de vier stappen uit de bead;
de vierde (seek over de hele tijdlijn) staat er los van, want die raakt
loadTrack en de keten.

  • De tracklijst blijft staan maar is geen keuzelijst: renderQueue hangt in bandmodus geen klik meer op de items, en een CSS-klasse haalt de belofte weg dat het kan. Op een cassette spring je niet naar nummer zeven.
  • Vorige/volgende worden terugspoelen/vooruitspoelen: ander teken, ander woord, en vasthouden spoelt door. Een korte klik spoelt vijf seconden, voor toetsenbord en schermlezer.
  • De spoellus is VERHUISD naar de speler. Hij stond in mod/tape.js, en met spoelknoppen op twee plekken zouden twee kopieen van dezelfde versnelling uit elkaar lopen zodra iemand aan een getal draait. De cassette in de post bedient nu dezelfde lus en tekent alleen nog het jasje.

En de teller is eerlijk geworden. Die kwam uit chain[last].end, dus uit de
lengte van wat toevallig gebufferd was: 2:05 met een nummer geladen, 4:07 met
drie, terwijl de band 6:13 duurt. Erger nog, sprong je naar nummer drie dan
begon de keten daar opnieuw op nul en stond de teller weer vooraan. Nu telt hij
bandOffset(nummer) + positie in dat nummer, uit de trackduren -- die daarvoor
in de wachtrij-JSON moesten. Ontbreekt er een duur, dan valt hij terug: een som
met gaten is een verzonnen getal.

Nagemeten op dev: totaal 6:13 meteen bij de eerste seconde (klopt met de
PT373S op het AP-object), vier knoppen op Terug-/Vooruitspoelen, drie items in
de lijst met pointer-events none en het lopende nummer gemarkeerd, en spoelen
via de spelerknop loopt door de band heen.

audio-player v38, MOD_V 55, style.css v108.

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

  • Property mode set to 100644
File size: 41.3 KB
Line 
1/**
2 * AudioEmbedService — Parse URLs and return embed HTML
3 * Supports: Spotify, Bandcamp, SoundCloud, Apple Music, YouTube, Vimeo
4 *
5 * Usage in post content:
6 * <p>https://open.spotify.com/track/123abc</p>
7 * →
8 * <figure class="folio-embed folio-embed--spotify">
9 * <iframe src="..."></iframe>
10 * </figure>
11 */
12
13// Dezelfde lijst soorten als de server en de editor gebruiken. Zie
14// assets/js/shared/post-music-type.js: die module is puur, dus hij mag hier.
15import { SOORTEN } from '../assets/js/shared/post-music-type.js';
16
17// "Open in" icons (brand-colored via CSS .pat-link--).
18const OPEN_IN_SVG = {
19 spotify: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm4.6 14.42a.62.62 0 01-.86.21c-2.35-1.44-5.3-1.76-8.79-.96a.62.62 0 11-.28-1.21c3.8-.87 7.07-.5 9.71 1.11.3.18.39.57.22.85zm1.23-2.73a.78.78 0 01-1.07.26c-2.69-1.66-6.79-2.14-9.97-1.17a.78.78 0 11-.45-1.49c3.63-1.1 8.15-.56 11.24 1.33.36.22.48.7.25 1.07zm.1-2.85C14.66 8.95 9.4 8.78 6.3 9.72a.93.93 0 11-.54-1.79c3.56-1.08 9.37-.87 13.07 1.33a.94.94 0 01-.96 1.61z"/></svg>',
20 youtube: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 7.1a3 3 0 00-2.1-2.12C19.04 4.5 12 4.5 12 4.5s-7.04 0-8.9.48A3 3 0 001 7.1 31.2 31.2 0 00.5 12 31.2 31.2 0 001 16.9a3 3 0 002.1 2.12c1.86.48 8.9.48 8.9.48s7.04 0 8.9-.48A3 3 0 0023 16.9 31.2 31.2 0 0023.5 12 31.2 31.2 0 0023 7.1zM9.75 15.5v-7l6 3.5-6 3.5z"/></svg>',
21 soundcloud: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 14v4M7.5 11v7M11 9v9"/><path d="M14.5 9.5V18h4a3 3 0 100-6 4 4 0 00-4-2.5z"/></svg>',
22};
23
24class AudioEmbedService {
25 // Small "open in" links for a track (Spotify/YouTube/SoundCloud). The hrefs
26 // are already validated server-side (https + correct host only). Returns ''
27 // when no links exist. Placed next to the play button (outside the button →
28 // no conflict with playback).
29 static openInLinks(t) {
30 if (!t) return '';
31 const out = [];
32 const add = (url, key, label) => {
33 if (!url) return;
34 out.push(`<a class="pat-link pat-link--${key}" href="${this.escape(url)}" target="_blank" rel="noopener noreferrer" title="Open in ${label}" aria-label="Open in ${label}">${OPEN_IN_SVG[key]}</a>`);
35 };
36 add(t.link_spotify, 'spotify', 'Spotify');
37 add(t.link_youtube, 'youtube', 'YouTube');
38 add(t.link_soundcloud, 'soundcloud', 'SoundCloud');
39 return out.length ? `<span class="pat-links">${out.join('')}</span>` : '';
40 }
41
42 static detectProvider(url) {
43 if (!url || typeof url !== 'string') return null;
44 url = url.trim();
45
46 // Only embed http(s) URLs. The provider regexes below are NOT anchored,
47 // so without this check e.g. `javascript:alert(1)//youtu.be/x` would match
48 // and land as an embed URL (stored XSS via an [[embed:...]] shortcode —
49 // that text never passes through the HTML sanitizer because it lives in a
50 // text node). The scheme guard excludes javascript:/data:/vbscript: etc.
51 if (!/^https?:\/\//i.test(url)) return null;
52
53 // Spotify
54 if (/open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i.test(url)) {
55 const match = url.match(/\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i);
56 return { provider: 'spotify', type: match[1], id: match[2], url };
57 }
58
59 // Bandcamp
60 if (/bandcamp\.com\/(track|album)/i.test(url)) {
61 return { provider: 'bandcamp', url };
62 }
63
64 // SoundCloud
65 if (/soundcloud\.com/i.test(url)) {
66 return { provider: 'soundcloud', url };
67 }
68
69 // Apple Music
70 if (/music\.apple\.com\/([a-z]{2})\/(?:album|playlist|song)\//i.test(url)) {
71 return { provider: 'applemusic', url };
72 }
73
74 // YouTube — a video id is always exactly 11 characters (aligns with the
75 // client-side ytId() in embed-player.js, which also expects {11}).
76 //
77 // A link may carry a video, a playlist, or both, and until now we kept only
78 // the video and threw `list=` away -- so a link to an album played its first
79 // song and stopped. The ref now keeps whichever is there, in the same three
80 // shapes the Klonkt hub uses, so one ref travels between the two unchanged:
81 //
82 // "<video>" one video
83 // "<video>?list=<L>" that video, and on through the list
84 // "list:<L>" the whole playlist (YouTube's `videoseries`)
85 //
86 // `list` may sit before or after `v=` and is often entity-encoded (&amp;)
87 // in a baked href, hence the scan over the whole URL rather than a fixed
88 // order. A list id is 10-60 chars: longer and looser than a video id.
89 if (/(?:youtube(?:-nocookie)?\.com\/(?:watch\?|playlist\?|embed\/|shorts\/|live\/)|youtu\.be\/)/i.test(url)) {
90 const vm = url.match(/(?:[?&](?:amp;)?v=|youtu\.be\/|\/embed\/|\/shorts\/|\/live\/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])/i);
91 const lm = url.match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/i);
92 // `videoseries` is a marker, not a video: a bare playlist embed URL reads
93 // /embed/videoseries?list=..., and taking that for an id gives a dead
94 // frame. It is EXACTLY eleven characters, so no length rule catches it --
95 // it has to be named. (Measured, not assumed: it slipped through a
96 // boundary check that looked like it covered this.)
97 const id = vm && vm[1] !== 'videoseries' ? vm[1] : null;
98 const list = lm ? lm[1] : null;
99 if (id || list) {
100 const ref = id ? (list ? `${id}?list=${list}` : id) : `list:${list}`;
101 // `id` stays exactly what it was for every caller that only wants a
102 // video; `list` and `ref` are additions.
103 return { provider: 'youtube', id, list, ref, url };
104 }
105 }
106
107 // Vimeo
108 if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) {
109 const match = url.match(/\d+/);
110 return { provider: 'vimeo', id: match[0], url };
111 }
112
113 return null;
114 }
115
116 // Direct media files (video/audio) hosted anywhere → a native <video>/<audio>
117 // player. Kept OUT of detectProvider() on purpose: the timeline/cover callers
118 // switch on provider slugs (youtube/spotify/…) and a bare file has none, so
119 // overloading detectProvider would suppress e.g. a PeerTube fallback. Only
120 // autoembed() and [[embed:…]] use this.
121 static MEDIA_FILE_EXT = {
122 video: ['mp4', 'webm', 'm4v', 'mov', 'ogv'],
123 audio: ['mp3', 'ogg', 'oga', 'wav', 'm4a', 'flac', 'opus', 'aac'],
124 };
125
126 static detectMediaFile(url) {
127 if (!url || typeof url !== 'string') return null;
128 if (!/^https?:\/\//i.test(url)) return null;
129 let pathname;
130 try { pathname = new URL(url).pathname.toLowerCase(); } catch { return null; }
131 const ext = (pathname.match(/\.([a-z0-9]+)$/) || [])[1];
132 if (!ext) return null;
133 if (this.MEDIA_FILE_EXT.video.includes(ext)) return { kind: 'video', url };
134 if (this.MEDIA_FILE_EXT.audio.includes(ext)) return { kind: 'audio', url };
135 return null;
136 }
137
138 static mediaFileEmbed(url) {
139 const m = this.detectMediaFile(url);
140 if (!m) return null;
141 const src = this.escape(m.url);
142 if (m.kind === 'video') {
143 return `<figure class="folio-embed folio-embed--video"><video src="${src}" controls preload="metadata" playsinline></video></figure>`;
144 }
145 return `<figure class="folio-embed folio-embed--audio"><audio src="${src}" controls preload="metadata"></audio></figure>`;
146 }
147
148 static generateIframe(provider, config) {
149 switch (provider) {
150 // Custom players (client-side via embed-player.js + the real platform APIs).
151 // We render a placeholder with data attributes instead of the bare platform
152 // iframe, so the embed appears in OUR brand style.
153 case 'youtube':
154 // The ref carries the list when there is one; `id` alone would drop it
155 // and play a single song out of an album.
156 return this.embedPlaceholder('youtube', config.ref || config.id, 'video',
157 config.url || (config.id ? `https://youtu.be/${config.id}`
158 : `https://www.youtube.com/playlist?list=${config.list}`));
159 case 'soundcloud':
160 return this.embedPlaceholder('soundcloud', config.url, 'track', config.url);
161 case 'spotify':
162 return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
163 config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
164 // No JS API (Bandcamp/Apple) or low priority (Vimeo): remain as iframes;
165 // mutual exclusion for these runs via the blur fallback.
166 case 'bandcamp':
167 return this.bandcampIframe(config);
168 case 'applemusic':
169 return this.applemusicIframe(config);
170 case 'vimeo':
171 return this.vimeoIframe(config);
172 default:
173 return null;
174 }
175 }
176
177 /**
178 * Placeholder for a custom player. embed-player.js picks up
179 * .folio-embed[data-embed-provider] and builds the card + player client-side.
180 * ALL values go through escape() — post.content_html is executed unescaped.
181 */
182 static embedPlaceholder(provider, ref, type, url) {
183 const attrs = [
184 `data-embed-provider="${this.escape(provider)}"`,
185 `data-embed-ref="${this.escape(ref)}"`,
186 type ? `data-embed-type="${this.escape(type)}"` : '',
187 `data-embed-url="${this.escape(url)}"`,
188 ].filter(Boolean).join(' ');
189 return `<div class="folio-embed folio-embed--${this.escape(provider)} pcms-embed pcms-embed-card pcms-embed-loading" ${attrs}></div>`;
190 }
191
192 static spotifyIframe({ type, id }) {
193 const src = `https://open.spotify.com/embed/${type}/${id}`;
194 return `
195 <figure class="folio-embed folio-embed--spotify">
196 <iframe src="${this.escape(src)}"
197 style="width:100%;height:152px;border:0;"
198 loading="lazy"
199 allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
200 title="Spotify ${type}"></iframe>
201 </figure>
202 `.trim();
203 }
204
205 static bandcampIframe({ url }) {
206 const encodedUrl = encodeURIComponent(url);
207 const src = `https://bandcamp.com/EmbeddedPlayer/url=${encodedUrl}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/`;
208 return `
209 <figure class="folio-embed folio-embed--bandcamp">
210 <iframe src="${this.escape(src)}"
211 style="width:100%;height:470px;border:0;"
212 loading="lazy"
213 allow="encrypted-media"
214 title="Bandcamp player"></iframe>
215 </figure>
216 `.trim();
217 }
218
219 static soundcloudIframe({ url }) {
220 const params = {
221 url: url,
222 color: '#ff5500',
223 auto_play: 'false',
224 hide_related: 'true',
225 show_comments: 'false',
226 show_user: 'true',
227 show_reposts: 'false',
228 show_teaser: 'false',
229 visual: 'true'
230 };
231 const query = new URLSearchParams(params).toString();
232 const src = `https://w.soundcloud.com/player/?${query}`;
233 return `
234 <figure class="folio-embed folio-embed--soundcloud">
235 <iframe src="${this.escape(src)}"
236 style="width:100%;height:300px;border:0;"
237 loading="lazy"
238 allow="autoplay; clipboard-write; encrypted-media"
239 title="SoundCloud player"></iframe>
240 </figure>
241 `.trim();
242 }
243
244 static applemusicIframe({ url }) {
245 // Een album of nummer heeft een NUMMER als id, een afspeellijst niet: die
246 // heet `pl.u-LdbqzVvI3go5g`. Met alleen [0-9]+ viel elke playlist hier af
247 // en gaf deze functie null -- waarna de shortcode zelf op de pagina kwam.
248 // Barts melding (17-8): het concept "The Mixtape" toonde in preview
249 // letterlijk [[embed:https://music.apple.com/nl/playlist/...]].
250 //
251 // Bewust krap: geen slash, vraagteken of hekje in het id, want wat hier
252 // gevangen wordt gaat rechtstreeks achter https://embed.music.apple.com/ aan.
253 const match = url.match(
254 /music\.apple\.com\/([a-z]{2}\/(album|playlist|song)\/[^/?#]+\/(?:[0-9]+|pl\.[A-Za-z0-9_-]+))/i,
255 );
256 if (!match) return null;
257 const src = `https://embed.music.apple.com/${match[1]}`;
258 // De hoogte hangt af van WAT je insluit, en dat stond hier op een vaste
259 // 175px -- de maat van een LOS NUMMER. Een album of afspeellijst is 450px,
260 // dus daarvan zag je ongeveer een derde, met `overflow:hidden` eroverheen
261 // zodat de rest ook niet te bereiken viel. Barts melding (20-8) over
262 // boiert.eu/the-mixtape.
263 //
264 // Nagemeten en niet overgenomen: de embed-pagina van die lijst
265 // (pl.u-LdbqzVvI3go5g) geeft zijn <main> EN zijn <body> allebei precies
266 // 450px. Dat is ook de hoogte in Apple's eigen insluitcode.
267 const hoogte = String(match[2]).toLowerCase() === 'song' ? 175 : 450;
268 return `
269 <figure class="folio-embed folio-embed--applemusic">
270 <iframe src="${this.escape(src)}"
271 style="width:100%;height:${hoogte}px;border:0;overflow:hidden;border-radius:8px;"
272 loading="lazy"
273 allow="autoplay; clipboard-write; encrypted-media"
274 title="Apple Music"></iframe>
275 </figure>
276 `.trim();
277 }
278
279 /**
280 * The plain provider iframe. Takes the same ref shapes as the placeholder:
281 * "<video>", "<video>?list=<L>" and "list:<L>" -- a bare playlist embeds as
282 * `videoseries`. Kept in step with the placeholder path on purpose: this is
283 * the fallback, and a fallback that silently drops the playlist is the worst
284 * kind, because it looks like it worked.
285 */
286 static youtubeIframe({ id, ref }) {
287 const r = ref || id || '';
288 const base = 'https://www.youtube-nocookie.com/embed/';
289 let src;
290 if (r.startsWith('list:')) {
291 src = `${base}videoseries?list=${encodeURIComponent(r.slice(5))}`;
292 } else if (r.includes('?list=')) {
293 const [v, l] = r.split('?list=');
294 src = `${base}${encodeURIComponent(v)}?list=${encodeURIComponent(l)}`;
295 } else {
296 src = base + encodeURIComponent(r);
297 }
298 return `
299 <figure class="folio-embed folio-embed--youtube">
300 <iframe src="${this.escape(src)}"
301 style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
302 loading="lazy"
303 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
304 allowfullscreen
305 title="YouTube video"></iframe>
306 </figure>
307 `.trim();
308 }
309
310 static vimeoIframe({ id }) {
311 const src = `https://player.vimeo.com/video/${id}`;
312 return `
313 <figure class="folio-embed folio-embed--vimeo">
314 <iframe src="${this.escape(src)}"
315 style="aspect-ratio:16/9;width:100%;height:auto;border:0;"
316 loading="lazy"
317 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
318 allowfullscreen
319 title="Vimeo video"></iframe>
320 </figure>
321 `.trim();
322 }
323
324 /**
325 * Auto-embed: Scan paragraphs containing only a URL.
326 * Handles two markdown-rendered shapes:
327 * <p>https://url</p> (bare URL — when GFM auto-link is off)
328 * <p><a href="https://url">https://url</a></p> (marked GFM auto-link — what we get)
329 * Either way → <figure class="folio-embed">...
330 */
331 static autoembed(html) {
332 if (!html) return html;
333 return html.replace(
334 /<p>\s*(?:<a\b[^>]*?\shref="([^"]+)"[^>]*>[^<]*<\/a>|(https?:\/\/[^\s<>"']+))\s*<\/p>/gi,
335 (match, hrefUrl, bareUrl) => {
336 const url = hrefUrl || bareUrl;
337 const detected = this.detectProvider(url);
338 if (detected) {
339 const iframe = this.generateIframe(detected.provider, detected);
340 return iframe || match;
341 }
342 // Bare media file (…/clip.webm, …/song.mp3) → native player.
343 const media = this.mediaFileEmbed(url);
344 if (media) return media;
345 return match;
346 }
347 );
348 }
349
350 /**
351 * Replace [[embed:<url>]] shortcodes with the platform iframe (YouTube, Spotify,
352 * SoundCloud, Apple Music, Bandcamp, Vimeo). The editor button inserts this
353 * shortcode; bare URL lines also embed automatically via autoembed().
354 * Unsupported/invalid URLs get a clean inline notice.
355 */
356 static embedMediaShortcodes(html) {
357 if (!html) return html;
358 return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawUrl) => {
359 const url = rawUrl.trim().replace(/&amp;/g, '&');
360 const detected = this.detectProvider(url);
361 if (!detected) {
362 // Bare media file (…/clip.webm, …/song.mp3) → native player.
363 const media = this.mediaFileEmbed(url);
364 if (media) return media;
365 return `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
366 }
367 // HERKEND maar niet te bouwen is geen reden om de shortcode zelf te
368 // tonen. Dat deed het wel, en dan leest een bezoeker "[[embed:https://...]]"
369 // op de pagina en denkt hij dat er iets stuk is. Onherkend gaf hierboven
370 // al een nette melding; herkend-maar-mislukt hoort dezelfde te geven,
371 // want voor de lezer is het hetzelfde geval.
372 return this.generateIframe(detected.provider, detected)
373 || `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
374 });
375 }
376
377 /**
378 * Replace [[track:<id>]] shortcodes with v9-style player markup.
379 * Caller passes a lookup function (id) -> { id, title, artist, url, cover }
380 * where url is already a signed /audio/stream/... URL. Unknown ids → left as-is.
381 */
382 static embedTrackShortcodes(html, trackLookup) {
383 if (!html || typeof trackLookup !== 'function') return html;
384 return html.replace(/\[\[track:([A-Za-z0-9_-]+)\]\]/g, (match, id) => {
385 const t = trackLookup(id);
386 if (!t) return match;
387 const titleH0 = this.escape(t.title || 'Untitled');
388 const artistH0 = this.escape(t.artist || '');
389 const creditBits0 = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
390 // Link-only track (no audio file): no play button, but info + open-in links.
391 if (!t.url) {
392 const coverH0 = this.escape(t.cover || '');
393 const leader0 = coverH0
394 ? `<span class="pat-noplay pat-noplay--cover" style="background-image:url('${coverH0}')" aria-hidden="true"></span>`
395 : `<span class="pat-noplay" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>`;
396 return `<div class="post-audio-track post-audio-track--static" id="track-${id}">
397 ${leader0}
398 <div class="pat-info">
399 <div class="pat-title">${titleH0}</div>
400 ${artistH0 ? `<div class="pat-artist">${artistH0}</div>` : ''}
401 ${creditBits0 ? `<div class="pat-credit">${creditBits0}</div>` : ''}
402 </div>
403 ${this.openInLinks(t)}
404</div>`;
405 }
406 const trackJson = JSON.stringify({
407 id,
408 url: t.url,
409 title: t.title || 'Untitled',
410 artist: t.artist || '',
411 cover: t.cover || '',
412 credit: t.credit || '',
413 license: t.license || '',
414 });
415 const titleH = this.escape(t.title || 'Untitled');
416 const artistH = this.escape(t.artist || '');
417 const urlH = this.escape(t.url);
418 // Visible owner/license line below the track.
419 const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
420 const dataAttr = trackJson
421 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
422 // id="track-<id>" = anchor so the mini-player can scroll to this element.
423 return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
424 <button type="button" class="pat-play" aria-label="Play ${titleH}">
425 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
426 </button>
427 <div class="pat-info">
428 <div class="pat-title">${titleH}</div>
429 ${artistH ? `<div class="pat-artist">${artistH}</div>` : ''}
430 ${creditBits ? `<div class="pat-credit">${creditBits}</div>` : ''}
431 </div>
432 ${this.openInLinks(t)}
433</div>`;
434 });
435 }
436
437 /**
438 * Replace [[album:<name>]] shortcodes with a v9-style album block.
439 * Caller passes a lookup function (name) -> { title, artist, cover, tracks: [{url,title,artist,cover}, ...] }
440 * Tracks must already have signed URLs. Unknown albums → left as-is.
441 * The wrapper carries the full album JSON so audio-player.js can queue it
442 * when any track or the album play button is clicked.
443 */
444 static embedAlbumShortcodes(html, albumLookup) {
445 if (!html || typeof albumLookup !== 'function') return html;
446 return html.replace(/\[\[album:([^\]]+)\]\]/g, (match, rawName) => {
447 const name = rawName.trim();
448 const album = albumLookup(name);
449 if (!album || !album.tracks || !album.tracks.length) return match;
450
451 // Stable DOM id for this rendering — used as data-pcms-album-id on tracks
452 const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10);
453 // Only playable tracks (with url) in the queue; link-only tracks appear
454 // in the list but not in the playback JSON.
455 const albumJson = JSON.stringify(album.tracks.filter((t) => t.url))
456 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
457 const titleH = this.escape(album.title || name);
458 const artistH = this.escape(album.artist || '');
459 const coverH = album.cover ? this.escape(album.cover) : '';
460
461 const trackItems = album.tracks.map((t, i) => {
462 const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
463 const tArtist = this.escape(t.artist || '');
464 // Link-only track: no play button, but track number + info + open-in links.
465 if (!t.url) {
466 return ` <li class="post-audio-track post-audio-track--static"${t.id ? ` id="track-${t.id}"` : ''}>
467 <span class="pat-track-num">${i + 1}.</span>
468 <div class="pat-info">
469 <div class="pat-title">${tTitle}</div>
470 ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
471 </div>
472 ${this.openInLinks(t)}
473 </li>`;
474 }
475 const tUrl = this.escape(t.url);
476 return ` <li class="post-audio-track"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''} data-pcms-track-url="${tUrl}" data-pcms-album-id="${albumDomId}">
477 <button type="button" class="pat-play" aria-label="Play ${tTitle}">
478 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
479 </button>
480 <div class="pat-info">
481 <span class="pat-track-num">${i + 1}.</span>
482 <div class="pat-title">${tTitle}</div>
483 ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
484 </div>
485 ${this.openInLinks(t)}
486 </li>`;
487 }).join('\n');
488
489 return `<div class="post-album" id="${albumDomId}" data-pcms-album='${albumJson}' data-pcms-album-title="${titleH}">
490 <div class="post-album-header">
491 <button type="button" class="post-album-cover-btn" data-pcms-album-id="${albumDomId}" aria-label="Play album ${titleH}">
492 ${coverH
493 ? `<img src="${coverH}" alt="" class="post-album-cover-img">`
494 : `<svg class="post-album-cover-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>`}
495 <span class="post-album-play-overlay" aria-hidden="true">
496 <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 4l12 8-12 8z"/></svg>
497 </span>
498 </button>
499 <div class="post-album-info">
500 <h3 class="post-album-title">${titleH}</h3>
501 ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
502 <p class="post-album-count">${album.tracks.length} track${album.tracks.length === 1 ? '' : 's'}</p>
503 </div>
504 </div>
505 <ol class="post-album-tracks">
506${trackItems}
507 </ol>
508</div>`;
509 });
510 }
511
512 /**
513 * Replace [[playlist:<id>]] shortcodes with a v9-style album block.
514 * Caller passes a lookup function (id) -> hydrated playlist object from
515 * PlaylistService.get(), or null. Unknown playlists render an inline
516 * "niet gevonden" placeholder so the post still validates as HTML.
517 *
518 * Shape returned by lookup:
519 * { id, title, artist, year, cover, kind, tracks: [{url,title,artist,cover,duration}, ...] }
520 *
521 * `kind` is honored:
522 * - 'album' → ordered list with track numbers
523 * - 'playlist' → list with per-track cover thumbnails (mixtape feel)
524 *
525 * opts: { isAdmin: boolean } — when true, an edit/delete action overlay
526 * is rendered top-right of each card. The handlers are wired up in
527 * audio-player.js via event delegation on data-pcms-playlist-delete.
528 */
529 static embedPlaylistShortcodes(html, playlistLookup, opts = {}) {
530 if (!html || typeof playlistLookup !== 'function') return html;
531 const isAdmin = !!opts.isAdmin;
532 return html.replace(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi, (match, rawId) => {
533 const id = rawId.toLowerCase();
534 const pl = playlistLookup(id);
535
536 if (!pl) {
537 return `<div class="post-playlist-missing"><em>Playlist "${this.escape(id)}" niet gevonden.</em></div>`;
538 }
539 if (!pl.tracks || !pl.tracks.length) {
540 return `<div class="post-playlist-empty"><em>Playlist "${this.escape(pl.title)}" heeft geen beschikbare tracks.</em></div>`;
541 }
542
543 const albumDomId = 'album-' + id;
544 // De soort zoals hij is opgeslagen. Stond hier als ternair met twee
545 // uitkomsten, en dan draagt een mixtape het jasje en het woord van een
546 // album -- dezelfde vorm die op vier andere plekken al misging.
547 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;
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.
556 // Only playable tracks in the queue; link-only tracks appear in the list
557 // but not in the playback JSON.
558 const tracksData = pl.tracks.filter(t => t.url).map(t => ({
559 id: t.id,
560 url: t.url,
561 title: t.title,
562 artist: t.artist || pl.artist || '',
563 cover: t.cover || pl.cover || '',
564 // De duur gaat mee omdat een BANDJE een lengte heeft. Zonder dit kan de
565 // speler alleen de gebufferde keten optellen, en dan las de teller
566 // 2:05 met een nummer geladen en 4:07 met drie -- een totaal dat
567 // meegroeit terwijl je luistert.
568 duration: Number(t.duration) || 0,
569 }));
570 const albumJson = JSON.stringify(tracksData)
571 .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
572
573 // Total duration for the meta line
574 const totalSec = pl.tracks.reduce((s, t) => s + (t.duration || 0), 0);
575 const metaParts = [];
576 if (pl.year) metaParts.push(String(pl.year));
577 metaParts.push(pl.tracks.length + (pl.tracks.length === 1 ? ' track' : ' tracks'));
578 if (totalSec > 0) {
579 const h = Math.floor(totalSec / 3600);
580 const m = Math.floor((totalSec % 3600) / 60);
581 if (h > 0) metaParts.push(`${h}h ${m}m`);
582 else metaParts.push(`${Math.max(1, m)} min`);
583 }
584 const metaLine = this.escape(metaParts.join(' · '));
585 const firstUrl = this.escape((pl.tracks.find(t => t.url) || {}).url || '');
586
587 // Track items — playlist-kind shows per-track cover thumbs, album-kind shows numbers
588 const trackItems = pl.tracks.map((t, i) => {
589 const tTitleH = this.escape(t.title || ('Track ' + (i + 1)));
590 const tArtistH = this.escape(t.artist || '');
591 const tUrl = this.escape(t.url);
592 const showArtist = tArtistH && tArtistH !== artistH;
593
594 // Duration cell — render even when 0 for consistent column layout
595 const durHtml = t.duration > 0
596 ? `<span class="pat-duration">${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}</span>`
597 : `<span class="pat-duration pat-duration-empty">—:—</span>`;
598
599 // Leader cell — number for albums, cover thumb for playlists
600 const leader = (kind === 'playlist' && t.cover)
601 ? `<span class="pat-cover" style="background-image:url(${this.escape(t.cover)})" aria-hidden="true"></span>`
602 : `<span class="pat-num">${i + 1}</span>`;
603
604 // Link-only track: no clickable play row (static div), but open-in links.
605 if (!t.url) {
606 return ` <li class="post-album-track-compact post-album-track-compact--static"${t.id ? ` id="track-${t.id}"` : ''}>
607 <div class="pat-row pat-static">
608 ${leader}
609 <span class="pat-meta">
610 <span class="pat-title">${tTitleH}</span>
611 ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
612 </span>
613 ${durHtml}
614 </div>
615 ${this.openInLinks(t)}
616 </li>`;
617 }
618 const trackBase = String(t.url).split('?')[0];
619 return ` <li class="post-album-track-compact"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''}>
620 <button type="button" class="pat-row"
621 data-pcms-track-url="${tUrl}"
622 data-pcms-album-id="${albumDomId}"
623 data-pcms-track-base="${this.escape(trackBase)}"
624 aria-label="Speel ${tTitleH}">
625 ${leader}
626 <span class="pat-meta">
627 <span class="pat-title">${tTitleH}</span>
628 ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
629 </span>
630 ${durHtml}
631 </button>
632 ${this.openInLinks(t)}
633 </li>`;
634 }).join('\n');
635
636 // HET BANDJE HEEFT ZIJN EIGEN VORM. Een album toont een genummerde lijst
637 // waar je in kunt prikken; een cassette is juist het tegenovergestelde --
638 // je hoort wat er komt, in de volgorde waarin het is opgenomen. Alles
639 // hierboven (de wachtrij, de metaregel, de duur) is gedeeld; alleen de
640 // opmaak splitst hier.
641 if (kind === 'mixtape') {
642 return this.renderTape({
643 domId: albumDomId, id, titleH, artistH, coverH, metaLine, firstUrl,
644 albumJson, tracks: pl.tracks, isAdmin,
645 });
646 }
647
648 return `<div class="post-album" id="${albumDomId}"
649 data-pcms-album='${albumJson}'
650 data-pcms-album-title="${titleH}"
651 data-pcms-album-kind="${kind}"
652 data-pcms-playlist-id="${this.escape(id)}">
653${isAdmin ? ` <div class="post-album-actions" role="group" aria-label="Playlist beheren">
654 <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk playlist" aria-label="Bewerk playlist">
655 <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>
656 </a>
657 <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">
658 <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>
659 </button>
660 </div>
661` : ''} <div class="post-album-header">
662 <button type="button" class="post-album-cover-btn"
663 data-pcms-track-url="${firstUrl}"
664 data-pcms-album-id="${albumDomId}"
665 aria-label="Speel ${kind}">
666 ${coverH
667 ? `<span class="post-album-cover" style="background-image:url('${coverH}')"></span>`
668 : `<span class="post-album-cover post-album-cover-empty">
669 <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>
670 </span>`}
671 <span class="post-album-cover-play" aria-hidden="true">
672 <svg viewBox="0 0 24 24"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>
673 </span>
674 </button>
675 <div class="post-album-info">
676 <p class="post-album-label">${kindLabel}</p>
677 <h3 class="post-album-title">${titleH}</h3>
678 ${artistH ? `<p class="post-album-artist">${artistH}</p>` : ''}
679 <p class="post-album-meta">${metaLine}</p>
680 </div>
681 </div>
682 <ol class="post-album-tracks post-album-tracks-compact" data-album-kind="${kind}">
683${trackItems}
684 </ol>
685</div>`;
686 });
687 }
688
689 /**
690 * Het bandje (Robins idee, 21-8).
691 *
692 * WAT HET ANDERS MAAKT DAN EEN ALBUM, en dat is de hele reden dat dit een
693 * eigen vorm heeft: bij een album prik je in een genummerde lijst en spring
694 * je naar nummer zeven. Op een cassette kan dat niet. Je spoelt vooruit of
695 * terug, en wat er komt hoor je in de volgorde waarin het is opgenomen. De
696 * lijst staat er dus wel -- je mag zien wat erop staat -- maar hij is geen
697 * knoppenrij.
698 *
699 * DE SPELER IS DE BESTAANDE SPELER. De knop hieronder draagt exact dezelfde
700 * data-attributen als de albumhoes (data-pcms-track-url + data-pcms-album-id),
701 * dus audio-player.js pakt hem op zonder dat hier iets nieuws bij komt. Vooruit
702 * en terug lopen via window.pcmsAudioPlayer.next()/prev(), en dat is meteen de
703 * reden dat spoelen per NUMMER gaat en niet per seconde: die speler denkt in
704 * een wachtrij, en een tweede speler ernaast bouwen om een band na te doen zou
705 * twee dingen tegelijk laten afspelen.
706 */
707 static renderTape({ domId, id, titleH, artistH, coverH, metaLine, firstUrl, albumJson, tracks, isAdmin }) {
708 // De nummers als tekst, niet als knoppen. Bewust geen data-pcms-track-url:
709 // een aanklikbaar nummer is precies wat een bandje niet heeft.
710 const lijst = tracks.map((t, i) => {
711 const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
712 const dur = t.duration > 0
713 ? `${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}`
714 : '—:—';
715 return ` <li class="tape-track" data-tape-index="${i}"><span class="tape-track-title">${tTitle}</span><span class="tape-track-dur">${dur}</span></li>`;
716 }).join('\n');
717
718 const spoel = (richting, label, pad) => ` <button type="button" class="tape-btn tape-btn--${richting}" data-tape-go="${richting}" aria-label="${label}">
719 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">${pad}</svg>
720 </button>`;
721
722 return `<div class="post-tape" id="${domId}"
723 data-pcms-album='${albumJson}'
724 data-pcms-album-title="${titleH}"
725 data-pcms-album-kind="mixtape"
726 data-pcms-playlist-id="${this.escape(id)}">
727${isAdmin ? ` <div class="post-album-actions" role="group" aria-label="Mixtape beheren">
728 <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk mixtape" aria-label="Bewerk mixtape">
729 <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>
730 </a>
731 </div>
732` : ''} <div class="tape-shell">
733 <svg class="tape-svg" viewBox="0 0 314 200" role="img" aria-label="Cassette" preserveAspectRatio="xMidYMid meet">
734 <defs>
735 <clipPath id="tapewin-${domId}"><rect x="72" y="96" width="170" height="62" rx="6"/></clipPath>
736 </defs>
737 <!-- de behuizing -->
738 <rect class="tape-body" x="3" y="3" width="308" height="194" rx="9"/>
739 <rect class="tape-body-inner" x="12" y="12" width="290" height="176" rx="6"/>
740 <!-- het labelvlak: hier komt de HTML-tekst overheen te staan -->
741 <rect class="tape-labelplate" x="24" y="22" width="266" height="62" rx="3"/>
742 <!-- het venster waardoor je de band ziet lopen -->
743 <rect class="tape-glass" x="72" y="96" width="170" height="62" rx="6"/>
744 <g clip-path="url(#tapewin-${domId})">
745 <!-- de bandpakketten om de spoelen; links loopt vol terwijl rechts leegloopt -->
746 <circle class="tape-pack tape-pack--left" cx="112" cy="127" r="30"/>
747 <circle class="tape-pack tape-pack--right" cx="202" cy="127" r="22"/>
748 <rect class="tape-ribbon" x="112" y="150" width="90" height="3"/>
749 </g>
750 <!-- de spoelen zelf: deze twee draaien -->
751 <g class="tape-reel tape-reel--left" style="transform-origin:112px 127px">
752 <circle class="tape-hub" cx="112" cy="127" r="15"/>
753 ${[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('')}
754 </g>
755 <g class="tape-reel tape-reel--right" style="transform-origin:202px 127px">
756 <circle class="tape-hub" cx="202" cy="127" r="15"/>
757 ${[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('')}
758 </g>
759 <!-- de schroefjes in de hoeken, en de openingen voor de kop onderin -->
760 <circle class="tape-screw" cx="22" cy="22" r="3.5"/>
761 <circle class="tape-screw" cx="292" cy="22" r="3.5"/>
762 <circle class="tape-screw" cx="22" cy="178" r="3.5"/>
763 <circle class="tape-screw" cx="292" cy="178" r="3.5"/>
764 <rect class="tape-slot" x="128" y="168" width="26" height="14" rx="2"/>
765 <rect class="tape-slot" x="160" y="168" width="26" height="14" rx="2"/>
766 <rect class="tape-slot tape-slot--capstan" x="98" y="170" width="10" height="12" rx="2"/>
767 <rect class="tape-slot tape-slot--capstan" x="206" y="170" width="10" height="12" rx="2"/>
768 </svg>
769 <div class="tape-label">
770 <p class="tape-kind">Mixtape</p>
771 <h3 class="tape-title">${titleH}</h3>
772 ${artistH ? `<p class="tape-artist">${artistH}</p>` : ''}
773 <p class="tape-meta">${metaLine}</p>
774 </div>
775 </div>
776 <div class="tape-controls" role="group" aria-label="Bandje bedienen">
777${spoel('back', 'Terugspoelen', '<path d="M11 12l9-7v14zM2 12l9-7v14z"/>')}
778 <button type="button" class="tape-btn tape-btn--play"
779 data-pcms-track-url="${firstUrl}"
780 data-pcms-album-id="${domId}"
781 data-tape-play
782 aria-label="Afspelen">
783 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
784 </button>
785${spoel('fwd', 'Vooruitspoelen', '<path d="M13 12L4 5v14zM22 12l-9-7v14z"/>')}
786 </div>
787 <p class="tape-now" data-tape-now aria-live="polite"></p>
788 <ol class="tape-tracks">
789${lijst}
790 </ol>
791</div>`;
792 }
793
794 /**
795 * Human-readable label for a provider slug. Used by external-link buttons.
796 */
797 static platformLabel(provider) {
798 return ({
799 spotify: 'Spotify',
800 bandcamp: 'Bandcamp',
801 soundcloud: 'SoundCloud',
802 applemusic: 'Apple Music',
803 youtube: 'YouTube',
804 vimeo: 'Vimeo',
805 tidal: 'Tidal',
806 deezer: 'Deezer',
807 mixcloud: 'Mixcloud',
808 })[provider] || 'External link';
809 }
810
811 /**
812 * Detect platform from URL purely by hostname (covers more services than
813 * detectProvider, which is embed-focused). Used for [[link:url]] rendering.
814 */
815 static detectLinkPlatform(url) {
816 try {
817 const host = new URL(url).hostname.toLowerCase();
818 if (host.includes('open.spotify.com') || host === 'spotify.com') return 'spotify';
819 if (host.includes('bandcamp.com')) return 'bandcamp';
820 if (host.includes('soundcloud.com')) return 'soundcloud';
821 if (host.includes('music.apple.com') || host.includes('itunes.apple.com')) return 'applemusic';
822 if (host.includes('youtube.com') || host.includes('youtu.be') || host.includes('music.youtube.com')) return 'youtube';
823 if (host.includes('vimeo.com')) return 'vimeo';
824 if (host.includes('tidal.com')) return 'tidal';
825 if (host.includes('deezer.com')) return 'deezer';
826 if (host.includes('mixcloud.com')) return 'mixcloud';
827 return 'other';
828 } catch (e) {
829 return null;
830 }
831 }
832
833 /**
834 * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
835 * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
836 * Per Robin's v9: "External link, click = open platform (target _blank)".
837 */
838 static embedExternalLinkShortcodes(html) {
839 if (!html) return html;
840 return html.replace(/\[\[link:([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, rawUrl, customLabel) => {
841 const url = rawUrl.trim();
842 if (!/^https?:\/\//i.test(url)) return match;
843 const platform = this.detectLinkPlatform(url) || 'other';
844 const label = (customLabel || '').trim();
845 const platformLabel = this.platformLabel(platform);
846 const buttonText = label || `Open in ${platformLabel}`;
847 const urlH = this.escape(url);
848 const textH = this.escape(buttonText);
849 return `<a class="post-audio-external post-audio-external--${platform}" href="${urlH}" target="_blank" rel="noopener noreferrer" data-platform="${platform}">
850 <span class="pae-icon" aria-hidden="true">▶</span>
851 <span class="pae-text">${textH}</span>
852 <span class="pae-arrow" aria-hidden="true">↗</span>
853</a>`;
854 });
855 }
856
857 static escape(str) {
858 return str
859 .replace(/&/g, '&amp;')
860 .replace(/</g, '&lt;')
861 .replace(/>/g, '&gt;')
862 .replace(/"/g, '&quot;')
863 .replace(/'/g, '&#39;');
864 }
865}
866
867export default AudioEmbedService;
Note: See TracBrowser for help on using the repository browser.