| [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.
|
|---|
| 15 | import { SOORTEN } from '../assets/js/shared/post-music-type.js';
|
|---|
| 16 |
|
|---|
| [834bcc3] | 17 | // "Open in" icons (brand-colored via CSS .pat-link--).
|
|---|
| [183875b] | 18 | const 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] | 24 | class 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 (&)
|
|---|
| 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(/&/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, '&').replace(/'/g, ''').replace(/</g, '<');
|
|---|
| [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, '&').replace(/'/g, ''').replace(/</g, '<');
|
|---|
| 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, '&').replace(/'/g, ''').replace(/</g, '<');
|
|---|
| 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">
|
|---|
| 728 | <div class="tape-window" aria-hidden="true">
|
|---|
| 729 | <span class="tape-reel tape-reel--left"><span class="tape-reel-hub"></span></span>
|
|---|
| 730 | <span class="tape-band"></span>
|
|---|
| 731 | <span class="tape-reel tape-reel--right"><span class="tape-reel-hub"></span></span>
|
|---|
| 732 | </div>
|
|---|
| 733 | <div class="tape-label"${coverH ? ` style="background-image:url('${coverH}')"` : ''}>
|
|---|
| 734 | <p class="tape-kind">📼 Mixtape</p>
|
|---|
| 735 | <h3 class="tape-title">${titleH}</h3>
|
|---|
| 736 | ${artistH ? `<p class="tape-artist">${artistH}</p>` : ''}
|
|---|
| 737 | <p class="tape-meta">${metaLine}</p>
|
|---|
| 738 | </div>
|
|---|
| 739 | </div>
|
|---|
| 740 | <div class="tape-controls" role="group" aria-label="Bandje bedienen">
|
|---|
| 741 | ${spoel('back', 'Terugspoelen', '<path d="M11 12l9-7v14zM2 12l9-7v14z"/>')}
|
|---|
| 742 | <button type="button" class="tape-btn tape-btn--play"
|
|---|
| 743 | data-pcms-track-url="${firstUrl}"
|
|---|
| 744 | data-pcms-album-id="${domId}"
|
|---|
| 745 | data-tape-play
|
|---|
| 746 | aria-label="Afspelen">
|
|---|
| 747 | <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
|
|---|
| 748 | </button>
|
|---|
| 749 | ${spoel('fwd', 'Vooruitspoelen', '<path d="M13 12L4 5v14zM22 12l-9-7v14z"/>')}
|
|---|
| 750 | </div>
|
|---|
| 751 | <p class="tape-now" data-tape-now aria-live="polite"></p>
|
|---|
| 752 | <ol class="tape-tracks">
|
|---|
| 753 | ${lijst}
|
|---|
| 754 | </ol>
|
|---|
| 755 | </div>`;
|
|---|
| 756 | }
|
|---|
| 757 |
|
|---|
| [7bc636b] | 758 | /**
|
|---|
| 759 | * Human-readable label for a provider slug. Used by external-link buttons.
|
|---|
| 760 | */
|
|---|
| 761 | static platformLabel(provider) {
|
|---|
| 762 | return ({
|
|---|
| 763 | spotify: 'Spotify',
|
|---|
| 764 | bandcamp: 'Bandcamp',
|
|---|
| 765 | soundcloud: 'SoundCloud',
|
|---|
| 766 | applemusic: 'Apple Music',
|
|---|
| 767 | youtube: 'YouTube',
|
|---|
| 768 | vimeo: 'Vimeo',
|
|---|
| 769 | tidal: 'Tidal',
|
|---|
| 770 | deezer: 'Deezer',
|
|---|
| 771 | mixcloud: 'Mixcloud',
|
|---|
| 772 | })[provider] || 'External link';
|
|---|
| 773 | }
|
|---|
| 774 |
|
|---|
| 775 | /**
|
|---|
| 776 | * Detect platform from URL purely by hostname (covers more services than
|
|---|
| 777 | * detectProvider, which is embed-focused). Used for [[link:url]] rendering.
|
|---|
| 778 | */
|
|---|
| 779 | static detectLinkPlatform(url) {
|
|---|
| 780 | try {
|
|---|
| 781 | const host = new URL(url).hostname.toLowerCase();
|
|---|
| 782 | if (host.includes('open.spotify.com') || host === 'spotify.com') return 'spotify';
|
|---|
| 783 | if (host.includes('bandcamp.com')) return 'bandcamp';
|
|---|
| 784 | if (host.includes('soundcloud.com')) return 'soundcloud';
|
|---|
| 785 | if (host.includes('music.apple.com') || host.includes('itunes.apple.com')) return 'applemusic';
|
|---|
| 786 | if (host.includes('youtube.com') || host.includes('youtu.be') || host.includes('music.youtube.com')) return 'youtube';
|
|---|
| 787 | if (host.includes('vimeo.com')) return 'vimeo';
|
|---|
| 788 | if (host.includes('tidal.com')) return 'tidal';
|
|---|
| 789 | if (host.includes('deezer.com')) return 'deezer';
|
|---|
| 790 | if (host.includes('mixcloud.com')) return 'mixcloud';
|
|---|
| 791 | return 'other';
|
|---|
| 792 | } catch (e) {
|
|---|
| 793 | return null;
|
|---|
| 794 | }
|
|---|
| 795 | }
|
|---|
| 796 |
|
|---|
| 797 | /**
|
|---|
| 798 | * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
|
|---|
| 799 | * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
|
|---|
| [834bcc3] | 800 | * Per Robin's v9: "External link, click = open platform (target _blank)".
|
|---|
| [7bc636b] | 801 | */
|
|---|
| 802 | static embedExternalLinkShortcodes(html) {
|
|---|
| 803 | if (!html) return html;
|
|---|
| 804 | return html.replace(/\[\[link:([^\]|]+)(?:\|([^\]]+))?\]\]/g, (match, rawUrl, customLabel) => {
|
|---|
| 805 | const url = rawUrl.trim();
|
|---|
| 806 | if (!/^https?:\/\//i.test(url)) return match;
|
|---|
| 807 | const platform = this.detectLinkPlatform(url) || 'other';
|
|---|
| 808 | const label = (customLabel || '').trim();
|
|---|
| 809 | const platformLabel = this.platformLabel(platform);
|
|---|
| 810 | const buttonText = label || `Open in ${platformLabel}`;
|
|---|
| 811 | const urlH = this.escape(url);
|
|---|
| 812 | const textH = this.escape(buttonText);
|
|---|
| 813 | return `<a class="post-audio-external post-audio-external--${platform}" href="${urlH}" target="_blank" rel="noopener noreferrer" data-platform="${platform}">
|
|---|
| 814 | <span class="pae-icon" aria-hidden="true">▶</span>
|
|---|
| 815 | <span class="pae-text">${textH}</span>
|
|---|
| 816 | <span class="pae-arrow" aria-hidden="true">↗</span>
|
|---|
| 817 | </a>`;
|
|---|
| 818 | });
|
|---|
| 819 | }
|
|---|
| 820 |
|
|---|
| 821 | static escape(str) {
|
|---|
| 822 | return str
|
|---|
| 823 | .replace(/&/g, '&')
|
|---|
| 824 | .replace(/</g, '<')
|
|---|
| 825 | .replace(/>/g, '>')
|
|---|
| 826 | .replace(/"/g, '"')
|
|---|
| 827 | .replace(/'/g, ''');
|
|---|
| 828 | }
|
|---|
| 829 | }
|
|---|
| 830 |
|
|---|
| 831 | export default AudioEmbedService; |
|---|