Changeset 9e6b54f in Klonkt


Ignore:
Timestamp:
06/20/2026 03:53:58 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
976d36d
Parents:
73b41eb
Message:

feat(player): YouTube audio via the site audio player (continuous play + bottom bar)

Audio-only YouTube embeds now play through the persistent site audio player
instead of a per-card player:

  • audio-player.js gets a second backend: a hidden YT IFrame player in the (persistent) player root. loadTrack/play/pause/seek/volume/mute/progress/ended are backend-aware (ytMode). New API: pcmsAudioPlayer.playYouTube({id,...}).
  • embed-player.js: an audio-only YT embed is now a launcher that calls pcmsAudioPlayer.playYouTube (no own control bar anymore).

Result: YouTube audio appears in the bottom bar, keeps playing during htmx
navigation, and mutual exclusion with the site tracks is automatic (one player).
busters audio-player.js?v=21, embed-player.js?v=9, embed.css?v=6.

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

Location:
src
Files:
4 edited

Legend:

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

    r73b41eb r9e6b54f  
    122122  pointer-events: none;
    123123}
     124/* Verborgen YouTube-audio-speler ván de site-audiospeler (onderbalk). Off-screen
     125 * 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/* Audio-only embed-kaart die nu via de onderbalk speelt = launcher-hint. */
     136.pcms-embed-elsewhere { opacity: .9; }
     137.pcms-embed-elsewhere .pcms-embed-title::after {
     138  content: ' — speelt in de speler ↓';
     139  font-weight: normal;
     140  opacity: .6;
     141  font-size: .85em;
     142}
     143
    124144/* Audio-only YouTube: de YT-speler draait verborgen (off-screen) maar moet een
    125145 * echte grootte houden, anders pauzeert de browser 'm. Alleen het geluid telt. */
  • src/assets/js/audio-player.js

    r73b41eb r9e6b54f  
    289289  });
    290290
     291  // ============================================================
     292  // 4a. YouTube-backend — speelt YT-AUDIO via een verborgen IFrame-speler in de
     293  // (persistente) speler-root. Zo speelt YouTube net als de site-tracks door bij
     294  // 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 ended
     337    if (s === 1) {
     338      isPlaying = true; root.classList.add('is-playing'); root.classList.remove('audio-needs-tap');
     339      mediaRegistry().setActive(registrySelf); startYTTick(); pullYTMeta();
     340    } else if (s === 2) {
     341      isPlaying = false; root.classList.remove('is-playing'); stopYTTick();
     342    } else if (s === 0) {
     343      stopYTTick(); next();
     344    }
     345  }
     346
     347  function startYTTick() {
     348    if (ytTick) return;
     349    ytTick = setInterval(() => {
     350      if (!ytPlayer || !ytMode) return;
     351      try { updateProgressUI(ytPlayer.getCurrentTime() || 0, ytPlayer.getDuration() || 0); } catch (e) {}
     352    }, 250);
     353  }
     354  function stopYTTick() { if (ytTick) { clearInterval(ytTick); ytTick = null; } }
     355
     356  function pullYTMeta() {
     357    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);
     362      }
     363    } catch (e) {}
     364  }
     365
     366  // Gedeelde voortgang-UI (beide backends). Audio gebruikt 'timeupdate', YT de tick.
     367  function updateProgressUI(cur, dur) {
     368    if (!dur || isNaN(dur)) return;
     369    const pct = Math.max(0, Math.min(100, (cur / dur) * 100));
     370    seekFill.style.width = pct + '%'; sheetSeekFill.style.width = pct + '%';
     371    currentEl.textContent = formatTime(cur); totalEl.textContent = formatTime(dur);
     372    sheetCurrent.textContent = formatTime(cur); sheetTotal.textContent = formatTime(dur);
     373  }
     374
    291375  function loadTrack(index, autoplay, metaOnly) {
    292376    if (!queue[index]) {
     
    296380    currentIndex = index;
    297381    const t = queue[index];
    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';
     382    // Metadata + chrome update synchronously (geldt voor beide backends).
     383    titleEl.textContent  = t.title  || (t.yt ? 'YouTube' : 'Untitled');
    306384    artistEl.textContent = t.artist || '';
    307     sheetTitle.textContent  = t.title  || 'Untitled';
     385    sheetTitle.textContent  = t.title  || (t.yt ? 'YouTube' : 'Untitled');
    308386    sheetArtist.textContent = t.artist || '';
    309387    sheetAlbum.textContent  = albumName || '';
     
    316394
    317395    if (metaOnly) return;
     396
     397    // YouTube-bron → verborgen YT-speler i.p.v. de blob-<audio>.
     398    if (t.yt) {
     399      ytMode = true;
     400      try { audio.pause(); } catch (e) {}
     401      loadSeq++; dropPreload();
     402      updateProgressUI(0, 1); currentEl.textContent = '0:00'; totalEl.textContent = '0:00';
     403      seekFill.style.width = '0%'; sheetSeekFill.style.width = '0%';
     404      root.classList.add('audio-loading');
     405      ensureYT().then((p) => {
     406        if (queue[currentIndex] !== t) return;  // inmiddels andere track gekozen
     407        root.classList.remove('audio-loading');
     408        if (!p) { onLoadError(new Error('YT API niet beschikbaar'), loadSeq); return; }
     409        try {
     410          if (autoplay) p.loadVideoById(t.yt); else p.cueVideoById(t.yt);
     411          p.setVolume(audio.muted ? 0 : Math.round(audio.volume * 100));
     412        } catch (e) {}
     413      });
     414      return;
     415    }
     416
     417    // Normale blob-audio track.
     418    ytMode = false;
     419    stopYTTick();
     420    try { if (ytPlayer && ytPlayer.stopVideo) ytPlayer.stopVideo(); } catch (e) {}
     421    if (!t.url) { console.error('[pcms-audio] track has no url', t); return; }
    318422
    319423    const mySeq = ++loadSeq;
     
    367471
    368472  function play() {
     473    if (ytMode) {
     474      if (ytPlayer && ytReady) { try { ytPlayer.playVideo(); } catch (e) {} }
     475      else if (queue[currentIndex]) loadTrack(currentIndex, true);
     476      return;
     477    }
    369478    if (!audio.src) {
    370479      // Nothing fetched yet (pre-seed showed metadata only, or a load is still
     
    388497    }
    389498  }
    390   function pause()      { audio.pause(); }
    391   function togglePlay() { audio.paused ? play() : pause(); }
     499  function pause()      { if (ytMode) { try { ytPlayer && ytPlayer.pauseVideo(); } catch (e) {} return; } audio.pause(); }
     500  function togglePlay() { if (ytMode) { isPlaying ? pause() : play(); return; } audio.paused ? play() : pause(); }
    392501  function next() {
    393502    if (!queue.length) return;
     
    404513    document.body.classList.remove('has-audio-player');
    405514    closeSheet();
     515    ytMode = false; stopYTTick();
     516    try { if (ytPlayer && ytPlayer.stopVideo) ytPlayer.stopVideo(); } catch (e) {}
    406517    queue = [];
    407518    albumName = '';
     
    462573    return r;
    463574  }
    464   const registrySelf = { pause() { try { audio.pause(); } catch (e) {} } };
     575  const registrySelf = { pause() { try { audio.pause(); } catch (e) {} try { if (ytPlayer && ytPlayer.pauseVideo) ytPlayer.pauseVideo(); } catch (e) {} } };
    465576
    466577  // ============================================================
     
    498609  audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
    499610  audio.addEventListener('timeupdate', () => {
    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);
     611    if (ytMode) return;  // YT-voortgang loopt via de tick
     612    updateProgressUI(audio.currentTime, audio.duration);
    508613  });
    509614
     
    516621  function attachSeek(seekEl) {
    517622    seekEl.addEventListener('click', (e) => {
    518       if (!audio.duration) return;
    519623      const rect = seekEl.getBoundingClientRect();
    520624      const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
     625      if (ytMode) {
     626        try { const d = ytPlayer.getDuration() || 0; if (d) ytPlayer.seekTo(ratio * d, true); } catch (e2) {}
     627        return;
     628      }
     629      if (!audio.duration) return;
    521630      audio.currentTime = ratio * audio.duration;
    522631    });
     
    527636  audio.volume = 0.8;
    528637  volumeSlider.addEventListener('input', () => {
    529     audio.volume = volumeSlider.value / 100;
    530     if (volumeSlider.value > 0) audio.muted = false;
    531   });
    532   muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; });
     638    const v = parseInt(volumeSlider.value, 10) || 0;
     639    audio.volume = v / 100;
     640    if (v > 0) audio.muted = false;
     641    if (ytPlayer) { try { ytPlayer.setVolume(v); if (v > 0 && ytPlayer.unMute) ytPlayer.unMute(); } catch (e) {} }
     642  });
     643  muteBtn.addEventListener('click', () => {
     644    audio.muted = !audio.muted;
     645    if (ytPlayer) { try { audio.muted ? ytPlayer.mute() : ytPlayer.unMute(); } catch (e) {} }
     646    root.classList.toggle('is-muted', audio.muted);
     647  });
    533648
    534649  // ============================================================
     
    797912  // 9. Public API
    798913  // ============================================================
     914  // Speel een YouTube-bron als AUDIO af in deze (persistente) site-speler — door
     915  // de embed-speler aangeroepen voor een audio-only YouTube-embed. Zo verschijnt
     916  // 'ie in de onderbalk én speelt 'ie door bij navigeren.
     917  function playYouTube(opts) {
     918    if (!opts || !opts.id) return;
     919    setQueue([{
     920      yt: opts.id,
     921      title: opts.title || 'YouTube',
     922      artist: opts.artist || '',
     923      cover: opts.cover || '',
     924      postUrl: opts.postUrl || '',
     925    }], 0);
     926  }
     927
    799928  window.pcmsAudioPlayer = {
    800     setQueue, play, pause, next, prev, close, openSheet, closeSheet,
     929    setQueue, play, pause, next, prev, close, openSheet, closeSheet, playYouTube,
    801930    isPlaying: () => isPlaying,
    802931    currentTrack: () => queue[currentIndex] || null,
  • src/assets/js/embed-player.js

    r73b41eb r9e6b54f  
    189189    if (audioOnly) el.classList.add('pcms-embed-audio-mode');
    190190    const isVideo = provider === 'youtube' && !audioOnly;
    191     const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls
     191    // Audio-only YT speelt via de site-onderbalk → geen eigen in-kaart controlebar.
     192    const custom = (provider === 'youtube' || provider === 'soundcloud') && !audioOnly;
    192193    const poster = provider === 'youtube'
    193194      ? `https://i.ytimg.com/vi/${ytId(ref, url)}/hqdefault.jpg` : '';
     
    306307    // Eerste interactie → adapter mounten + spelen. Daarna toggelt de knop.
    307308    async function ensureMountedAndPlay() {
     309      // Audio-only YouTube → speel via de site-audiospeler (onderbalk): zichtbaar
     310      // én doorspelen bij navigeren. De inline-kaart is enkel de launcher.
     311      if (audioOnly && window.pcmsAudioPlayer && typeof window.pcmsAudioPlayer.playYouTube === 'function') {
     312        window.pcmsAudioPlayer.playYouTube({ id: ytId(ref, url), cover: poster, postUrl: location.pathname + location.search });
     313        el.classList.add('pcms-embed-elsewhere');
     314        return;
     315      }
    308316      if (mounted) { if (adapter) adapter.play(); return; }
    309317      mounted = true;
  • src/views/shell.ejs

    r73b41eb r9e6b54f  
    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=5">
     172<link rel="stylesheet" href="/assets/css/embed.css?v=6">
    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=20"></script>
     302<script src="/assets/js/audio-player.js?v=21"></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=8" defer></script>
     305<script src="/assets/js/embed-player.js?v=9" 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.