Changeset fef781e in Klonkt
- Timestamp:
- 06/20/2026 04:46:35 AM (3 months ago)
- Branches:
- main
- Children:
- 0d7acdf
- Parents:
- 8ab20b6
- Location:
- src
- Files:
-
- 7 edited
-
assets/css/embed.css (modified) (1 diff)
-
assets/js/audio-player.js (modified) (11 diffs)
-
assets/js/embed-player.js (modified) (6 diffs)
-
routes/audio.js (modified) (1 diff)
-
services/AudioEmbedService.js (modified) (3 diffs)
-
views/pages/post-edit.ejs (modified) (1 diff)
-
views/shell.ejs (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/css/embed.css
r8ab20b6 rfef781e 119 119 width: 320px; 120 120 height: 60px; 121 opacity: 0;122 pointer-events: none;123 }124 /* Verborgen YouTube-audio-speler ván de site-audiospeler (onderbalk). Off-screen125 * met echte grootte zodat de browser 'm niet pauzeert; alleen het geluid telt. */126 #pcms-yt-host {127 position: absolute;128 left: -99999px;129 top: 0;130 width: 320px;131 height: 180px;132 opacity: 0;133 pointer-events: none;134 }135 /* YouTube-afspeellijst als album-kaart: geen losse embed-card-chrome, de136 * .post-album-stijl (uit style.css) stuurt de weergave. */137 .pcms-ytalbum { background: none; border: 0; padding: 0; margin: 1.5rem 0; }138 .pcms-ytalbum-msg { padding: .75rem 1rem; color: var(--ink-soft, #6b6560); font-size: .9rem; }139 140 /* Audio-only embed-kaart die nu via de onderbalk speelt = launcher-hint. */141 .pcms-embed-elsewhere { opacity: .9; }142 .pcms-embed-elsewhere .pcms-embed-title::after {143 content: ' — speelt in de speler ↓';144 font-weight: normal;145 opacity: .6;146 font-size: .85em;147 }148 149 /* Audio-only YouTube: de YT-speler draait verborgen (off-screen) maar moet een150 * echte grootte houden, anders pauzeert de browser 'm. Alleen het geluid telt. */151 .pcms-embed-card--youtube.pcms-embed-audio-mode .pcms-embed-mount {152 position: absolute;153 left: -99999px;154 top: 0;155 width: 320px;156 height: 180px;157 121 opacity: 0; 158 122 pointer-events: none; -
src/assets/js/audio-player.js
r8ab20b6 rfef781e 289 289 }); 290 290 291 // ============================================================292 // 4a. YouTube-backend — speelt YT-AUDIO via een verborgen IFrame-speler in de293 // (persistente) speler-root. Zo speelt YouTube net als de site-tracks door bij294 // htmx-navigatie en is 't bedienbaar in de onderbalk. Video wordt niet getoond.295 // ============================================================296 let ytMode = false, ytPlayer = null, ytReady = false, ytApiLoading = null, ytTick = null;297 298 function loadYTApi() {299 if (window.YT && window.YT.Player) return Promise.resolve(window.YT);300 if (ytApiLoading) return ytApiLoading;301 ytApiLoading = new Promise((resolve) => {302 const prev = window.onYouTubeIframeAPIReady;303 window.onYouTubeIframeAPIReady = function () { if (prev) { try { prev(); } catch (e) {} } resolve(window.YT); };304 if (!document.querySelector('script[src*="youtube.com/iframe_api"]')) {305 const s = document.createElement('script'); s.src = 'https://www.youtube.com/iframe_api'; s.async = true; document.head.appendChild(s);306 }307 setTimeout(() => { if (window.YT && window.YT.Player) resolve(window.YT); }, 8000);308 });309 return ytApiLoading;310 }311 312 function ensureYT() {313 if (ytPlayer) return Promise.resolve(ytPlayer);314 return loadYTApi().then((YT) => new Promise((resolve) => {315 if (!YT || !YT.Player) { resolve(null); return; }316 let host = document.getElementById('pcms-yt-host');317 if (!host) {318 host = document.createElement('div'); host.id = 'pcms-yt-host';319 const m = document.createElement('div'); m.id = 'pcms-yt-mount'; host.appendChild(m);320 root.appendChild(host);321 }322 ytPlayer = new YT.Player('pcms-yt-mount', {323 host: 'https://www.youtube-nocookie.com',324 playerVars: { controls: 0, playsinline: 1, rel: 0, modestbranding: 1, iv_load_policy: 3, origin: window.location.origin },325 events: {326 onReady: () => { ytReady = true; try { ytPlayer.setVolume(audio.muted ? 0 : Math.round(audio.volume * 100)); } catch (e) {} resolve(ytPlayer); },327 onStateChange: onYTState,328 },329 });330 setTimeout(() => resolve(ytPlayer), 8000);331 }));332 }333 334 function onYTState(e) {335 if (!ytMode) return;336 const s = e.data; // 1 playing, 2 paused, 0 ended337 if (s === 1) {338 isPlaying = true; root.classList.add('is-playing'); root.classList.remove('audio-needs-tap');339 // We startten gedempt (autoplay-policy) → nu 't speelt het geluid terugzetten,340 // tenzij de gebruiker zelf gedempt heeft.341 try { if (!audio.muted) { ytPlayer.unMute(); ytPlayer.setVolume(Math.round(audio.volume * 100)); } } catch (e) {}342 mediaRegistry().setActive(registrySelf); startYTTick(); pullYTMeta();343 } else if (s === 2) {344 isPlaying = false; root.classList.remove('is-playing'); stopYTTick();345 } else if (s === 0) {346 stopYTTick();347 // Bij een afspeellijst regelt YouTube zelf het doorschakelen → niet dubbel.348 const t = queue[currentIndex];349 if (!(t && t.ytList)) next();350 }351 }352 353 function startYTTick() {354 if (ytTick) return;355 ytTick = setInterval(() => {356 if (!ytPlayer || !ytMode) return;357 try { updateProgressUI(ytPlayer.getCurrentTime() || 0, ytPlayer.getDuration() || 0); } catch (e) {}358 }, 250);359 }360 function stopYTTick() { if (ytTick) { clearInterval(ytTick); ytTick = null; } }361 362 function pullYTMeta() {363 try {364 const t = queue[currentIndex]; if (!t || (!t.yt && !t.ytList) || !ytPlayer.getVideoData) return;365 const vd = ytPlayer.getVideoData() || {};366 // Bij een afspeellijst wisselt de titel per nummer → altijd bijwerken.367 if (vd.title && (t.ytList || !t.title || t.title === 'YouTube')) {368 titleEl.textContent = vd.title; sheetTitle.textContent = vd.title;369 if (!t.ytList) t.title = vd.title;370 }371 if (vd.video_id) {372 setYtCover(vd.video_id);373 markPlaying(vd.video_id); // licht de bijbehorende album-rij op (id="track-<videoId>")374 }375 } catch (e) {}376 }377 378 // Gedeelde voortgang-UI (beide backends). Audio gebruikt 'timeupdate', YT de tick.379 function updateProgressUI(cur, dur) {380 if (!dur || isNaN(dur)) return;381 const pct = Math.max(0, Math.min(100, (cur / dur) * 100));382 seekFill.style.width = pct + '%'; sheetSeekFill.style.width = pct + '%';383 currentEl.textContent = formatTime(cur); totalEl.textContent = formatTime(dur);384 sheetCurrent.textContent = formatTime(cur); sheetTotal.textContent = formatTime(dur);385 }386 387 291 function loadTrack(index, autoplay, metaOnly) { 388 292 if (!queue[index]) { … … 392 296 currentIndex = index; 393 297 const t = queue[index]; 394 // Metadata + chrome update synchronously (geldt voor beide backends). 395 titleEl.textContent = t.title || (t.yt ? 'YouTube' : 'Untitled'); 298 if (!t.url) { 299 console.error('[pcms-audio] track has no url', t); 300 return; 301 } 302 console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : ''); 303 // Metadata + chrome update synchronously so the UI reacts instantly while 304 // the bytes download. 305 titleEl.textContent = t.title || 'Untitled'; 396 306 artistEl.textContent = t.artist || ''; 397 sheetTitle.textContent = t.title || (t.yt ? 'YouTube' : 'Untitled');307 sheetTitle.textContent = t.title || 'Untitled'; 398 308 sheetArtist.textContent = t.artist || ''; 399 309 sheetAlbum.textContent = albumName || ''; … … 406 316 407 317 if (metaOnly) return; 408 409 // YouTube-bron (los nummer óf afspeellijst/album) → verborgen YT-speler.410 if (t.yt || t.ytList) {411 ytMode = true;412 try { audio.pause(); } catch (e) {}413 loadSeq++; dropPreload();414 updateProgressUI(0, 1); currentEl.textContent = '0:00'; totalEl.textContent = '0:00';415 seekFill.style.width = '0%'; sheetSeekFill.style.width = '0%';416 root.classList.add('audio-loading');417 ensureYT().then((p) => {418 if (queue[currentIndex] !== t) return; // inmiddels andere track gekozen419 root.classList.remove('audio-loading');420 if (!p) { onLoadError(new Error('YT API niet beschikbaar'), loadSeq); return; }421 try {422 // Gedempt starten = autoplay is ALTIJD toegestaan (YouTube blokkeert423 // anders het geluid). onYTState ontdempt zodra 't echt speelt.424 if (autoplay) { try { p.mute(); } catch (e) {} }425 if (t.ytList) {426 const idx = t.ytIndex || 0;427 // listType:'playlist' is vereist voor de object-vorm; zonder dit laadt428 // (en speelt) de afspeellijst niet betrouwbaar.429 if (autoplay) p.loadPlaylist({ listType: 'playlist', list: t.ytList, index: idx });430 else p.cuePlaylist({ listType: 'playlist', list: t.ytList, index: idx });431 } else {432 if (autoplay) p.loadVideoById(t.yt); else p.cueVideoById(t.yt);433 }434 p.setVolume(audio.muted ? 0 : Math.round(audio.volume * 100));435 } catch (e) {}436 });437 return;438 }439 440 // Normale blob-audio track.441 ytMode = false;442 stopYTTick();443 try { if (ytPlayer && ytPlayer.stopVideo) ytPlayer.stopVideo(); } catch (e) {}444 if (!t.url) { console.error('[pcms-audio] track has no url', t); return; }445 318 446 319 const mySeq = ++loadSeq; … … 494 367 495 368 function play() { 496 if (ytMode) {497 if (ytPlayer && ytReady) { try { ytPlayer.playVideo(); } catch (e) {} }498 else if (queue[currentIndex]) loadTrack(currentIndex, true);499 return;500 }501 369 if (!audio.src) { 502 370 // Nothing fetched yet (pre-seed showed metadata only, or a load is still … … 520 388 } 521 389 } 522 function pause() { if (ytMode) { try { ytPlayer && ytPlayer.pauseVideo(); } catch (e) {} return; }audio.pause(); }523 function togglePlay() { if (ytMode) { isPlaying ? pause() : play(); return; }audio.paused ? play() : pause(); }390 function pause() { audio.pause(); } 391 function togglePlay() { audio.paused ? play() : pause(); } 524 392 function next() { 525 const t = queue[currentIndex];526 if (t && t.ytList && ytPlayer && ytMode) { try { ytPlayer.nextVideo(); } catch (e) {} return; }527 393 if (!queue.length) return; 528 394 loadTrack((currentIndex + 1) % queue.length, true); 529 395 } 530 396 function prev() { 531 const t = queue[currentIndex];532 if (t && t.ytList && ytPlayer && ytMode) { try { ytPlayer.previousVideo(); } catch (e) {} return; }533 397 if (!queue.length) return; 534 398 loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true); … … 540 404 document.body.classList.remove('has-audio-player'); 541 405 closeSheet(); 542 ytMode = false; stopYTTick();543 try { if (ytPlayer && ytPlayer.stopVideo) ytPlayer.stopVideo(); } catch (e) {}544 406 queue = []; 545 407 albumName = ''; … … 600 462 return r; 601 463 } 602 const registrySelf = { pause() { try { audio.pause(); } catch (e) {} try { if (ytPlayer && ytPlayer.pauseVideo) ytPlayer.pauseVideo(); } catch (e) {}} };464 const registrySelf = { pause() { try { audio.pause(); } catch (e) {} } }; 603 465 604 466 // ============================================================ … … 636 498 audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); }); 637 499 audio.addEventListener('timeupdate', () => { 638 if (ytMode) return; // YT-voortgang loopt via de tick 639 updateProgressUI(audio.currentTime, audio.duration); 500 if (!audio.duration || isNaN(audio.duration)) return; 501 const pct = (audio.currentTime / audio.duration) * 100; 502 seekFill.style.width = pct + '%'; 503 sheetSeekFill.style.width = pct + '%'; 504 currentEl.textContent = formatTime(audio.currentTime); 505 totalEl.textContent = formatTime(audio.duration); 506 sheetCurrent.textContent = formatTime(audio.currentTime); 507 sheetTotal.textContent = formatTime(audio.duration); 640 508 }); 641 509 … … 648 516 function attachSeek(seekEl) { 649 517 seekEl.addEventListener('click', (e) => { 518 if (!audio.duration) return; 650 519 const rect = seekEl.getBoundingClientRect(); 651 520 const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); 652 if (ytMode) {653 try { const d = ytPlayer.getDuration() || 0; if (d) ytPlayer.seekTo(ratio * d, true); } catch (e2) {}654 return;655 }656 if (!audio.duration) return;657 521 audio.currentTime = ratio * audio.duration; 658 522 }); … … 663 527 audio.volume = 0.8; 664 528 volumeSlider.addEventListener('input', () => { 665 const v = parseInt(volumeSlider.value, 10) || 0; 666 audio.volume = v / 100; 667 if (v > 0) audio.muted = false; 668 if (ytPlayer) { try { ytPlayer.setVolume(v); if (v > 0 && ytPlayer.unMute) ytPlayer.unMute(); } catch (e) {} } 669 }); 670 muteBtn.addEventListener('click', () => { 671 audio.muted = !audio.muted; 672 if (ytPlayer) { try { audio.muted ? ytPlayer.mute() : ytPlayer.unMute(); } catch (e) {} } 673 root.classList.toggle('is-muted', audio.muted); 674 }); 529 audio.volume = volumeSlider.value / 100; 530 if (volumeSlider.value > 0) audio.muted = false; 531 }); 532 muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; }); 675 533 676 534 // ============================================================ … … 939 797 // 9. Public API 940 798 // ============================================================ 941 // Speel een YouTube-bron als AUDIO af in deze (persistente) site-speler — door942 // de embed-speler aangeroepen voor een audio-only YouTube-embed. Zo verschijnt943 // 'ie in de onderbalk én speelt 'ie door bij navigeren.944 function playYouTube(opts) {945 if (!opts || (!opts.id && !opts.list)) return;946 setQueue([{947 yt: opts.id || null,948 ytList: opts.list || null, // YouTube-afspeellijst/album (OLAK…/PL…)949 ytIndex: opts.index || 0, // startindex binnen de lijst950 title: opts.title || 'YouTube',951 artist: opts.artist || '',952 cover: opts.cover || '',953 postUrl: opts.postUrl || '',954 }], 0);955 // De scherpe cover + echte titel komen via pullYTMeta() zodra het nummer speelt.956 }957 958 // Zet de bar-cover op de thumbnail van een video (mqdefault = balkloos), en959 // upgrade async naar de scherpe maxresdefault als die bestaat.960 function setYtCover(videoId) {961 if (!videoId) return;962 const mq = 'https://i.ytimg.com/vi/' + videoId + '/mqdefault.jpg';963 setCoverImage(cover, mq); setCoverImage(sheetCover, mq);964 try {965 const maxres = 'https://i.ytimg.com/vi/' + videoId + '/maxresdefault.jpg';966 const im = new Image();967 im.onload = () => {968 if (im.naturalWidth <= 120) return;969 const cur = ytPlayer && ytPlayer.getVideoData ? (ytPlayer.getVideoData() || {}).video_id : null;970 if (cur === videoId) { setCoverImage(cover, maxres); setCoverImage(sheetCover, maxres); }971 };972 im.src = maxres;973 } catch (e) {}974 }975 976 // Speel een YouTube-AFSPEELLIJST af door 'm te expanderen naar onze eigen queue977 // van losse YouTube-tracks (elk via loadVideoById = bewezen betrouwbaar; next/978 // prev/ended lopen via onze queue). items = [{yt,title,cover,postUrl}, …].979 function playYouTubeList(items, startIndex) {980 if (!Array.isArray(items) || !items.length) return;981 setQueue(items.map((it) => ({982 yt: it.yt,983 title: it.title || 'YouTube',984 artist: it.artist || '',985 cover: it.cover || '',986 postUrl: it.postUrl || '',987 })), startIndex || 0);988 }989 990 // Maak de verborgen YT-speler vast aan (zonder iets te spelen), zodat 'ie bij de991 // eerste klik al klaar staat → loadVideoById valt dan binnen de user-gesture en992 // YouTube's autoplay-policy blokkeert het niet.993 function prewarmYouTube() { try { ensureYT(); } catch (e) {} }994 995 799 window.pcmsAudioPlayer = { 996 setQueue, play, pause, next, prev, close, openSheet, closeSheet, playYouTube, playYouTubeList, prewarmYouTube,800 setQueue, play, pause, next, prev, close, openSheet, closeSheet, 997 801 isPlaying: () => isPlaying, 998 802 currentTrack: () => queue[currentIndex] || null, -
src/assets/js/embed-player.js
r8ab20b6 rfef781e 162 162 if (!provider) return; 163 163 164 // Audio-only YouTube met een afspeellijst (?list=…) → album-kaart met tracklijst.165 if (provider === 'youtube' && el.dataset.embedMode === 'audio') {166 const lm = url.match(/[?&]list=([A-Za-z0-9_-]+)/);167 if (lm) { try { buildYtAlbum(el, lm[1]); } catch (e) { console.error('[pcms-embed] yt-album faalde', e); } return; }168 }169 170 164 el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider); 171 165 el.classList.remove('pcms-embed-loading'); … … 189 183 190 184 // --- UI ophangen (verschilt per provider-type) --- 191 // Audio-only YouTube (data-embed-mode="audio"): render als audio-kaart met 192 // onze controls; de YT-speler draait verborgen (off-screen) en levert alleen 193 // het geluid. Video blijft de default. 194 const audioOnly = provider === 'youtube' && el.dataset.embedMode === 'audio'; 195 if (audioOnly) el.classList.add('pcms-embed-audio-mode'); 196 const isVideo = provider === 'youtube' && !audioOnly; 197 // Audio-only YT speelt via de site-onderbalk → geen eigen in-kaart controlebar. 198 const custom = (provider === 'youtube' || provider === 'soundcloud') && !audioOnly; 185 const isVideo = provider === 'youtube'; 186 const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls 199 187 const poster = provider === 'youtube' 200 188 ? `https://i.ytimg.com/vi/${ytId(ref, url)}/hqdefault.jpg` : ''; 201 // 16:9-thumbnail ZONDER zwarte balken (mqdefault) voor de vierkante cover —202 // hqdefault is 4:3 met letterbox en vult een vierkant niet schoon.203 const ytThumb = provider === 'youtube'204 ? `https://i.ytimg.com/vi/${ytId(ref, url)}/mqdefault.jpg` : '';205 189 206 190 el.innerHTML = '' … … 238 222 let vol = 100; // huidige volume 0-100 (wordt op de adapter toegepast) 239 223 let preMuteVol = 100; 240 // Audio-only YouTube: zet de thumbnail als albumhoes op de audio-kaart.241 let ytTitle = ''; // echte videotitel (via same-origin oEmbed-proxy)242 if (audioOnly && ytThumb) {243 const art0 = el.querySelector('.pcms-embed-art');244 if (art0) { art0.style.backgroundImage = `url('${ytThumb}')`; art0.classList.add('has-art'); }245 // Speler vast aanmaken → eerste klik valt binnen de user-gesture (autoplay).246 if (window.pcmsAudioPlayer && window.pcmsAudioPlayer.prewarmYouTube) window.pcmsAudioPlayer.prewarmYouTube();247 // Haal de echte titel op (server-proxy, want YT-oEmbed heeft geen CORS) en248 // toon 'm i.p.v. "YouTube".249 fetch('/audio/yt-title?url=' + encodeURIComponent(url))250 .then((r) => (r.ok ? r.json() : null))251 .then((d) => {252 if (d && d.title) {253 ytTitle = d.title;254 const tt = el.querySelector('.pcms-embed-title');255 if (tt) tt.textContent = d.title;256 }257 })258 .catch(() => {});259 }260 224 261 225 function setPlayingUI(on) { … … 332 296 // Eerste interactie → adapter mounten + spelen. Daarna toggelt de knop. 333 297 async function ensureMountedAndPlay() { 334 // Audio-only YouTube → speel via de site-audiospeler (onderbalk): zichtbaar335 // én doorspelen bij navigeren. De inline-kaart is enkel de launcher.336 if (audioOnly && window.pcmsAudioPlayer && typeof window.pcmsAudioPlayer.playYouTube === 'function') {337 window.pcmsAudioPlayer.playYouTube({ id: ytId(ref, url), title: ytTitle || undefined, cover: ytThumb, postUrl: location.pathname + location.search });338 el.classList.add('pcms-embed-elsewhere');339 return;340 }341 298 if (mounted) { if (adapter) adapter.play(); return; } 342 299 mounted = true; … … 391 348 } 392 349 393 // Haal de video-IDs van een YouTube-afspeellijst op via een verborgen,394 // tijdelijke speler (cuePlaylist → getPlaylist). Geen API-key nodig.395 function ytPlaylistIds(listId) {396 return ytApi().then((YT) => new Promise((resolve) => {397 if (!YT || !YT.Player) { resolve([]); return; }398 const host = document.createElement('div');399 host.style.cssText = 'position:absolute;left:-99999px;top:0;width:320px;height:180px;opacity:0;pointer-events:none';400 const mount = document.createElement('div'); host.appendChild(mount); document.body.appendChild(host);401 let done = false;402 const finish = (ids) => { if (done) return; done = true; try { p.destroy(); } catch (e) {} try { host.remove(); } catch (e) {} resolve(ids || []); };403 const p = new YT.Player(mount, {404 host: 'https://www.youtube-nocookie.com', playerVars: { playsinline: 1 },405 events: { onReady: () => {406 try { p.cuePlaylist({ listType: 'playlist', list: listId }); } catch (e) {}407 setTimeout(() => { let ids = []; try { ids = p.getPlaylist() || []; } catch (e) {} finish(ids); }, 2500);408 } },409 });410 setTimeout(() => finish([]), 9000);411 }));412 }413 414 // Render een audio-only YouTube-AFSPEELLIJST als album-kaart (zelfde stijl als415 // de eigen albums) met tracklijst; afspelen loopt via de site-audiospeler.416 async function buildYtAlbum(el, listId) {417 el.className = 'folio-embed pcms-ytalbum'; // geen losse card-chrome; .post-album stuurt de stijl418 const thumb = (id) => `https://i.ytimg.com/vi/${id}/mqdefault.jpg`;419 el.innerHTML = ''420 + '<div class="post-album">'421 + '<div class="post-album-header">'422 + '<button type="button" class="post-album-cover-btn" aria-label="Album afspelen">'423 + '<img class="post-album-cover-img" src="" alt="">'424 + '<span class="post-album-play-overlay" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 4l12 8-12 8z"/></svg></span>'425 + '</button>'426 + '<div class="post-album-info">'427 + '<h3 class="post-album-title">Album laden…</h3>'428 + '<p class="post-album-artist"></p>'429 + '<p class="post-album-count">YouTube</p>'430 + '</div>'431 + '</div>'432 + '<ol class="post-album-tracks"><li class="pcms-ytalbum-msg">Tracks laden…</li></ol>'433 + '</div>';434 const coverImg = el.querySelector('.post-album-cover-img');435 const titleEl = el.querySelector('.post-album-title');436 const countEl = el.querySelector('.post-album-count');437 const olEl = el.querySelector('.post-album-tracks');438 const coverBtn = el.querySelector('.post-album-cover-btn');439 440 // Albumnaam via de oEmbed-proxy (playlist-url).441 fetch('/audio/yt-title?url=' + encodeURIComponent('https://www.youtube.com/playlist?list=' + listId))442 .then((r) => (r.ok ? r.json() : null)).then((d) => { if (d && d.title) titleEl.textContent = d.title; }).catch(() => {});443 444 let ids = [];445 try { ids = await ytPlaylistIds(listId); } catch (e) { ids = []; }446 if (!ids.length) { olEl.innerHTML = '<li class="pcms-ytalbum-msg">Kon de afspeellijst niet laden.</li>'; return; }447 countEl.textContent = ids.length + ' track' + (ids.length === 1 ? '' : 's');448 if (coverImg) coverImg.src = thumb(ids[0]);449 // Speler vast aanmaken → eerste klik valt binnen de user-gesture (autoplay).450 if (window.pcmsAudioPlayer && window.pcmsAudioPlayer.prewarmYouTube) window.pcmsAudioPlayer.prewarmYouTube();451 452 olEl.innerHTML = ids.map((id, i) =>453 `<li class="post-album-track-compact" id="track-${id}" data-yt-id="${id}" data-yt-index="${i}">`454 + `<button type="button" class="pat-row" aria-label="Speel track ${i + 1}">`455 + `<span class="pat-cover" style="background-image:url('${thumb(id)}')" aria-hidden="true"></span>`456 + `<span class="pat-meta"><span class="pat-title">Track ${i + 1}</span><span class="pat-artist"></span></span>`457 + `<span class="pat-duration pat-duration-empty">—:—</span>`458 + `</button></li>`459 ).join('');460 461 // Tracktitels lazy invullen (oEmbed-proxy per video).462 ids.forEach((id, i) => {463 fetch('/audio/yt-title?url=' + encodeURIComponent('https://www.youtube.com/watch?v=' + id))464 .then((r) => (r.ok ? r.json() : null))465 .then((d) => { if (d && d.title) { const tt = olEl.querySelector(`[data-yt-index="${i}"] .pat-title`); if (tt) tt.textContent = d.title; } })466 .catch(() => {});467 });468 469 function playAt(i) {470 if (!(window.pcmsAudioPlayer && window.pcmsAudioPlayer.playYouTubeList)) return;471 // Expandeer naar losse YT-tracks (betrouwbaarder dan loadPlaylist).472 const items = ids.map((id, j) => ({473 yt: id,474 title: (olEl.querySelector(`[data-yt-index="${j}"] .pat-title`) || {}).textContent || ('Track ' + (j + 1)),475 cover: thumb(id),476 postUrl: location.pathname + location.search,477 }));478 window.pcmsAudioPlayer.playYouTubeList(items, i);479 }480 coverBtn.addEventListener('click', () => playAt(0));481 olEl.addEventListener('click', (e) => { const li = e.target.closest('[data-yt-index]'); if (li) playAt(parseInt(li.dataset.ytIndex, 10) || 0); });482 }483 484 350 // ============================================================ 485 351 // 4. Adapters — normaliseren de 3 zeer verschillende API's naar 1 vorm. … … 503 369 events: { 504 370 onReady() { 505 let d = 0; const meta = {}; 506 try { d = player.getDuration() || 0; } catch (e) {} 507 try { const vd = player.getVideoData(); if (vd && vd.title) meta.title = vd.title; } catch (e) {} 508 meta.duration = d; 509 hooks.onReady(meta); 371 let d = 0; try { d = player.getDuration() || 0; } catch (e) {} 372 hooks.onReady({ duration: d }); 510 373 resolve({ 511 374 play() { try { player.playVideo(); } catch (e) {} }, -
src/routes/audio.js
r8ab20b6 rfef781e 158 158 }); 159 159 160 // Same-origin proxy voor de YouTube-titel (oEmbed). YouTube's oEmbed stuurt geen161 // CORS-header, dus de browser kan 'm niet direct fetchen — wij halen 'm server-162 // side op (geen CORS) en cachen 't. Gebruikt door de audio-only YT-embed om de163 // echte videotitel te tonen i.p.v. "YouTube".164 const ytTitleCache = new Map();165 router.get('/yt-title', async (req, res) => {166 const url = String(req.query.url || '');167 if (!/^https:\/\/(www\.)?(youtube\.com|youtu\.be|youtube-nocookie\.com)\//i.test(url)) {168 return res.status(400).json({ error: 'bad url' });169 }170 if (ytTitleCache.has(url)) return res.json({ title: ytTitleCache.get(url) });171 try {172 const ac = new AbortController();173 const timer = setTimeout(() => ac.abort(), 6000);174 const r = await fetch('https://www.youtube.com/oembed?format=json&url=' + encodeURIComponent(url), { signal: ac.signal });175 clearTimeout(timer);176 if (!r.ok) { ytTitleCache.set(url, ''); return res.json({ title: '' }); }177 const d = await r.json();178 const title = (d && d.title) ? String(d.title).slice(0, 200) : '';179 ytTitleCache.set(url, title);180 res.json({ title });181 } catch (e) {182 res.status(502).json({ error: 'fetch failed' });183 }184 });185 186 160 export default router; -
src/services/AudioEmbedService.js
r8ab20b6 rfef781e 67 67 case 'youtube': 68 68 return this.embedPlaceholder('youtube', config.id, 'video', 69 config.url || `https://youtu.be/${config.id}` , config.mode);69 config.url || `https://youtu.be/${config.id}`); 70 70 case 'soundcloud': 71 71 return this.embedPlaceholder('soundcloud', config.url, 'track', config.url); … … 91 91 * ALLE waarden via escape() — post.content_html wordt ongeescaped uitgevoerd. 92 92 */ 93 static embedPlaceholder(provider, ref, type, url , mode) {93 static embedPlaceholder(provider, ref, type, url) { 94 94 const attrs = [ 95 95 `data-embed-provider="${this.escape(provider)}"`, 96 96 `data-embed-ref="${this.escape(ref)}"`, 97 97 type ? `data-embed-type="${this.escape(type)}"` : '', 98 mode ? `data-embed-mode="${this.escape(mode)}"` : '',99 98 `data-embed-url="${this.escape(url)}"`, 100 99 ].filter(Boolean).join(' '); … … 228 227 static embedMediaShortcodes(html) { 229 228 if (!html) return html; 230 return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawSpec) => { 231 // Optionele modus-prefix: [[embed:audio:URL]] = alleen-audio speler, 232 // [[embed:video:URL]] / [[embed:URL]] = normaal (video waar van toepassing). 233 let spec = rawSpec.trim(); 234 let mode = null; 235 const mm = spec.match(/^(audio|video):(.+)$/i); 236 if (mm) { mode = mm[1].toLowerCase(); spec = mm[2].trim(); } 237 const url = spec.replace(/&/g, '&'); 229 return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawUrl) => { 230 const url = rawUrl.trim().replace(/&/g, '&'); 238 231 const detected = this.detectProvider(url); 239 232 if (!detected) { 240 233 return `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`; 241 234 } 242 return this.generateIframe(detected.provider, { ...detected, mode }) || match;235 return this.generateIframe(detected.provider, detected) || match; 243 236 }); 244 237 } -
src/views/pages/post-edit.ejs
r8ab20b6 rfef781e 1463 1463 const url = raw.trim(); 1464 1464 if (!/^https?:\/\//i.test(url)) { alert('Geef een volledige URL (https://…).'); return; } 1465 // YouTube: keuze audio-only (alleen geluid) of video. 1466 let val = url; 1467 if (/(youtube\.com|youtu\.be)/i.test(url)) { 1468 const audioOnly = window.confirm('YouTube — alleen audio afspelen?\n\nOK = audio-only speler (geen video)\nAnnuleren = video'); 1469 if (audioOnly) val = 'audio:' + url; 1470 } 1471 insertChip('embed', val); 1465 insertChip('embed', url); 1472 1466 }); 1473 1467 } -
src/views/shell.ejs
r8ab20b6 rfef781e 170 170 <link rel="stylesheet" href="/assets/css/audio.css?v=6"> 171 171 <!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) in huisstijl. --> 172 <link rel="stylesheet" href="/assets/css/embed.css?v= 7">172 <link rel="stylesheet" href="/assets/css/embed.css?v=8"> 173 173 174 174 <%- include('partials/shared-styles') %> … … 300 300 ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat 301 301 Cloudflare (max-age=1y) niet de oude versie blijft serveren. --> 302 <script src="/assets/js/audio-player.js?v=2 7"></script>302 <script src="/assets/js/audio-player.js?v=28"></script> 303 303 <!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte 304 304 player-API's + gedeelde mutual-exclusion registry met de site-speler. --> 305 <script src="/assets/js/embed-player.js?v=1 5" defer></script>305 <script src="/assets/js/embed-player.js?v=16" defer></script> 306 306 <% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %> 307 307 <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)