| 1 | /**
|
|---|
| 2 | * Klonkt Audio Player — v9 mini-player + Spotify-style sheet.
|
|---|
| 3 | *
|
|---|
| 4 | * Two surfaces:
|
|---|
| 5 | * 1. .audio-player — bottom strip: cover + meta + controls + progress + volume
|
|---|
| 6 | * 2. .audio-sheet — full-height now-playing panel (slides up from bottom)
|
|---|
| 7 | *
|
|---|
| 8 | * Features:
|
|---|
| 9 | * - Click cover/meta on mini-player → open sheet
|
|---|
| 10 | * - Sheet handle (pill) or backdrop click → close
|
|---|
| 11 | * - Touch swipe-down on the drag-zone → close (mobile only; desktop has the X)
|
|---|
| 12 | * - Reads data-pcms-track-url + data-pcms-track + data-pcms-album from posts
|
|---|
| 13 | * - body.has-audio-player adds bottom padding when player visible
|
|---|
| 14 | * - body.audio-sheet-locked prevents body scroll when sheet open
|
|---|
| 15 | * - Survives HTMX swaps + history-restores via event delegation on document.body
|
|---|
| 16 | *
|
|---|
| 17 | * Singleton — guards against double-init.
|
|---|
| 18 | */
|
|---|
| 19 | (function() {
|
|---|
| 20 | if (window.pcmsAudioPlayer) return;
|
|---|
| 21 |
|
|---|
| 22 | const SVG = {
|
|---|
| 23 | play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>',
|
|---|
| 24 | pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4h4v16H7zM13 4h4v16h-4z" fill="currentColor"/></svg>',
|
|---|
| 25 | prev: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5v14M20 5l-11 7 11 7V5z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|---|
| 26 | next: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 5v14M4 5l11 7-11 7V5z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|---|
| 27 | vol: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zM16 8a5 5 0 010 8M19 5a9 9 0 010 14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|---|
| 28 | mute: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zM17 9l5 5M22 9l-5 5" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|---|
| 29 | musicNote: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 17V5l12-2v12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="17" r="3" fill="currentColor"/><circle cx="18" cy="15" r="3" fill="currentColor"/></svg>',
|
|---|
| 30 | };
|
|---|
| 31 |
|
|---|
| 32 | // ============================================================
|
|---|
| 33 | // 1. Build DOM
|
|---|
| 34 | // ============================================================
|
|---|
| 35 | const root = document.createElement('aside');
|
|---|
| 36 | root.id = 'pcms-audio-player';
|
|---|
| 37 | root.className = 'audio-player';
|
|---|
| 38 | root.setAttribute('aria-label', 'Audio speler');
|
|---|
| 39 | root.innerHTML = `
|
|---|
| 40 | <div class="audio-player-inner">
|
|---|
| 41 | <button type="button" class="audio-player-expand-trigger" id="audio-expand-trigger" aria-label="Vergroot speler">
|
|---|
| 42 | <div class="audio-player-cover" id="audio-cover" aria-hidden="true">${SVG.musicNote}</div>
|
|---|
| 43 | <div class="audio-player-meta">
|
|---|
| 44 | <div class="audio-player-title-wrap"><span class="audio-player-title" id="audio-title">No track</span></div>
|
|---|
| 45 | <div class="audio-player-artist" id="audio-artist"></div>
|
|---|
| 46 | </div>
|
|---|
| 47 | </button>
|
|---|
| 48 | <div class="audio-player-controls">
|
|---|
| 49 | <button type="button" class="audio-btn" id="audio-prev" aria-label="Vorige" title="Vorige">${SVG.prev}</button>
|
|---|
| 50 | <button type="button" class="audio-btn audio-btn-play" id="audio-play" aria-label="Afspelen">
|
|---|
| 51 | <span class="icon-play">${SVG.play}</span><span class="icon-pause">${SVG.pause}</span>
|
|---|
| 52 | </button>
|
|---|
| 53 | <button type="button" class="audio-btn" id="audio-next" aria-label="Volgende" title="Volgende">${SVG.next}</button>
|
|---|
| 54 | </div>
|
|---|
| 55 | <div class="audio-player-progress">
|
|---|
| 56 | <span class="audio-time mono" id="audio-current">0:00</span>
|
|---|
| 57 | <div class="audio-seek" id="audio-seek" role="slider" aria-label="Voortgang" tabindex="0">
|
|---|
| 58 | <div class="audio-seek-bar"><div class="audio-seek-fill" id="audio-seek-fill"></div></div>
|
|---|
| 59 | </div>
|
|---|
| 60 | <span class="audio-time mono" id="audio-total">0:00</span>
|
|---|
| 61 | </div>
|
|---|
| 62 | <div class="audio-player-volume">
|
|---|
| 63 | <button type="button" class="audio-btn" id="audio-mute" aria-label="Mute">
|
|---|
| 64 | <span class="icon-vol">${SVG.vol}</span><span class="icon-mute">${SVG.mute}</span>
|
|---|
| 65 | </button>
|
|---|
| 66 | <div class="audio-volume-popup">
|
|---|
| 67 | <input type="range" id="audio-volume" min="0" max="100" value="80" aria-label="Volume">
|
|---|
| 68 | </div>
|
|---|
| 69 | </div>
|
|---|
| 70 | </div>
|
|---|
| 71 | <audio id="audio-element" preload="none" playsinline webkit-playsinline controlsList="nodownload"></audio>
|
|---|
| 72 |
|
|---|
| 73 | <div class="audio-sheet" id="audio-sheet" aria-hidden="true">
|
|---|
| 74 | <div class="audio-sheet-backdrop" id="audio-sheet-backdrop"></div>
|
|---|
| 75 | <div class="audio-sheet-panel" role="dialog" aria-label="Now playing">
|
|---|
| 76 | <div class="audio-sheet-drag-zone" id="audio-sheet-drag-zone">
|
|---|
| 77 | <button type="button" class="audio-sheet-handle" id="audio-sheet-close" aria-label="Speler verkleinen"></button>
|
|---|
| 78 | <div class="audio-sheet-cover" id="audio-sheet-cover" aria-hidden="true">${SVG.musicNote}</div>
|
|---|
| 79 | </div>
|
|---|
| 80 | <div class="audio-sheet-info">
|
|---|
| 81 | <div class="audio-sheet-title" id="audio-sheet-title">—</div>
|
|---|
| 82 | <div class="audio-sheet-artist" id="audio-sheet-artist"></div>
|
|---|
| 83 | <div class="audio-sheet-album" id="audio-sheet-album"></div>
|
|---|
| 84 | </div>
|
|---|
| 85 | <div class="audio-sheet-progress">
|
|---|
| 86 | <span class="audio-time mono" id="audio-sheet-current">0:00</span>
|
|---|
| 87 | <div class="audio-seek" id="audio-sheet-seek" role="slider" aria-label="Voortgang" tabindex="0">
|
|---|
| 88 | <div class="audio-seek-bar"><div class="audio-seek-fill" id="audio-sheet-seek-fill"></div></div>
|
|---|
| 89 | </div>
|
|---|
| 90 | <span class="audio-time mono" id="audio-sheet-total">0:00</span>
|
|---|
| 91 | </div>
|
|---|
| 92 | <div class="audio-sheet-controls">
|
|---|
| 93 | <button type="button" class="audio-btn audio-sheet-btn" id="audio-sheet-prev" aria-label="Vorige">${SVG.prev}</button>
|
|---|
| 94 | <button type="button" class="audio-btn audio-sheet-play" id="audio-sheet-play" aria-label="Afspelen">
|
|---|
| 95 | <span class="icon-play">${SVG.play}</span><span class="icon-pause">${SVG.pause}</span>
|
|---|
| 96 | </button>
|
|---|
| 97 | <button type="button" class="audio-btn audio-sheet-btn" id="audio-sheet-next" aria-label="Volgende">${SVG.next}</button>
|
|---|
| 98 | </div>
|
|---|
| 99 | <div class="audio-sheet-queue" id="audio-sheet-queue" hidden>
|
|---|
| 100 | <div class="audio-sheet-queue-label">Queue</div>
|
|---|
| 101 | <ol class="audio-sheet-queue-list" id="audio-sheet-queue-list"></ol>
|
|---|
| 102 | </div>
|
|---|
| 103 | </div>
|
|---|
| 104 | </div>
|
|---|
| 105 | `;
|
|---|
| 106 | document.body.appendChild(root);
|
|---|
| 107 |
|
|---|
| 108 | // FIX: Move .audio-sheet out of the .audio-player root so its
|
|---|
| 109 | // position:fixed is anchored to the viewport, not to the mini-bar.
|
|---|
| 110 | // The mini-bar has backdrop-filter, which makes it a containing
|
|---|
| 111 | // block for fixed descendants — that broke the sheet's left/right/top/bottom.
|
|---|
| 112 | const _detachedSheet = root.querySelector('.audio-sheet');
|
|---|
| 113 | if (_detachedSheet) document.body.appendChild(_detachedSheet);
|
|---|
| 114 |
|
|---|
| 115 | // ============================================================
|
|---|
| 116 | // 2. Element references
|
|---|
| 117 | // ============================================================
|
|---|
| 118 | const $ = (id) => document.getElementById(id);
|
|---|
| 119 | const audio = $('audio-element');
|
|---|
| 120 | const cover = $('audio-cover');
|
|---|
| 121 | const titleEl = $('audio-title');
|
|---|
| 122 | const artistEl = $('audio-artist');
|
|---|
| 123 | const seek = $('audio-seek');
|
|---|
| 124 | const seekFill = $('audio-seek-fill');
|
|---|
| 125 | const currentEl = $('audio-current');
|
|---|
| 126 | const totalEl = $('audio-total');
|
|---|
| 127 | const playBtn = $('audio-play');
|
|---|
| 128 | const prevBtn = $('audio-prev');
|
|---|
| 129 | const nextBtn = $('audio-next');
|
|---|
| 130 | const muteBtn = $('audio-mute');
|
|---|
| 131 | const volumeSlider = $('audio-volume');
|
|---|
| 132 | const expandTrigger = $('audio-expand-trigger');
|
|---|
| 133 |
|
|---|
| 134 | const sheet = $('audio-sheet');
|
|---|
| 135 | const sheetBackdrop = $('audio-sheet-backdrop');
|
|---|
| 136 | const sheetPanel = sheet.querySelector('.audio-sheet-panel');
|
|---|
| 137 | const sheetClose = $('audio-sheet-close');
|
|---|
| 138 | const sheetCover = $('audio-sheet-cover');
|
|---|
| 139 | const sheetTitle = $('audio-sheet-title');
|
|---|
| 140 | const sheetArtist = $('audio-sheet-artist');
|
|---|
| 141 | const sheetAlbum = $('audio-sheet-album');
|
|---|
| 142 | const sheetSeek = $('audio-sheet-seek');
|
|---|
| 143 | const sheetSeekFill = $('audio-sheet-seek-fill');
|
|---|
| 144 | const sheetCurrent = $('audio-sheet-current');
|
|---|
| 145 | const sheetTotal = $('audio-sheet-total');
|
|---|
| 146 | const sheetPlay = $('audio-sheet-play');
|
|---|
| 147 | const sheetPrev = $('audio-sheet-prev');
|
|---|
| 148 | const sheetNext = $('audio-sheet-next');
|
|---|
| 149 | const sheetQueue = $('audio-sheet-queue');
|
|---|
| 150 | const sheetQueueList = $('audio-sheet-queue-list');
|
|---|
| 151 | const dragZone = $('audio-sheet-drag-zone');
|
|---|
| 152 |
|
|---|
| 153 | // ============================================================
|
|---|
| 154 | // 3. State
|
|---|
| 155 | // ============================================================
|
|---|
| 156 | let queue = [];
|
|---|
| 157 | let currentIndex = 0;
|
|---|
| 158 | let isPlaying = false;
|
|---|
| 159 | let albumName = '';
|
|---|
| 160 | // Blob playback state. We fetch each track's bytes and play from a blob:
|
|---|
| 161 | // object URL — no plain media URL is ever exposed to the page. currentObjectUrl
|
|---|
| 162 | // is revoked when we move on, so we don't leak one Blob per track in memory.
|
|---|
| 163 | let currentObjectUrl = null;
|
|---|
| 164 | // Monotonic load token: a fast prev/next can fire several loads before an
|
|---|
| 165 | // earlier fetch resolves. Only the latest load may set audio.src.
|
|---|
| 166 | let loadSeq = 0;
|
|---|
| 167 | // Next-track prefetch. While the current track plays we download the *next*
|
|---|
| 168 | // track's bytes into a held blob, so `ended` → next() can swap src instantly
|
|---|
| 169 | // (no silent gap, and no long async window where the browser's autoplay
|
|---|
| 170 | // activation can lapse and reject play()). At most one track ahead is held.
|
|---|
| 171 | // Shape: { url, objUrl } — objUrl is null while the fetch is still in flight.
|
|---|
| 172 | let preload = null;
|
|---|
| 173 |
|
|---|
| 174 | // Hide initially
|
|---|
| 175 | root.classList.add('audio-player-hidden');
|
|---|
| 176 |
|
|---|
| 177 | // ============================================================
|
|---|
| 178 | // 4. Track / queue loading
|
|---|
| 179 | // ============================================================
|
|---|
| 180 | function setCoverImage(el, url) {
|
|---|
| 181 | if (url) {
|
|---|
| 182 | el.style.backgroundImage = `url("${url}")`;
|
|---|
| 183 | el.classList.add('has-image');
|
|---|
| 184 | el.innerHTML = '';
|
|---|
| 185 | } else {
|
|---|
| 186 | el.style.backgroundImage = '';
|
|---|
| 187 | el.classList.remove('has-image');
|
|---|
| 188 | el.innerHTML = SVG.musicNote;
|
|---|
| 189 | }
|
|---|
| 190 | }
|
|---|
| 191 |
|
|---|
| 192 | // Fetch the track bytes and hand back a blob: object URL. The X-Audio-Player
|
|---|
| 193 | // header + same-origin credentials get us past the stream route's access gate.
|
|---|
| 194 | // Retries a few times with backoff: a single transient network blip used to
|
|---|
| 195 | // bump the error counter and SKIP the song (auto-advance past it). Now one
|
|---|
| 196 | // hiccup just costs a retry, and we only give up after genuinely failing.
|
|---|
| 197 | async function fetchAsObjectUrl(url, attempts) {
|
|---|
| 198 | attempts = attempts || 1;
|
|---|
| 199 | let lastErr;
|
|---|
| 200 | for (let i = 0; i < attempts; i++) {
|
|---|
| 201 | try {
|
|---|
| 202 | const r = await fetch(url, {
|
|---|
| 203 | credentials: 'same-origin',
|
|---|
| 204 | headers: { 'X-Audio-Player': '1' },
|
|---|
| 205 | });
|
|---|
| 206 | if (!r.ok) throw new Error('HTTP ' + r.status);
|
|---|
| 207 | const blob = await r.blob();
|
|---|
| 208 | return URL.createObjectURL(blob);
|
|---|
| 209 | } catch (e) {
|
|---|
| 210 | lastErr = e;
|
|---|
| 211 | if (i < attempts - 1) {
|
|---|
| 212 | await new Promise((res) => setTimeout(res, 350 * (i + 1)));
|
|---|
| 213 | }
|
|---|
| 214 | }
|
|---|
| 215 | }
|
|---|
| 216 | throw lastErr;
|
|---|
| 217 | }
|
|---|
| 218 |
|
|---|
| 219 | // Apply a ready blob URL to the <audio> element. Single source of truth for
|
|---|
| 220 | // "swap the playing source": used by both the cached-preload path and the
|
|---|
| 221 | // fresh-fetch path so there's one place that touches audio.src.
|
|---|
| 222 | function applyBlob(objUrl, autoplay, mySeq) {
|
|---|
| 223 | if (mySeq !== loadSeq) { try { URL.revokeObjectURL(objUrl); } catch (e) {} return; } // superseded
|
|---|
| 224 | root.classList.remove('audio-loading');
|
|---|
| 225 | // Free the previously-playing track's blob — otherwise each track leaks a
|
|---|
| 226 | // copy. Never the same handle as objUrl (createObjectURL is unique), so this
|
|---|
| 227 | // can't revoke the source we're about to play.
|
|---|
| 228 | if (currentObjectUrl && currentObjectUrl !== objUrl) {
|
|---|
| 229 | try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
|
|---|
| 230 | }
|
|---|
| 231 | currentObjectUrl = objUrl;
|
|---|
| 232 | // Schone overgang: pause + load forceert reset van internal state na
|
|---|
| 233 | // meerdere src-changes (voorkomt state-corruption van het audio-element).
|
|---|
| 234 | try { audio.pause(); } catch (e) {}
|
|---|
| 235 | audio.src = objUrl;
|
|---|
| 236 | try { audio.load(); } catch (e) {}
|
|---|
| 237 | if (autoplay) play();
|
|---|
| 238 | }
|
|---|
| 239 |
|
|---|
| 240 | function onLoadError(err, mySeq) {
|
|---|
| 241 | if (mySeq !== loadSeq) return; // superseded — ignore stale failure
|
|---|
| 242 | root.classList.remove('audio-loading');
|
|---|
| 243 | console.error('[pcms-audio] track load failed after retries', err);
|
|---|
| 244 | // Genuine failure (after retries): bump the counter and auto-skip, but stop
|
|---|
| 245 | // after 3 in a row so a fully-broken queue can't loop "next" forever.
|
|---|
| 246 | consecutiveErrors++;
|
|---|
| 247 | if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
|
|---|
| 248 | }
|
|---|
| 249 |
|
|---|
| 250 | // Discard any held/in-flight preload and free its blob if resolved.
|
|---|
| 251 | function dropPreload() {
|
|---|
| 252 | if (preload && preload.objUrl) { try { URL.revokeObjectURL(preload.objUrl); } catch (e) {} }
|
|---|
| 253 | preload = null;
|
|---|
| 254 | }
|
|---|
| 255 |
|
|---|
| 256 | // Prefetch the *next* track's bytes in the background. Idempotent: re-calling
|
|---|
| 257 | // while the same track is already cached / in flight is a no-op. Called from
|
|---|
| 258 | // the `playing` event so the network is otherwise idle.
|
|---|
| 259 | function preloadNext() {
|
|---|
| 260 | if (queue.length < 2) return;
|
|---|
| 261 | const ni = (currentIndex + 1) % queue.length;
|
|---|
| 262 | const t = queue[ni];
|
|---|
| 263 | if (!t || !t.url) return;
|
|---|
| 264 | if (preload && preload.url === t.url) return; // already held or in flight
|
|---|
| 265 | dropPreload(); // different track queued before → free it
|
|---|
| 266 | const marker = { url: t.url, objUrl: null };
|
|---|
| 267 | preload = marker;
|
|---|
| 268 | fetchAsObjectUrl(t.url, 2).then((obj) => {
|
|---|
| 269 | // Only keep it if this is still the track we want next; otherwise free it.
|
|---|
| 270 | if (preload === marker) { marker.objUrl = obj; }
|
|---|
| 271 | else { try { URL.revokeObjectURL(obj); } catch (e) {} }
|
|---|
| 272 | }).catch(() => { if (preload === marker) preload = null; });
|
|---|
| 273 | }
|
|---|
| 274 |
|
|---|
| 275 | // metaOnly: show the track in the UI but DON'T download its bytes yet.
|
|---|
| 276 | // Used by the site pre-seed so opening a page doesn't auto-download audio;
|
|---|
| 277 | // the blob is fetched lazily on the first play().
|
|---|
| 278 | // Markeer de huidige track persistent (blijvende highlight zolang 'ie actief is).
|
|---|
| 279 | function markPlaying(trackId) {
|
|---|
| 280 | document.querySelectorAll('.pat-playing').forEach((e) => e.classList.remove('pat-playing'));
|
|---|
| 281 | if (!trackId) return;
|
|---|
| 282 | const el = document.getElementById('track-' + trackId);
|
|---|
| 283 | if (el) el.classList.add('pat-playing');
|
|---|
| 284 | }
|
|---|
| 285 | // Na een htmx-navigatie is de post-DOM vervangen → highlight opnieuw zetten.
|
|---|
| 286 | document.body.addEventListener('htmx:afterSettle', () => {
|
|---|
| 287 | const t = queue[currentIndex];
|
|---|
| 288 | if (t) markPlaying(t.id);
|
|---|
| 289 | });
|
|---|
| 290 |
|
|---|
| 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();
|
|---|
| 344 | // Bij een afspeellijst regelt YouTube zelf het doorschakelen → niet dubbel.
|
|---|
| 345 | const t = queue[currentIndex];
|
|---|
| 346 | if (!(t && t.ytList)) next();
|
|---|
| 347 | }
|
|---|
| 348 | }
|
|---|
| 349 |
|
|---|
| 350 | function startYTTick() {
|
|---|
| 351 | if (ytTick) return;
|
|---|
| 352 | ytTick = setInterval(() => {
|
|---|
| 353 | if (!ytPlayer || !ytMode) return;
|
|---|
| 354 | try { updateProgressUI(ytPlayer.getCurrentTime() || 0, ytPlayer.getDuration() || 0); } catch (e) {}
|
|---|
| 355 | }, 250);
|
|---|
| 356 | }
|
|---|
| 357 | function stopYTTick() { if (ytTick) { clearInterval(ytTick); ytTick = null; } }
|
|---|
| 358 |
|
|---|
| 359 | function pullYTMeta() {
|
|---|
| 360 | try {
|
|---|
| 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>")
|
|---|
| 371 | }
|
|---|
| 372 | } catch (e) {}
|
|---|
| 373 | }
|
|---|
| 374 |
|
|---|
| 375 | // Gedeelde voortgang-UI (beide backends). Audio gebruikt 'timeupdate', YT de tick.
|
|---|
| 376 | function updateProgressUI(cur, dur) {
|
|---|
| 377 | if (!dur || isNaN(dur)) return;
|
|---|
| 378 | const pct = Math.max(0, Math.min(100, (cur / dur) * 100));
|
|---|
| 379 | seekFill.style.width = pct + '%'; sheetSeekFill.style.width = pct + '%';
|
|---|
| 380 | currentEl.textContent = formatTime(cur); totalEl.textContent = formatTime(dur);
|
|---|
| 381 | sheetCurrent.textContent = formatTime(cur); sheetTotal.textContent = formatTime(dur);
|
|---|
| 382 | }
|
|---|
| 383 |
|
|---|
| 384 | function loadTrack(index, autoplay, metaOnly) {
|
|---|
| 385 | if (!queue[index]) {
|
|---|
| 386 | console.warn('[pcms-audio] loadTrack: no track at index', index);
|
|---|
| 387 | return;
|
|---|
| 388 | }
|
|---|
| 389 | currentIndex = index;
|
|---|
| 390 | const t = queue[index];
|
|---|
| 391 | // Metadata + chrome update synchronously (geldt voor beide backends).
|
|---|
| 392 | titleEl.textContent = t.title || (t.yt ? 'YouTube' : 'Untitled');
|
|---|
| 393 | artistEl.textContent = t.artist || '';
|
|---|
| 394 | sheetTitle.textContent = t.title || (t.yt ? 'YouTube' : 'Untitled');
|
|---|
| 395 | sheetArtist.textContent = t.artist || '';
|
|---|
| 396 | sheetAlbum.textContent = albumName || '';
|
|---|
| 397 | setCoverImage(cover, t.cover);
|
|---|
| 398 | setCoverImage(sheetCover, t.cover);
|
|---|
| 399 | root.classList.remove('audio-player-hidden');
|
|---|
| 400 | document.body.classList.add('has-audio-player');
|
|---|
| 401 | renderQueue();
|
|---|
| 402 | markPlaying(t.id);
|
|---|
| 403 |
|
|---|
| 404 | if (metaOnly) return;
|
|---|
| 405 |
|
|---|
| 406 | // YouTube-bron (los nummer óf afspeellijst/album) → verborgen YT-speler.
|
|---|
| 407 | if (t.yt || t.ytList) {
|
|---|
| 408 | ytMode = true;
|
|---|
| 409 | try { audio.pause(); } catch (e) {}
|
|---|
| 410 | loadSeq++; dropPreload();
|
|---|
| 411 | updateProgressUI(0, 1); currentEl.textContent = '0:00'; totalEl.textContent = '0:00';
|
|---|
| 412 | seekFill.style.width = '0%'; sheetSeekFill.style.width = '0%';
|
|---|
| 413 | root.classList.add('audio-loading');
|
|---|
| 414 | ensureYT().then((p) => {
|
|---|
| 415 | if (queue[currentIndex] !== t) return; // inmiddels andere track gekozen
|
|---|
| 416 | root.classList.remove('audio-loading');
|
|---|
| 417 | if (!p) { onLoadError(new Error('YT API niet beschikbaar'), loadSeq); return; }
|
|---|
| 418 | try {
|
|---|
| 419 | if (t.ytList) {
|
|---|
| 420 | const idx = t.ytIndex || 0;
|
|---|
| 421 | // listType:'playlist' is vereist voor de object-vorm; zonder dit laadt
|
|---|
| 422 | // (en speelt) de afspeellijst niet betrouwbaar.
|
|---|
| 423 | if (autoplay) p.loadPlaylist({ listType: 'playlist', list: t.ytList, index: idx });
|
|---|
| 424 | else p.cuePlaylist({ listType: 'playlist', list: t.ytList, index: idx });
|
|---|
| 425 | } else {
|
|---|
| 426 | if (autoplay) p.loadVideoById(t.yt); else p.cueVideoById(t.yt);
|
|---|
| 427 | }
|
|---|
| 428 | p.setVolume(audio.muted ? 0 : Math.round(audio.volume * 100));
|
|---|
| 429 | } catch (e) {}
|
|---|
| 430 | });
|
|---|
| 431 | return;
|
|---|
| 432 | }
|
|---|
| 433 |
|
|---|
| 434 | // Normale blob-audio track.
|
|---|
| 435 | ytMode = false;
|
|---|
| 436 | stopYTTick();
|
|---|
| 437 | try { if (ytPlayer && ytPlayer.stopVideo) ytPlayer.stopVideo(); } catch (e) {}
|
|---|
| 438 | if (!t.url) { console.error('[pcms-audio] track has no url', t); return; }
|
|---|
| 439 |
|
|---|
| 440 | const mySeq = ++loadSeq;
|
|---|
| 441 |
|
|---|
| 442 | // Fast path: the bytes for this exact track were already prefetched while
|
|---|
| 443 | // the previous track played → swap in instantly, no gap, no fetch window.
|
|---|
| 444 | if (preload && preload.url === t.url && preload.objUrl) {
|
|---|
| 445 | const obj = preload.objUrl;
|
|---|
| 446 | preload = null; // ownership moves to applyBlob (becomes currentObjectUrl)
|
|---|
| 447 | applyBlob(obj, autoplay, mySeq);
|
|---|
| 448 | return;
|
|---|
| 449 | }
|
|---|
| 450 |
|
|---|
| 451 | // Not preloaded (or preload still in flight) → drop any stale preload and
|
|---|
| 452 | // fetch fresh, retrying transient failures before giving up.
|
|---|
| 453 | dropPreload();
|
|---|
| 454 | root.classList.add('audio-loading');
|
|---|
| 455 | fetchAsObjectUrl(t.url, 3)
|
|---|
| 456 | .then((objUrl) => applyBlob(objUrl, autoplay, mySeq))
|
|---|
| 457 | .catch((err) => onLoadError(err, mySeq));
|
|---|
| 458 | }
|
|---|
| 459 |
|
|---|
| 460 | // True when the viewport is in the mobile sheet-layout — matches the CSS
|
|---|
| 461 | // breakpoint where .audio-sheet slides up full-width from the bottom
|
|---|
| 462 | // (@media max-width:719.98px). On wider/desktop widths the sheet is a centered
|
|---|
| 463 | // panel, so we do NOT auto-open it there.
|
|---|
| 464 | // Drie weergaves (Robin 2026-06-15): telefoon (<768) = fullscreen sheet;
|
|---|
| 465 | // tablet/auto (768–1199) = grote landscape full-player (tablet + tablet-in-de-
|
|---|
| 466 | // auto); desktop (≥1200) = alleen mini-speler, GEEN full player. matchMedia
|
|---|
| 467 | // zodat dit exact de CSS-breekpunten volgt.
|
|---|
| 468 | function playerTier() {
|
|---|
| 469 | if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop';
|
|---|
| 470 | if (window.matchMedia('(min-width: 768px)').matches) return 'tablet';
|
|---|
| 471 | return 'phone';
|
|---|
| 472 | }
|
|---|
| 473 | function hasFullPlayer() { return playerTier() !== 'desktop'; }
|
|---|
| 474 | function isMobileView() { return playerTier() === 'phone'; }
|
|---|
| 475 |
|
|---|
| 476 | function setQueue(tracks, startIdx, opts) {
|
|---|
| 477 | queue = Array.isArray(tracks) ? tracks.slice() : [];
|
|---|
| 478 | albumName = (opts && opts.albumName) || '';
|
|---|
| 479 | if (!queue.length) return;
|
|---|
| 480 | loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
|
|---|
| 481 | // Mobile: a track press from an album/playlist auto-opens the full
|
|---|
| 482 | // now-playing sheet (Spotify-style) i.p.v. alleen de dunne mini-strip.
|
|---|
| 483 | // Alleen hier (setQueue = een verse, door-de-gebruiker-gestarte queue) —
|
|---|
| 484 | // niet bij next/prev of de site-pre-seed — zodat een sheet die de gebruiker
|
|---|
| 485 | // bewust sloot niet vanzelf terugkomt.
|
|---|
| 486 | if (hasFullPlayer()) openSheet();
|
|---|
| 487 | }
|
|---|
| 488 |
|
|---|
| 489 | function play() {
|
|---|
| 490 | if (ytMode) {
|
|---|
| 491 | if (ytPlayer && ytReady) { try { ytPlayer.playVideo(); } catch (e) {} }
|
|---|
| 492 | else if (queue[currentIndex]) loadTrack(currentIndex, true);
|
|---|
| 493 | return;
|
|---|
| 494 | }
|
|---|
| 495 | if (!audio.src) {
|
|---|
| 496 | // Nothing fetched yet (pre-seed showed metadata only, or a load is still
|
|---|
| 497 | // in flight). Kick off the blob load for the current track and autoplay.
|
|---|
| 498 | if (queue[currentIndex]) loadTrack(currentIndex, true);
|
|---|
| 499 | return;
|
|---|
| 500 | }
|
|---|
| 501 | const p = audio.play();
|
|---|
| 502 | if (p && typeof p.catch === 'function') {
|
|---|
| 503 | p.catch((err) => {
|
|---|
| 504 | console.warn('[pcms-audio] play() rejected:', err.name, err.message);
|
|---|
| 505 | // Browser-autoplay-policy heeft 't gestopt (typisch na 3-4
|
|---|
| 506 | // automatische plays op iOS Safari, of als tab tijdelijk inactive
|
|---|
| 507 | // was). Visuele hint dat user op play moet tappen.
|
|---|
| 508 | if (err && err.name === 'NotAllowedError') {
|
|---|
| 509 | root.classList.add('audio-needs-tap');
|
|---|
| 510 | isPlaying = false;
|
|---|
| 511 | root.classList.remove('is-playing');
|
|---|
| 512 | }
|
|---|
| 513 | });
|
|---|
| 514 | }
|
|---|
| 515 | }
|
|---|
| 516 | function pause() { if (ytMode) { try { ytPlayer && ytPlayer.pauseVideo(); } catch (e) {} return; } audio.pause(); }
|
|---|
| 517 | function togglePlay() { if (ytMode) { isPlaying ? pause() : play(); return; } audio.paused ? play() : pause(); }
|
|---|
| 518 | function next() {
|
|---|
| 519 | const t = queue[currentIndex];
|
|---|
| 520 | if (t && t.ytList && ytPlayer && ytMode) { try { ytPlayer.nextVideo(); } catch (e) {} return; }
|
|---|
| 521 | if (!queue.length) return;
|
|---|
| 522 | loadTrack((currentIndex + 1) % queue.length, true);
|
|---|
| 523 | }
|
|---|
| 524 | function prev() {
|
|---|
| 525 | const t = queue[currentIndex];
|
|---|
| 526 | if (t && t.ytList && ytPlayer && ytMode) { try { ytPlayer.previousVideo(); } catch (e) {} return; }
|
|---|
| 527 | if (!queue.length) return;
|
|---|
| 528 | loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
|
|---|
| 529 | }
|
|---|
| 530 | function close() {
|
|---|
| 531 | pause();
|
|---|
| 532 | mediaRegistry().release(registrySelf);
|
|---|
| 533 | root.classList.add('audio-player-hidden');
|
|---|
| 534 | document.body.classList.remove('has-audio-player');
|
|---|
| 535 | closeSheet();
|
|---|
| 536 | ytMode = false; stopYTTick();
|
|---|
| 537 | try { if (ytPlayer && ytPlayer.stopVideo) ytPlayer.stopVideo(); } catch (e) {}
|
|---|
| 538 | queue = [];
|
|---|
| 539 | albumName = '';
|
|---|
| 540 | loadSeq++; // cancel any in-flight load
|
|---|
| 541 | dropPreload(); // free any prefetched next-track blob
|
|---|
| 542 | if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
|
|---|
| 543 | currentObjectUrl = null;
|
|---|
| 544 | try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
|
|---|
| 545 | }
|
|---|
| 546 |
|
|---|
| 547 | function renderQueue() {
|
|---|
| 548 | if (!queue.length) { sheetQueue.hidden = true; return; }
|
|---|
| 549 | sheetQueueList.innerHTML = '';
|
|---|
| 550 | queue.forEach((t, i) => {
|
|---|
| 551 | const li = document.createElement('li');
|
|---|
| 552 | li.className = 'audio-sheet-queue-item' + (i === currentIndex ? ' is-current' : '');
|
|---|
| 553 | li.dataset.idx = String(i);
|
|---|
| 554 | li.innerHTML = `<span class="aqi-num">${i + 1}.</span> <span class="aqi-title">${escapeHtml(t.title || 'Untitled')}</span>`
|
|---|
| 555 | + (t.artist ? `<span class="aqi-artist">${escapeHtml(t.artist)}</span>` : '');
|
|---|
| 556 | sheetQueueList.appendChild(li);
|
|---|
| 557 | });
|
|---|
| 558 | sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
|
|---|
| 559 | li.addEventListener('click', () => {
|
|---|
| 560 | const idx = parseInt(li.dataset.idx, 10);
|
|---|
| 561 | if (!isNaN(idx) && idx !== currentIndex) {
|
|---|
| 562 | loadTrack(idx, true);
|
|---|
| 563 | }
|
|---|
| 564 | });
|
|---|
| 565 | });
|
|---|
| 566 | sheetQueue.hidden = queue.length < 2;
|
|---|
| 567 | }
|
|---|
| 568 |
|
|---|
| 569 | function escapeHtml(s) {
|
|---|
| 570 | return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|---|
| 571 | }
|
|---|
| 572 |
|
|---|
| 573 | // ============================================================
|
|---|
| 574 | // 4b. Mutual exclusion — gedeelde media-registry (zie embed-player.js).
|
|---|
| 575 | // ============================================================
|
|---|
| 576 | // Alle spelers (deze site-speler + de YouTube/SoundCloud/Spotify-embeds)
|
|---|
| 577 | // registreren zich in window.pcmsMediaRegistry. Start er één, dan pauzeert de
|
|---|
| 578 | // vorige. Dit is de precieze vervanger van de oude focus/blur-heuristiek voor
|
|---|
| 579 | // de embeds met een echte JS-API. (De blur-fallback hieronder blijft staan
|
|---|
| 580 | // voor iframe-only embeds zonder API: Bandcamp/Apple Music/Vimeo.)
|
|---|
| 581 | function mediaRegistry() {
|
|---|
| 582 | if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
|
|---|
| 583 | const r = {
|
|---|
| 584 | _active: null,
|
|---|
| 585 | setActive(player) {
|
|---|
| 586 | if (this._active && this._active !== player && this._active.pause) {
|
|---|
| 587 | try { this._active.pause(); } catch (e) {}
|
|---|
| 588 | }
|
|---|
| 589 | this._active = player;
|
|---|
| 590 | },
|
|---|
| 591 | release(player) { if (this._active === player) this._active = null; },
|
|---|
| 592 | };
|
|---|
| 593 | window.pcmsMediaRegistry = r;
|
|---|
| 594 | return r;
|
|---|
| 595 | }
|
|---|
| 596 | const registrySelf = { pause() { try { audio.pause(); } catch (e) {} try { if (ytPlayer && ytPlayer.pauseVideo) ytPlayer.pauseVideo(); } catch (e) {} } };
|
|---|
| 597 |
|
|---|
| 598 | // ============================================================
|
|---|
| 599 | // 5. Audio element events → UI sync
|
|---|
| 600 | // ============================================================
|
|---|
| 601 | // Error-counter voorkomt infinite-loop als ALLE tracks broken zijn.
|
|---|
| 602 | let consecutiveErrors = 0;
|
|---|
| 603 |
|
|---|
| 604 | audio.addEventListener('play', () => {
|
|---|
| 605 | isPlaying = true;
|
|---|
| 606 | root.classList.add('is-playing');
|
|---|
| 607 | root.classList.remove('audio-needs-tap'); // verstop tap-hint
|
|---|
| 608 | mediaRegistry().setActive(registrySelf); // pauzeer eventueel spelende embeds
|
|---|
| 609 | });
|
|---|
| 610 | // Reset de error-teller pas bij ECHTE playback-start (`playing`), niet bij
|
|---|
| 611 | // het eager `play`-event. `play` vuurt vóór een eventuele netwerk-/decode-
|
|---|
| 612 | // fout, dus resetten daar zou de 3-strikes-stop nooit laten triggeren bij
|
|---|
| 613 | // een kapotte track → infinite "next"-loop. `playing` vuurt alleen als er
|
|---|
| 614 | // daadwerkelijk audio speelt.
|
|---|
| 615 | audio.addEventListener('playing', () => { consecutiveErrors = 0; preloadNext(); });
|
|---|
| 616 | audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
|
|---|
| 617 | audio.addEventListener('ended', next);
|
|---|
| 618 | audio.addEventListener('error', (e) => {
|
|---|
| 619 | const code = audio.error ? audio.error.code : '?';
|
|---|
| 620 | console.error('[pcms-audio] playback error', code, audio.src, e);
|
|---|
| 621 | consecutiveErrors++;
|
|---|
| 622 | // Bij netwerk/decode-fout: skip naar volgende track ipv stilstaan.
|
|---|
| 623 | // Max 3 fouten op rij voordat we opgeven (anders infinite loop).
|
|---|
| 624 | if (consecutiveErrors < 3 && queue.length > 1) {
|
|---|
| 625 | console.warn('[pcms-audio] auto-skip naar volgende na error', consecutiveErrors);
|
|---|
| 626 | setTimeout(next, 400);
|
|---|
| 627 | }
|
|---|
| 628 | });
|
|---|
| 629 | audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled at', audio.currentTime));
|
|---|
| 630 | audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
|
|---|
| 631 | audio.addEventListener('timeupdate', () => {
|
|---|
| 632 | if (ytMode) return; // YT-voortgang loopt via de tick
|
|---|
| 633 | updateProgressUI(audio.currentTime, audio.duration);
|
|---|
| 634 | });
|
|---|
| 635 |
|
|---|
| 636 | function formatTime(s) {
|
|---|
| 637 | if (!s || isNaN(s)) return '0:00';
|
|---|
| 638 | const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
|---|
| 639 | return m + ':' + (sec < 10 ? '0' : '') + sec;
|
|---|
| 640 | }
|
|---|
| 641 |
|
|---|
| 642 | function attachSeek(seekEl) {
|
|---|
| 643 | seekEl.addEventListener('click', (e) => {
|
|---|
| 644 | const rect = seekEl.getBoundingClientRect();
|
|---|
| 645 | const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|---|
| 646 | if (ytMode) {
|
|---|
| 647 | try { const d = ytPlayer.getDuration() || 0; if (d) ytPlayer.seekTo(ratio * d, true); } catch (e2) {}
|
|---|
| 648 | return;
|
|---|
| 649 | }
|
|---|
| 650 | if (!audio.duration) return;
|
|---|
| 651 | audio.currentTime = ratio * audio.duration;
|
|---|
| 652 | });
|
|---|
| 653 | }
|
|---|
| 654 | attachSeek(seek);
|
|---|
| 655 | attachSeek(sheetSeek);
|
|---|
| 656 |
|
|---|
| 657 | audio.volume = 0.8;
|
|---|
| 658 | volumeSlider.addEventListener('input', () => {
|
|---|
| 659 | const v = parseInt(volumeSlider.value, 10) || 0;
|
|---|
| 660 | audio.volume = v / 100;
|
|---|
| 661 | if (v > 0) audio.muted = false;
|
|---|
| 662 | if (ytPlayer) { try { ytPlayer.setVolume(v); if (v > 0 && ytPlayer.unMute) ytPlayer.unMute(); } catch (e) {} }
|
|---|
| 663 | });
|
|---|
| 664 | muteBtn.addEventListener('click', () => {
|
|---|
| 665 | audio.muted = !audio.muted;
|
|---|
| 666 | if (ytPlayer) { try { audio.muted ? ytPlayer.mute() : ytPlayer.unMute(); } catch (e) {} }
|
|---|
| 667 | root.classList.toggle('is-muted', audio.muted);
|
|---|
| 668 | });
|
|---|
| 669 |
|
|---|
| 670 | // ============================================================
|
|---|
| 671 | // 6. Control wiring
|
|---|
| 672 | // ============================================================
|
|---|
| 673 | playBtn.addEventListener('click', togglePlay);
|
|---|
| 674 | prevBtn.addEventListener('click', prev);
|
|---|
| 675 | nextBtn.addEventListener('click', next);
|
|---|
| 676 | sheetPlay.addEventListener('click', togglePlay);
|
|---|
| 677 | sheetPrev.addEventListener('click', prev);
|
|---|
| 678 | sheetNext.addEventListener('click', next);
|
|---|
| 679 |
|
|---|
| 680 | // ============================================================
|
|---|
| 681 | // 7. Sheet expand/close + drag-down-to-close
|
|---|
| 682 | // ============================================================
|
|---|
| 683 | // Back-knop sluit de sheet op mobiel: bij openen pushen we een history-entry,
|
|---|
| 684 | // zodat de telefoon-terugknop (popstate) eerst de sheet sluit i.p.v. de pagina
|
|---|
| 685 | // te verlaten. We balanceren 'm bij een UI-sluiting via history.back().
|
|---|
| 686 | let sheetHistoryPushed = false;
|
|---|
| 687 |
|
|---|
| 688 | function openSheet() {
|
|---|
| 689 | if (!hasFullPlayer()) return; // desktop (≥1200): geen full player, alleen mini-speler
|
|---|
| 690 | if (sheet.classList.contains('is-open')) return;
|
|---|
| 691 | sheet.classList.add('is-open');
|
|---|
| 692 | sheet.setAttribute('aria-hidden', 'false');
|
|---|
| 693 | document.body.classList.add('audio-sheet-locked');
|
|---|
| 694 | // Telefoon + tablet: history-entry zodat de terug-knop eerst de sheet sluit.
|
|---|
| 695 | try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {}
|
|---|
| 696 | }
|
|---|
| 697 | function closeSheet(fromPopstate) {
|
|---|
| 698 | if (!sheet.classList.contains('is-open')) return;
|
|---|
| 699 | sheet.classList.remove('is-open');
|
|---|
| 700 | sheet.setAttribute('aria-hidden', 'true');
|
|---|
| 701 | document.body.classList.remove('audio-sheet-locked');
|
|---|
| 702 | sheetPanel.style.removeProperty('--pcms-drag-y');
|
|---|
| 703 | sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
|
|---|
| 704 | // UI-sluiting (X / swipe / backdrop / Esc): pop onze eigen history-entry zodat
|
|---|
| 705 | // de volgende terug-knop weer normaal navigeert. Bij een popstate-sluiting
|
|---|
| 706 | // (de terug-knop zelf) is de entry al gepopt.
|
|---|
| 707 | const wasPushed = sheetHistoryPushed;
|
|---|
| 708 | sheetHistoryPushed = false;
|
|---|
| 709 | if (wasPushed && !fromPopstate) { try { history.back(); } catch (e) {} }
|
|---|
| 710 | }
|
|---|
| 711 | window.addEventListener('popstate', () => {
|
|---|
| 712 | if (sheet.classList.contains('is-open')) closeSheet(true);
|
|---|
| 713 | });
|
|---|
| 714 | // Mini-player-info aanklikken:
|
|---|
| 715 | // - DESKTOP (≥768px): spring naar de post waar de track vandaan komt (als bekend),
|
|---|
| 716 | // via htmx zodat de audio blijft spelen. Geen post bekend → val terug op de sheet.
|
|---|
| 717 | // - MOBIEL: altijd de uitgebreide speler-sheet openen (huidig gedrag).
|
|---|
| 718 | function scrollToTrack(trackId) {
|
|---|
| 719 | if (!trackId) { window.scrollTo(0, 0); return; }
|
|---|
| 720 | const el = document.getElementById('track-' + trackId);
|
|---|
| 721 | if (!el) { window.scrollTo(0, 0); return; }
|
|---|
| 722 | el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|---|
| 723 | el.classList.add('pat-flash');
|
|---|
| 724 | setTimeout(() => el.classList.remove('pat-flash'), 1600);
|
|---|
| 725 | }
|
|---|
| 726 | function goToPost(url, trackId) {
|
|---|
| 727 | const hash = trackId ? ('#track-' + trackId) : '';
|
|---|
| 728 | if (window.htmx && url.charAt(0) === '/') {
|
|---|
| 729 | try {
|
|---|
| 730 | const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' });
|
|---|
| 731 | history.pushState({}, '', url + hash);
|
|---|
| 732 | // Na de swap naar de track scrollen (klein uitstel zodat de globale
|
|---|
| 733 | // afterSwap-top-scroll eerst is geweest); val terug op top als niet gevonden.
|
|---|
| 734 | const go = () => setTimeout(() => scrollToTrack(trackId), 60);
|
|---|
| 735 | if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150);
|
|---|
| 736 | return;
|
|---|
| 737 | } catch (e) { /* val terug op volledige navigatie */ }
|
|---|
| 738 | }
|
|---|
| 739 | location.href = url + hash;
|
|---|
| 740 | }
|
|---|
| 741 | expandTrigger.addEventListener('click', () => {
|
|---|
| 742 | const t = queue[currentIndex];
|
|---|
| 743 | // Alleen op échte desktop (≥1200, geen full-player) springen we naar de post.
|
|---|
| 744 | // Tablet + telefoon hebben een full-player → open die (zoals mobiel).
|
|---|
| 745 | const jumpToPost = !hasFullPlayer();
|
|---|
| 746 | if (jumpToPost && t) {
|
|---|
| 747 | // 1) Track speelde vanuit een post → die URL kennen we al.
|
|---|
| 748 | if (t.postUrl) { goToPost(t.postUrl, t.id); return; }
|
|---|
| 749 | // 2) Site-brede track (geen postUrl) → zoek de post op via de track-id.
|
|---|
| 750 | if (t.id) {
|
|---|
| 751 | fetch('/audio/track/' + encodeURIComponent(t.id) + '/post')
|
|---|
| 752 | .then((r) => (r.ok ? r.json() : null))
|
|---|
| 753 | .then((d) => { if (d && d.url) goToPost(d.url, t.id); else openSheet(); })
|
|---|
| 754 | .catch(() => openSheet());
|
|---|
| 755 | return;
|
|---|
| 756 | }
|
|---|
| 757 | }
|
|---|
| 758 | openSheet();
|
|---|
| 759 | });
|
|---|
| 760 | sheetClose.addEventListener('click', () => closeSheet());
|
|---|
| 761 | sheetBackdrop.addEventListener('click', () => closeSheet());
|
|---|
| 762 | document.addEventListener('keydown', (e) => {
|
|---|
| 763 | if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
|
|---|
| 764 | });
|
|---|
| 765 | // Naar desktop-breedte (≥1200) gesleept terwijl de full player open is? Sluiten —
|
|---|
| 766 | // op desktop bestaat de full player niet.
|
|---|
| 767 | window.addEventListener('resize', () => {
|
|---|
| 768 | if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet();
|
|---|
| 769 | });
|
|---|
| 770 |
|
|---|
| 771 | // Vangnet voor mutual exclusion. Voor YouTube/SoundCloud/Spotify-embeds doet de
|
|---|
| 772 | // registry dit al precies (echte play-events). Maar voor iframe-only embeds
|
|---|
| 773 | // ZONDER JS-API (Bandcamp/Apple/Vimeo) én voor de iframe-FALLBACK (als een
|
|---|
| 774 | // ad-blocker de player-API blokkeert) is er geen play-event: daar vangen we het
|
|---|
| 775 | // af via focus. Klikt de gebruiker zo'n iframe aan → window 'blur' → pauzeer
|
|---|
| 776 | // onze speler. (Voor de API-embeds is dit hooguit een onschadelijke dubbele
|
|---|
| 777 | // pauze.)
|
|---|
| 778 | window.addEventListener('blur', () => {
|
|---|
| 779 | setTimeout(() => {
|
|---|
| 780 | const el = document.activeElement;
|
|---|
| 781 | // Alleen embed-iframes (binnen .folio-embed) pauzeren de speler — niet een
|
|---|
| 782 | // willekeurig iframe (captcha/reclame/kaart) dat per ongeluk focus krijgt.
|
|---|
| 783 | if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
|
|---|
| 784 | pause();
|
|---|
| 785 | }
|
|---|
| 786 | }, 0);
|
|---|
| 787 | });
|
|---|
| 788 |
|
|---|
| 789 | // Drag-down-to-close on touch devices.
|
|---|
| 790 | //
|
|---|
| 791 | // We set --pcms-drag-y as a CSS custom prop instead of writing
|
|---|
| 792 | // sheetPanel.style.transform directly. The reason: on desktop the panel
|
|---|
| 793 | // uses `transform: translate(-50%, 0)` for horizontal centering. Writing
|
|---|
| 794 | // `style.transform = translateY(...)` would obliterate the -50% and the
|
|---|
| 795 | // panel would jump rightward. With a custom prop, audio.css composes the
|
|---|
| 796 | // final transform per breakpoint:
|
|---|
| 797 | // mobile: transform: translateY(var(--pcms-drag-y, 0))
|
|---|
| 798 | // desktop: transform: translate(-50%, var(--pcms-drag-y, 0))
|
|---|
| 799 | let dragStartY = 0, dragLastY = 0, isDragging = false;
|
|---|
| 800 | function onPointerDown(e) {
|
|---|
| 801 | if (e.pointerType !== 'touch') return;
|
|---|
| 802 | if (sheetPanel.scrollTop > 0) return; // queue is scrolled, don't drag
|
|---|
| 803 | dragStartY = dragLastY = e.clientY;
|
|---|
| 804 | isDragging = true;
|
|---|
| 805 | sheetPanel.classList.add('is-dragging');
|
|---|
| 806 | sheetBackdrop.classList.add('is-dragging');
|
|---|
| 807 | }
|
|---|
| 808 | function onPointerMove(e) {
|
|---|
| 809 | if (!isDragging) return;
|
|---|
| 810 | dragLastY = e.clientY;
|
|---|
| 811 | const dy = Math.max(0, dragLastY - dragStartY);
|
|---|
| 812 | sheetPanel.style.setProperty('--pcms-drag-y', dy + 'px');
|
|---|
| 813 | const progress = Math.max(0, 1 - dy / sheetPanel.offsetHeight);
|
|---|
| 814 | sheetBackdrop.style.setProperty('--pcms-sheet-progress', String(progress));
|
|---|
| 815 | }
|
|---|
| 816 | function onPointerUp() {
|
|---|
| 817 | if (!isDragging) return;
|
|---|
| 818 | isDragging = false;
|
|---|
| 819 | sheetPanel.classList.remove('is-dragging');
|
|---|
| 820 | sheetBackdrop.classList.remove('is-dragging');
|
|---|
| 821 | const dy = dragLastY - dragStartY;
|
|---|
| 822 | if (dy > 100) {
|
|---|
| 823 | closeSheet();
|
|---|
| 824 | } else {
|
|---|
| 825 | sheetPanel.style.removeProperty('--pcms-drag-y');
|
|---|
| 826 | sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
|
|---|
| 827 | }
|
|---|
| 828 | }
|
|---|
| 829 | if (window.PointerEvent) {
|
|---|
| 830 | dragZone.addEventListener('pointerdown', onPointerDown);
|
|---|
| 831 | document.addEventListener('pointermove', onPointerMove);
|
|---|
| 832 | document.addEventListener('pointerup', onPointerUp);
|
|---|
| 833 | document.addEventListener('pointercancel', onPointerUp);
|
|---|
| 834 | }
|
|---|
| 835 |
|
|---|
| 836 | // ============================================================
|
|---|
| 837 | // 8. Hook up post-audio-track + post-album-cover-btn + .pat-row + .post-album-playall
|
|---|
| 838 | // ============================================================
|
|---|
| 839 | // Four entry points fire the same play action:
|
|---|
| 840 | // - .pat-play → single-track widget in a post
|
|---|
| 841 | // - .pat-row → track row inside an album/playlist tracklist
|
|---|
| 842 | // - .post-album-cover-btn → big cover button (plays album from track 0)
|
|---|
| 843 | // - .post-album-playall → "Speel album" / "Speel playlist" button
|
|---|
| 844 | //
|
|---|
| 845 | // For .pat-play the metadata lives on the surrounding .post-audio-track
|
|---|
| 846 | // wrapper. For the other three the data is on the button itself. The
|
|---|
| 847 | // handler reads from button-first, falls back to wrapper.
|
|---|
| 848 | // Event-delegation op document.body i.p.v. per-knop listeners. Dit overleeft
|
|---|
| 849 | // HTMX history-restores: de mobiele terug-knop (popstate) laat HTMX #pcms-main
|
|---|
| 850 | // terugzetten uit z'n snapshot; een per-element `data-pcms-attached`-vlag zou
|
|---|
| 851 | // dan dode knoppen geven (vlag ingebakken in de snapshot, listener weg). Eén
|
|---|
| 852 | // gedelegeerde listener werkt ongeacht hoe vaak de DOM ge(her)swapt wordt.
|
|---|
| 853 | const PLAY_SELECTOR =
|
|---|
| 854 | '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall';
|
|---|
| 855 | document.body.addEventListener('click', (e) => {
|
|---|
| 856 | const btn = e.target.closest(PLAY_SELECTOR);
|
|---|
| 857 | if (!btn) return;
|
|---|
| 858 | e.preventDefault();
|
|---|
| 859 | e.stopPropagation();
|
|---|
| 860 | // De post waar je VANAF afspeelt = de pagina waar je nu bent (de embeds staan
|
|---|
| 861 | // in de post-content). Bewaar 'm op de track(s) zodat de mini-player er op
|
|---|
| 862 | // desktop naartoe kan springen — overleeft ook de sessionStorage-resume.
|
|---|
| 863 | const postUrl = location.pathname + location.search;
|
|---|
| 864 | // Resolve metadata: button-first, then closest .post-audio-track wrapper
|
|---|
| 865 | // (only inline single-track widgets put the data on the wrapper).
|
|---|
| 866 | const wrapper = btn.closest('.post-audio-track');
|
|---|
| 867 | const albumId = btn.dataset.pcmsAlbumId || (wrapper && wrapper.dataset.pcmsAlbumId);
|
|---|
| 868 | const trackData = btn.dataset.pcmsTrack || (wrapper && wrapper.dataset.pcmsTrack);
|
|---|
| 869 | const trackUrl = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
|
|---|
| 870 | console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
|
|---|
| 871 |
|
|---|
| 872 | if (albumId) {
|
|---|
| 873 | const album = document.getElementById(albumId);
|
|---|
| 874 | if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
|
|---|
| 875 | try {
|
|---|
| 876 | const tracks = JSON.parse(album.dataset.pcmsAlbum);
|
|---|
| 877 | tracks.forEach((t) => { t.postUrl = postUrl; });
|
|---|
| 878 | // Start at the clicked track if we know its URL, else start at 0
|
|---|
| 879 | // (cover-btn and playall both want to start from the beginning).
|
|---|
| 880 | const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
|
|---|
| 881 | setQueue(tracks, startIdx >= 0 ? startIdx : 0, { albumName: album.dataset.pcmsAlbumTitle || '' });
|
|---|
| 882 | } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
|
|---|
| 883 | } else if (trackData) {
|
|---|
| 884 | try {
|
|---|
| 885 | const t = JSON.parse(trackData);
|
|---|
| 886 | t.postUrl = postUrl;
|
|---|
| 887 | setQueue([t], 0);
|
|---|
| 888 | } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
|
|---|
| 889 | } else if (trackUrl) {
|
|---|
| 890 | // Fallback: at minimum we have the signed URL
|
|---|
| 891 | setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '', postUrl }], 0);
|
|---|
| 892 | } else {
|
|---|
| 893 | console.error('[pcms-audio] no track data or url on button or wrapper', btn);
|
|---|
| 894 | }
|
|---|
| 895 | });
|
|---|
| 896 |
|
|---|
| 897 | // ============================================================
|
|---|
| 898 | // 8b. Admin playlist delete (event delegation)
|
|---|
| 899 | // ============================================================
|
|---|
| 900 | // The post-album embed renders a [data-pcms-playlist-delete] button
|
|---|
| 901 | // top-right when the viewer is admin (server decides; not client).
|
|---|
| 902 | // We delegate from document.body so HTMX-swapped content works too.
|
|---|
| 903 | document.body.addEventListener('click', async (e) => {
|
|---|
| 904 | const btn = e.target.closest('[data-pcms-playlist-delete]');
|
|---|
| 905 | if (!btn) return;
|
|---|
| 906 | e.preventDefault();
|
|---|
| 907 | e.stopPropagation();
|
|---|
| 908 | const id = btn.dataset.pcmsPlaylistDelete;
|
|---|
| 909 | const title = btn.dataset.pcmsPlaylistTitle || id;
|
|---|
| 910 | if (!id) return;
|
|---|
| 911 | if (!confirm(`Playlist "${title}" verwijderen? De embed in deze post toont vanaf nu een placeholder.`)) return;
|
|---|
| 912 | btn.disabled = true;
|
|---|
| 913 | try {
|
|---|
| 914 | const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
|
|---|
| 915 | method: 'POST',
|
|---|
| 916 | headers: { 'X-CSRF-Token': '' },
|
|---|
| 917 | credentials: 'same-origin',
|
|---|
| 918 | });
|
|---|
| 919 | const j = await r.json().catch(() => ({}));
|
|---|
| 920 | if (j && j.ok) {
|
|---|
| 921 | location.reload();
|
|---|
| 922 | } else {
|
|---|
| 923 | alert('Verwijderen mislukt: ' + ((j && j.error) || 'onbekende fout'));
|
|---|
| 924 | btn.disabled = false;
|
|---|
| 925 | }
|
|---|
| 926 | } catch (err) {
|
|---|
| 927 | alert('Verwijderen mislukt: ' + err.message);
|
|---|
| 928 | btn.disabled = false;
|
|---|
| 929 | }
|
|---|
| 930 | });
|
|---|
| 931 |
|
|---|
| 932 | // ============================================================
|
|---|
| 933 | // 9. Public API
|
|---|
| 934 | // ============================================================
|
|---|
| 935 | // Speel een YouTube-bron als AUDIO af in deze (persistente) site-speler — door
|
|---|
| 936 | // de embed-speler aangeroepen voor een audio-only YouTube-embed. Zo verschijnt
|
|---|
| 937 | // 'ie in de onderbalk én speelt 'ie door bij navigeren.
|
|---|
| 938 | function playYouTube(opts) {
|
|---|
| 939 | if (!opts || (!opts.id && !opts.list)) return;
|
|---|
| 940 | setQueue([{
|
|---|
| 941 | yt: opts.id || null,
|
|---|
| 942 | ytList: opts.list || null, // YouTube-afspeellijst/album (OLAK…/PL…)
|
|---|
| 943 | ytIndex: opts.index || 0, // startindex binnen de lijst
|
|---|
| 944 | title: opts.title || 'YouTube',
|
|---|
| 945 | artist: opts.artist || '',
|
|---|
| 946 | cover: opts.cover || '',
|
|---|
| 947 | postUrl: opts.postUrl || '',
|
|---|
| 948 | }], 0);
|
|---|
| 949 | // De scherpe cover + echte titel komen via pullYTMeta() zodra het nummer speelt.
|
|---|
| 950 | }
|
|---|
| 951 |
|
|---|
| 952 | // Zet de bar-cover op de thumbnail van een video (mqdefault = balkloos), en
|
|---|
| 953 | // upgrade async naar de scherpe maxresdefault als die bestaat.
|
|---|
| 954 | function setYtCover(videoId) {
|
|---|
| 955 | if (!videoId) return;
|
|---|
| 956 | const mq = 'https://i.ytimg.com/vi/' + videoId + '/mqdefault.jpg';
|
|---|
| 957 | setCoverImage(cover, mq); setCoverImage(sheetCover, mq);
|
|---|
| 958 | try {
|
|---|
| 959 | const maxres = 'https://i.ytimg.com/vi/' + videoId + '/maxresdefault.jpg';
|
|---|
| 960 | const im = new Image();
|
|---|
| 961 | im.onload = () => {
|
|---|
| 962 | if (im.naturalWidth <= 120) return;
|
|---|
| 963 | const cur = ytPlayer && ytPlayer.getVideoData ? (ytPlayer.getVideoData() || {}).video_id : null;
|
|---|
| 964 | if (cur === videoId) { setCoverImage(cover, maxres); setCoverImage(sheetCover, maxres); }
|
|---|
| 965 | };
|
|---|
| 966 | im.src = maxres;
|
|---|
| 967 | } catch (e) {}
|
|---|
| 968 | }
|
|---|
| 969 |
|
|---|
| 970 | window.pcmsAudioPlayer = {
|
|---|
| 971 | setQueue, play, pause, next, prev, close, openSheet, closeSheet, playYouTube,
|
|---|
| 972 | isPlaying: () => isPlaying,
|
|---|
| 973 | currentTrack: () => queue[currentIndex] || null,
|
|---|
| 974 | };
|
|---|
| 975 |
|
|---|
| 976 | // ============================================================
|
|---|
| 977 | // 10. Sessie-persistentie — speler "blijft" over page-navigaties heen
|
|---|
| 978 | // ============================================================
|
|---|
| 979 | // Een <audio> overleeft geen volledige page-load (en cross-context navigatie
|
|---|
| 980 | // — bv. naar de headerloze hub-overview — is bewust full-nav). We bewaren de
|
|---|
| 981 | // sessie in sessionStorage en herstellen + hervatten 'm op de volgende pagina:
|
|---|
| 982 | // de speler staat er weer met dezelfde track op dezelfde positie. In Chrome
|
|---|
| 983 | // (hoge media-engagement) speelt 'ie meteen door; staat de browser autoplay
|
|---|
| 984 | // niet toe, dan staat 'ie klaar op die plek (één tik = verder).
|
|---|
| 985 | const PLAYER_STATE_KEY = 'pcms-player-state';
|
|---|
| 986 | let pendingSeek = 0;
|
|---|
| 987 | function savePlayerState() {
|
|---|
| 988 | try {
|
|---|
| 989 | if (!queue.length) { sessionStorage.removeItem(PLAYER_STATE_KEY); return; }
|
|---|
| 990 | sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
|
|---|
| 991 | queue, currentIndex, albumName,
|
|---|
| 992 | time: audio.currentTime || 0,
|
|---|
| 993 | playing: !!audio.src && !audio.paused,
|
|---|
| 994 | }));
|
|---|
| 995 | } catch (e) {}
|
|---|
| 996 | }
|
|---|
| 997 | window.addEventListener('pagehide', savePlayerState);
|
|---|
| 998 | window.addEventListener('beforeunload', savePlayerState);
|
|---|
| 999 | audio.addEventListener('play', savePlayerState);
|
|---|
| 1000 | audio.addEventListener('pause', savePlayerState);
|
|---|
| 1001 | audio.addEventListener('ended', savePlayerState);
|
|---|
| 1002 | setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000);
|
|---|
| 1003 | // Herstelde positie toepassen zodra de track-metadata binnen is.
|
|---|
| 1004 | audio.addEventListener('loadedmetadata', () => {
|
|---|
| 1005 | if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) {
|
|---|
| 1006 | try { audio.currentTime = Math.min(pendingSeek, audio.duration - 0.25); } catch (e) {}
|
|---|
| 1007 | pendingSeek = 0;
|
|---|
| 1008 | }
|
|---|
| 1009 | });
|
|---|
| 1010 | function restorePlayerState() {
|
|---|
| 1011 | let s = null;
|
|---|
| 1012 | try { s = JSON.parse(sessionStorage.getItem(PLAYER_STATE_KEY) || 'null'); } catch (e) { return false; }
|
|---|
| 1013 | if (!s || !Array.isArray(s.queue) || !s.queue.length) return false;
|
|---|
| 1014 | queue = s.queue;
|
|---|
| 1015 | albumName = s.albumName || '';
|
|---|
| 1016 | pendingSeek = s.time || 0;
|
|---|
| 1017 | const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
|
|---|
| 1018 | // playing -> fetch + (poging tot) hervatten; gepauzeerd -> alleen metadata.
|
|---|
| 1019 | loadTrack(idx, !!s.playing, !s.playing);
|
|---|
| 1020 | return true;
|
|---|
| 1021 | }
|
|---|
| 1022 |
|
|---|
| 1023 | // ============================================================
|
|---|
| 1024 | // 11. Site-level pre-seed (window.PCMS_SITE_TRACKS)
|
|---|
| 1025 | // ============================================================
|
|---|
| 1026 | // Een actieve sessie (restore) wint van de pagina-seed, zodat lopende muziek
|
|---|
| 1027 | // doorgaat i.p.v. vervangen te worden door de tracks van de nieuwe pagina.
|
|---|
| 1028 | if (!restorePlayerState()) {
|
|---|
| 1029 | if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
|
|---|
| 1030 | queue = window.PCMS_SITE_TRACKS.map(t => ({
|
|---|
| 1031 | id: t.id || null,
|
|---|
| 1032 | url: t.media_url || t.url,
|
|---|
| 1033 | title: t.title || 'Untitled',
|
|---|
| 1034 | artist: t.artist || '',
|
|---|
| 1035 | cover: t.cover_url || t.cover || '',
|
|---|
| 1036 | }));
|
|---|
| 1037 | if (queue.length) loadTrack(0, false, true); // metadata only — fetch on first play
|
|---|
| 1038 | }
|
|---|
| 1039 | }
|
|---|
| 1040 | })();
|
|---|