source: Klonkt/src/services/AudioEmbedService.js@ 1907a18

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

feat: embed media via editor button ([[embed:url]] shortcode)

Embed YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp with a toolbar
button in the post editor: paste a URL -> [[embed:url]] chip -> server renders
an iframe (AudioEmbedService.embedMediaShortcodes, in the existing embed pipeline).
Standalone URL lines also embed via autoembed(). CSP already allows the domains.

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

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