Changeset d43ea3f in Klonkt


Ignore:
Timestamp:
06/20/2026 04:22:32 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
71e8fa5
Parents:
b56afa7
Message:

feat(embed): YouTube playlist/album as audio on the site

An audio-only YouTube embed with ?list=… now renders as an album card (same
style as own albums): cover + album name + numbered track list (titles via the
oEmbed proxy; video IDs via getPlaylist). Clicking a track or the album art
plays via the site audio player (loadPlaylist on index → next/prev within the
album; title/cover update per track; row highlight via #track-<videoId>).
Duration per track is empty (—:—) — not available without a YouTube Data API key.
busters audio-player.js?v=23, embed-player.js?v=12, embed.css?v=7.

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

Location:
src
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/embed.css

    rb56afa7 rd43ea3f  
    133133  pointer-events: none;
    134134}
     135/* YouTube-afspeellijst als album-kaart: geen losse embed-card-chrome, de
     136 * .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
    135140/* Audio-only embed-kaart die nu via de onderbalk speelt = launcher-hint. */
    136141.pcms-embed-elsewhere { opacity: .9; }
  • src/assets/js/audio-player.js

    rb56afa7 rd43ea3f  
    341341      isPlaying = false; root.classList.remove('is-playing'); stopYTTick();
    342342    } else if (s === 0) {
    343       stopYTTick(); next();
     343      stopYTTick();
     344      // Bij een afspeellijst regelt YouTube zelf het doorschakelen → niet dubbel.
     345      const t = queue[currentIndex];
     346      if (!(t && t.ytList)) next();
    344347    }
    345348  }
     
    356359  function pullYTMeta() {
    357360    try {
    358       const t = queue[currentIndex]; if (!t || !t.yt || !ytPlayer.getVideoData) return;
    359       const vd = ytPlayer.getVideoData();
    360       if (vd && vd.title && (!t.title || t.title === 'YouTube')) {
    361         t.title = vd.title; titleEl.textContent = vd.title; sheetTitle.textContent = vd.title; renderQueue(); markPlaying(t.id);
     361      const t = queue[currentIndex]; if (!t || (!t.yt && !t.ytList) || !ytPlayer.getVideoData) return;
     362      const vd = ytPlayer.getVideoData() || {};
     363      // Bij een afspeellijst wisselt de titel per nummer → altijd bijwerken.
     364      if (vd.title && (t.ytList || !t.title || t.title === 'YouTube')) {
     365        titleEl.textContent = vd.title; sheetTitle.textContent = vd.title;
     366        if (!t.ytList) t.title = vd.title;
     367      }
     368      if (vd.video_id) {
     369        setYtCover(vd.video_id);
     370        markPlaying(vd.video_id);  // licht de bijbehorende album-rij op (id="track-<videoId>")
    362371      }
    363372    } catch (e) {}
     
    395404    if (metaOnly) return;
    396405
    397     // YouTube-bron → verborgen YT-speler i.p.v. de blob-<audio>.
    398     if (t.yt) {
     406    // YouTube-bron (los nummer óf afspeellijst/album) → verborgen YT-speler.
     407    if (t.yt || t.ytList) {
    399408      ytMode = true;
    400409      try { audio.pause(); } catch (e) {}
     
    408417        if (!p) { onLoadError(new Error('YT API niet beschikbaar'), loadSeq); return; }
    409418        try {
    410           if (autoplay) p.loadVideoById(t.yt); else p.cueVideoById(t.yt);
     419          if (t.ytList) {
     420            const idx = t.ytIndex || 0;
     421            if (autoplay) p.loadPlaylist({ list: t.ytList, index: idx }); else p.cuePlaylist({ list: t.ytList, index: idx });
     422          } else {
     423            if (autoplay) p.loadVideoById(t.yt); else p.cueVideoById(t.yt);
     424          }
    411425          p.setVolume(audio.muted ? 0 : Math.round(audio.volume * 100));
    412426        } catch (e) {}
     
    500514  function togglePlay() { if (ytMode) { isPlaying ? pause() : play(); return; } audio.paused ? play() : pause(); }
    501515  function next() {
     516    const t = queue[currentIndex];
     517    if (t && t.ytList && ytPlayer && ytMode) { try { ytPlayer.nextVideo(); } catch (e) {} return; }
    502518    if (!queue.length) return;
    503519    loadTrack((currentIndex + 1) % queue.length, true);
    504520  }
    505521  function prev() {
     522    const t = queue[currentIndex];
     523    if (t && t.ytList && ytPlayer && ytMode) { try { ytPlayer.previousVideo(); } catch (e) {} return; }
    506524    if (!queue.length) return;
    507525    loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
     
    916934  // 'ie in de onderbalk én speelt 'ie door bij navigeren.
    917935  function playYouTube(opts) {
    918     if (!opts || !opts.id) return;
     936    if (!opts || (!opts.id && !opts.list)) return;
    919937    setQueue([{
    920       yt: opts.id,
     938      yt: opts.id || null,
     939      ytList: opts.list || null,     // YouTube-afspeellijst/album (OLAK…/PL…)
     940      ytIndex: opts.index || 0,      // startindex binnen de lijst
    921941      title: opts.title || 'YouTube',
    922942      artist: opts.artist || '',
     
    924944      postUrl: opts.postUrl || '',
    925945    }], 0);
    926     // Upgrade de cover async naar de scherpe maxresdefault (ook 16:9, geen balken)
    927     // als die bestaat. Async → de user-gesture voor afspelen blijft intact; de
    928     // direct gezette mqdefault toont al meteen een balkloze cover.
     946    // De scherpe cover + echte titel komen via pullYTMeta() zodra het nummer speelt.
     947  }
     948
     949  // Zet de bar-cover op de thumbnail van een video (mqdefault = balkloos), en
     950  // upgrade async naar de scherpe maxresdefault als die bestaat.
     951  function setYtCover(videoId) {
     952    if (!videoId) return;
     953    const mq = 'https://i.ytimg.com/vi/' + videoId + '/mqdefault.jpg';
     954    setCoverImage(cover, mq); setCoverImage(sheetCover, mq);
    929955    try {
    930       const maxres = 'https://i.ytimg.com/vi/' + opts.id + '/maxresdefault.jpg';
     956      const maxres = 'https://i.ytimg.com/vi/' + videoId + '/maxresdefault.jpg';
    931957      const im = new Image();
    932958      im.onload = () => {
    933         if (im.naturalWidth <= 120) return;  // 120x90 = YouTube-placeholder (geen maxres)
    934         const t = queue[currentIndex];
    935         if (t && t.yt === opts.id) { t.cover = maxres; setCoverImage(cover, maxres); setCoverImage(sheetCover, maxres); }
     959        if (im.naturalWidth <= 120) return;
     960        const cur = ytPlayer && ytPlayer.getVideoData ? (ytPlayer.getVideoData() || {}).video_id : null;
     961        if (cur === videoId) { setCoverImage(cover, maxres); setCoverImage(sheetCover, maxres); }
    936962      };
    937963      im.src = maxres;
  • src/assets/js/embed-player.js

    rb56afa7 rd43ea3f  
    161161    const url = el.dataset.embedUrl || '';
    162162    if (!provider) return;
     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    }
    163169
    164170    el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider);
     
    381387  function escAttr(s) {
    382388    return String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
     389  }
     390
     391  // Haal de video-IDs van een YouTube-afspeellijst op via een verborgen,
     392  // tijdelijke speler (cuePlaylist → getPlaylist). Geen API-key nodig.
     393  function ytPlaylistIds(listId) {
     394    return ytApi().then((YT) => new Promise((resolve) => {
     395      if (!YT || !YT.Player) { resolve([]); return; }
     396      const host = document.createElement('div');
     397      host.style.cssText = 'position:absolute;left:-99999px;top:0;width:320px;height:180px;opacity:0;pointer-events:none';
     398      const mount = document.createElement('div'); host.appendChild(mount); document.body.appendChild(host);
     399      let done = false;
     400      const finish = (ids) => { if (done) return; done = true; try { p.destroy(); } catch (e) {} try { host.remove(); } catch (e) {} resolve(ids || []); };
     401      const p = new YT.Player(mount, {
     402        host: 'https://www.youtube-nocookie.com', playerVars: { playsinline: 1 },
     403        events: { onReady: () => {
     404          try { p.cuePlaylist({ list: listId }); } catch (e) {}
     405          setTimeout(() => { let ids = []; try { ids = p.getPlaylist() || []; } catch (e) {} finish(ids); }, 2500);
     406        } },
     407      });
     408      setTimeout(() => finish([]), 9000);
     409    }));
     410  }
     411
     412  // Render een audio-only YouTube-AFSPEELLIJST als album-kaart (zelfde stijl als
     413  // de eigen albums) met tracklijst; afspelen loopt via de site-audiospeler.
     414  async function buildYtAlbum(el, listId) {
     415    el.className = 'folio-embed pcms-ytalbum';  // geen losse card-chrome; .post-album stuurt de stijl
     416    const thumb = (id) => `https://i.ytimg.com/vi/${id}/mqdefault.jpg`;
     417    el.innerHTML = ''
     418      + '<div class="post-album">'
     419      +   '<div class="post-album-header">'
     420      +     '<button type="button" class="post-album-cover-btn" aria-label="Album afspelen">'
     421      +       '<img class="post-album-cover-img" src="" alt="">'
     422      +       '<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>'
     423      +     '</button>'
     424      +     '<div class="post-album-info">'
     425      +       '<h3 class="post-album-title">Album laden…</h3>'
     426      +       '<p class="post-album-artist"></p>'
     427      +       '<p class="post-album-count">YouTube</p>'
     428      +     '</div>'
     429      +   '</div>'
     430      +   '<ol class="post-album-tracks"><li class="pcms-ytalbum-msg">Tracks laden…</li></ol>'
     431      + '</div>';
     432    const coverImg = el.querySelector('.post-album-cover-img');
     433    const titleEl = el.querySelector('.post-album-title');
     434    const countEl = el.querySelector('.post-album-count');
     435    const olEl = el.querySelector('.post-album-tracks');
     436    const coverBtn = el.querySelector('.post-album-cover-btn');
     437
     438    // Albumnaam via de oEmbed-proxy (playlist-url).
     439    fetch('/audio/yt-title?url=' + encodeURIComponent('https://www.youtube.com/playlist?list=' + listId))
     440      .then((r) => (r.ok ? r.json() : null)).then((d) => { if (d && d.title) titleEl.textContent = d.title; }).catch(() => {});
     441
     442    let ids = [];
     443    try { ids = await ytPlaylistIds(listId); } catch (e) { ids = []; }
     444    if (!ids.length) { olEl.innerHTML = '<li class="pcms-ytalbum-msg">Kon de afspeellijst niet laden.</li>'; return; }
     445    countEl.textContent = ids.length + ' track' + (ids.length === 1 ? '' : 's');
     446    if (coverImg) coverImg.src = thumb(ids[0]);
     447
     448    olEl.innerHTML = ids.map((id, i) =>
     449      `<li class="post-album-track-compact" id="track-${id}" data-yt-id="${id}" data-yt-index="${i}">`
     450      + `<button type="button" class="pat-row" aria-label="Speel track ${i + 1}">`
     451      +   `<span class="pat-cover" style="background-image:url('${thumb(id)}')" aria-hidden="true"></span>`
     452      +   `<span class="pat-meta"><span class="pat-title">Track ${i + 1}</span><span class="pat-artist"></span></span>`
     453      +   `<span class="pat-duration pat-duration-empty">—:—</span>`
     454      + `</button></li>`
     455    ).join('');
     456
     457    // Tracktitels lazy invullen (oEmbed-proxy per video).
     458    ids.forEach((id, i) => {
     459      fetch('/audio/yt-title?url=' + encodeURIComponent('https://www.youtube.com/watch?v=' + id))
     460        .then((r) => (r.ok ? r.json() : null))
     461        .then((d) => { if (d && d.title) { const tt = olEl.querySelector(`[data-yt-index="${i}"] .pat-title`); if (tt) tt.textContent = d.title; } })
     462        .catch(() => {});
     463    });
     464
     465    function playAt(i) {
     466      if (!(window.pcmsAudioPlayer && window.pcmsAudioPlayer.playYouTube)) return;
     467      const tt = (olEl.querySelector(`[data-yt-index="${i}"] .pat-title`) || {}).textContent || '';
     468      window.pcmsAudioPlayer.playYouTube({ list: listId, index: i, title: tt, cover: thumb(ids[i]), postUrl: location.pathname + location.search });
     469    }
     470    coverBtn.addEventListener('click', () => playAt(0));
     471    olEl.addEventListener('click', (e) => { const li = e.target.closest('[data-yt-index]'); if (li) playAt(parseInt(li.dataset.ytIndex, 10) || 0); });
    383472  }
    384473
  • src/views/shell.ejs

    rb56afa7 rd43ea3f  
    170170<link rel="stylesheet" href="/assets/css/audio.css?v=6">
    171171<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) in huisstijl. -->
    172 <link rel="stylesheet" href="/assets/css/embed.css?v=6">
     172<link rel="stylesheet" href="/assets/css/embed.css?v=7">
    173173
    174174<%- include('partials/shared-styles') %>
     
    300300     ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
    301301     Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
    302 <script src="/assets/js/audio-player.js?v=22"></script>
     302<script src="/assets/js/audio-player.js?v=23"></script>
    303303<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte
    304304     player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
    305 <script src="/assets/js/embed-player.js?v=11" defer></script>
     305<script src="/assets/js/embed-player.js?v=12" defer></script>
    306306<% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
    307307  <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
Note: See TracChangeset for help on using the changeset viewer.