| 1 | /**
|
|---|
| 2 | * PrutCMS v10 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 via htmx:afterSettle re-attach
|
|---|
| 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 |
|
|---|
| 161 | // Hide initially
|
|---|
| 162 | root.classList.add('audio-player-hidden');
|
|---|
| 163 |
|
|---|
| 164 | // ============================================================
|
|---|
| 165 | // 4. Track / queue loading
|
|---|
| 166 | // ============================================================
|
|---|
| 167 | function setCoverImage(el, url) {
|
|---|
| 168 | if (url) {
|
|---|
| 169 | el.style.backgroundImage = `url("${url}")`;
|
|---|
| 170 | el.classList.add('has-image');
|
|---|
| 171 | el.innerHTML = '';
|
|---|
| 172 | } else {
|
|---|
| 173 | el.style.backgroundImage = '';
|
|---|
| 174 | el.classList.remove('has-image');
|
|---|
| 175 | el.innerHTML = SVG.musicNote;
|
|---|
| 176 | }
|
|---|
| 177 | }
|
|---|
| 178 |
|
|---|
| 179 | function loadTrack(index) {
|
|---|
| 180 | if (!queue[index]) {
|
|---|
| 181 | console.warn('[pcms-audio] loadTrack: no track at index', index);
|
|---|
| 182 | return;
|
|---|
| 183 | }
|
|---|
| 184 | currentIndex = index;
|
|---|
| 185 | const t = queue[index];
|
|---|
| 186 | if (!t.url) {
|
|---|
| 187 | console.error('[pcms-audio] track has no url', t);
|
|---|
| 188 | return;
|
|---|
| 189 | }
|
|---|
| 190 | console.log('[pcms-audio] loading', t.title, t.url);
|
|---|
| 191 | // Schone overgang: pause + reset voorkomt state-corruption van het
|
|---|
| 192 | // audio-element na meerdere src-changes (bug die continuous playback
|
|---|
| 193 | // brak na 3-4 tracks). audio.load() forceert reset van internal state.
|
|---|
| 194 | try { audio.pause(); } catch (e) {}
|
|---|
| 195 | audio.src = t.url;
|
|---|
| 196 | try { audio.load(); } catch (e) {}
|
|---|
| 197 | titleEl.textContent = t.title || 'Untitled';
|
|---|
| 198 | artistEl.textContent = t.artist || '';
|
|---|
| 199 | sheetTitle.textContent = t.title || 'Untitled';
|
|---|
| 200 | sheetArtist.textContent = t.artist || '';
|
|---|
| 201 | sheetAlbum.textContent = albumName || '';
|
|---|
| 202 | setCoverImage(cover, t.cover);
|
|---|
| 203 | setCoverImage(sheetCover, t.cover);
|
|---|
| 204 | root.classList.remove('audio-player-hidden');
|
|---|
| 205 | document.body.classList.add('has-audio-player');
|
|---|
| 206 | renderQueue();
|
|---|
| 207 | }
|
|---|
| 208 |
|
|---|
| 209 | function setQueue(tracks, startIdx, opts) {
|
|---|
| 210 | queue = Array.isArray(tracks) ? tracks.slice() : [];
|
|---|
| 211 | albumName = (opts && opts.albumName) || '';
|
|---|
| 212 | if (!queue.length) return;
|
|---|
| 213 | loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0);
|
|---|
| 214 | play();
|
|---|
| 215 | }
|
|---|
| 216 |
|
|---|
| 217 | function play() {
|
|---|
| 218 | if (!audio.src) return;
|
|---|
| 219 | const p = audio.play();
|
|---|
| 220 | if (p && typeof p.catch === 'function') {
|
|---|
| 221 | p.catch((err) => {
|
|---|
| 222 | console.warn('[pcms-audio] play() rejected:', err.name, err.message);
|
|---|
| 223 | // Browser-autoplay-policy heeft 't gestopt (typisch na 3-4
|
|---|
| 224 | // automatische plays op iOS Safari, of als tab tijdelijk inactive
|
|---|
| 225 | // was). Visuele hint dat user op play moet tappen.
|
|---|
| 226 | if (err && err.name === 'NotAllowedError') {
|
|---|
| 227 | root.classList.add('audio-needs-tap');
|
|---|
| 228 | isPlaying = false;
|
|---|
| 229 | root.classList.remove('is-playing');
|
|---|
| 230 | }
|
|---|
| 231 | });
|
|---|
| 232 | }
|
|---|
| 233 | }
|
|---|
| 234 | function pause() { audio.pause(); }
|
|---|
| 235 | function togglePlay() { audio.paused ? play() : pause(); }
|
|---|
| 236 | function next() {
|
|---|
| 237 | if (!queue.length) return;
|
|---|
| 238 | loadTrack((currentIndex + 1) % queue.length);
|
|---|
| 239 | play();
|
|---|
| 240 | }
|
|---|
| 241 | function prev() {
|
|---|
| 242 | if (!queue.length) return;
|
|---|
| 243 | loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1);
|
|---|
| 244 | play();
|
|---|
| 245 | }
|
|---|
| 246 | function close() {
|
|---|
| 247 | pause();
|
|---|
| 248 | root.classList.add('audio-player-hidden');
|
|---|
| 249 | document.body.classList.remove('has-audio-player');
|
|---|
| 250 | closeSheet();
|
|---|
| 251 | queue = [];
|
|---|
| 252 | albumName = '';
|
|---|
| 253 | }
|
|---|
| 254 |
|
|---|
| 255 | function renderQueue() {
|
|---|
| 256 | if (!queue.length) { sheetQueue.hidden = true; return; }
|
|---|
| 257 | sheetQueueList.innerHTML = '';
|
|---|
| 258 | queue.forEach((t, i) => {
|
|---|
| 259 | const li = document.createElement('li');
|
|---|
| 260 | li.className = 'audio-sheet-queue-item' + (i === currentIndex ? ' is-current' : '');
|
|---|
| 261 | li.dataset.idx = String(i);
|
|---|
| 262 | li.innerHTML = `<span class="aqi-num">${i + 1}.</span> <span class="aqi-title">${escapeHtml(t.title || 'Untitled')}</span>`
|
|---|
| 263 | + (t.artist ? `<span class="aqi-artist">${escapeHtml(t.artist)}</span>` : '');
|
|---|
| 264 | sheetQueueList.appendChild(li);
|
|---|
| 265 | });
|
|---|
| 266 | sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
|
|---|
| 267 | li.addEventListener('click', () => {
|
|---|
| 268 | const idx = parseInt(li.dataset.idx, 10);
|
|---|
| 269 | if (!isNaN(idx) && idx !== currentIndex) {
|
|---|
| 270 | loadTrack(idx);
|
|---|
| 271 | play();
|
|---|
| 272 | }
|
|---|
| 273 | });
|
|---|
| 274 | });
|
|---|
| 275 | sheetQueue.hidden = queue.length < 2;
|
|---|
| 276 | }
|
|---|
| 277 |
|
|---|
| 278 | function escapeHtml(s) {
|
|---|
| 279 | return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|---|
| 280 | }
|
|---|
| 281 |
|
|---|
| 282 | // ============================================================
|
|---|
| 283 | // 5. Audio element events → UI sync
|
|---|
| 284 | // ============================================================
|
|---|
| 285 | // Error-counter voorkomt infinite-loop als ALLE tracks broken zijn.
|
|---|
| 286 | let consecutiveErrors = 0;
|
|---|
| 287 |
|
|---|
| 288 | audio.addEventListener('play', () => {
|
|---|
| 289 | isPlaying = true;
|
|---|
| 290 | consecutiveErrors = 0; // reset bij succesvolle play
|
|---|
| 291 | root.classList.add('is-playing');
|
|---|
| 292 | root.classList.remove('audio-needs-tap'); // verstop tap-hint
|
|---|
| 293 | });
|
|---|
| 294 | audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
|
|---|
| 295 | audio.addEventListener('ended', next);
|
|---|
| 296 | audio.addEventListener('error', (e) => {
|
|---|
| 297 | const code = audio.error ? audio.error.code : '?';
|
|---|
| 298 | console.error('[pcms-audio] playback error', code, audio.src, e);
|
|---|
| 299 | consecutiveErrors++;
|
|---|
| 300 | // Bij netwerk/decode-fout: skip naar volgende track ipv stilstaan.
|
|---|
| 301 | // Max 3 fouten op rij voordat we opgeven (anders infinite loop).
|
|---|
| 302 | if (consecutiveErrors < 3 && queue.length > 1) {
|
|---|
| 303 | console.warn('[pcms-audio] auto-skip naar volgende na error', consecutiveErrors);
|
|---|
| 304 | setTimeout(next, 400);
|
|---|
| 305 | }
|
|---|
| 306 | });
|
|---|
| 307 | audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled at', audio.currentTime));
|
|---|
| 308 | audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
|
|---|
| 309 | audio.addEventListener('timeupdate', () => {
|
|---|
| 310 | if (!audio.duration || isNaN(audio.duration)) return;
|
|---|
| 311 | const pct = (audio.currentTime / audio.duration) * 100;
|
|---|
| 312 | seekFill.style.width = pct + '%';
|
|---|
| 313 | sheetSeekFill.style.width = pct + '%';
|
|---|
| 314 | currentEl.textContent = formatTime(audio.currentTime);
|
|---|
| 315 | totalEl.textContent = formatTime(audio.duration);
|
|---|
| 316 | sheetCurrent.textContent = formatTime(audio.currentTime);
|
|---|
| 317 | sheetTotal.textContent = formatTime(audio.duration);
|
|---|
| 318 | });
|
|---|
| 319 |
|
|---|
| 320 | function formatTime(s) {
|
|---|
| 321 | if (!s || isNaN(s)) return '0:00';
|
|---|
| 322 | const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
|---|
| 323 | return m + ':' + (sec < 10 ? '0' : '') + sec;
|
|---|
| 324 | }
|
|---|
| 325 |
|
|---|
| 326 | function attachSeek(seekEl) {
|
|---|
| 327 | seekEl.addEventListener('click', (e) => {
|
|---|
| 328 | if (!audio.duration) return;
|
|---|
| 329 | const rect = seekEl.getBoundingClientRect();
|
|---|
| 330 | const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|---|
| 331 | audio.currentTime = ratio * audio.duration;
|
|---|
| 332 | });
|
|---|
| 333 | }
|
|---|
| 334 | attachSeek(seek);
|
|---|
| 335 | attachSeek(sheetSeek);
|
|---|
| 336 |
|
|---|
| 337 | audio.volume = 0.8;
|
|---|
| 338 | volumeSlider.addEventListener('input', () => {
|
|---|
| 339 | audio.volume = volumeSlider.value / 100;
|
|---|
| 340 | if (volumeSlider.value > 0) audio.muted = false;
|
|---|
| 341 | });
|
|---|
| 342 | muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; });
|
|---|
| 343 |
|
|---|
| 344 | // ============================================================
|
|---|
| 345 | // 6. Control wiring
|
|---|
| 346 | // ============================================================
|
|---|
| 347 | playBtn.addEventListener('click', togglePlay);
|
|---|
| 348 | prevBtn.addEventListener('click', prev);
|
|---|
| 349 | nextBtn.addEventListener('click', next);
|
|---|
| 350 | sheetPlay.addEventListener('click', togglePlay);
|
|---|
| 351 | sheetPrev.addEventListener('click', prev);
|
|---|
| 352 | sheetNext.addEventListener('click', next);
|
|---|
| 353 |
|
|---|
| 354 | // ============================================================
|
|---|
| 355 | // 7. Sheet expand/close + drag-down-to-close
|
|---|
| 356 | // ============================================================
|
|---|
| 357 | function openSheet() {
|
|---|
| 358 | sheet.classList.add('is-open');
|
|---|
| 359 | sheet.setAttribute('aria-hidden', 'false');
|
|---|
| 360 | document.body.classList.add('audio-sheet-locked');
|
|---|
| 361 | }
|
|---|
| 362 | function closeSheet() {
|
|---|
| 363 | sheet.classList.remove('is-open');
|
|---|
| 364 | sheet.setAttribute('aria-hidden', 'true');
|
|---|
| 365 | document.body.classList.remove('audio-sheet-locked');
|
|---|
| 366 | sheetPanel.style.removeProperty('--pcms-drag-y');
|
|---|
| 367 | sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
|
|---|
| 368 | }
|
|---|
| 369 | expandTrigger.addEventListener('click', openSheet);
|
|---|
| 370 | sheetClose.addEventListener('click', closeSheet);
|
|---|
| 371 | sheetBackdrop.addEventListener('click', closeSheet);
|
|---|
| 372 | document.addEventListener('keydown', (e) => {
|
|---|
| 373 | if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
|
|---|
| 374 | });
|
|---|
| 375 |
|
|---|
| 376 | // Drag-down-to-close on touch devices.
|
|---|
| 377 | //
|
|---|
| 378 | // We set --pcms-drag-y as a CSS custom prop instead of writing
|
|---|
| 379 | // sheetPanel.style.transform directly. The reason: on desktop the panel
|
|---|
| 380 | // uses `transform: translate(-50%, 0)` for horizontal centering. Writing
|
|---|
| 381 | // `style.transform = translateY(...)` would obliterate the -50% and the
|
|---|
| 382 | // panel would jump rightward. With a custom prop, audio.css composes the
|
|---|
| 383 | // final transform per breakpoint:
|
|---|
| 384 | // mobile: transform: translateY(var(--pcms-drag-y, 0))
|
|---|
| 385 | // desktop: transform: translate(-50%, var(--pcms-drag-y, 0))
|
|---|
| 386 | let dragStartY = 0, dragLastY = 0, isDragging = false;
|
|---|
| 387 | function onPointerDown(e) {
|
|---|
| 388 | if (e.pointerType !== 'touch') return;
|
|---|
| 389 | if (sheetPanel.scrollTop > 0) return; // queue is scrolled, don't drag
|
|---|
| 390 | dragStartY = dragLastY = e.clientY;
|
|---|
| 391 | isDragging = true;
|
|---|
| 392 | sheetPanel.classList.add('is-dragging');
|
|---|
| 393 | sheetBackdrop.classList.add('is-dragging');
|
|---|
| 394 | }
|
|---|
| 395 | function onPointerMove(e) {
|
|---|
| 396 | if (!isDragging) return;
|
|---|
| 397 | dragLastY = e.clientY;
|
|---|
| 398 | const dy = Math.max(0, dragLastY - dragStartY);
|
|---|
| 399 | sheetPanel.style.setProperty('--pcms-drag-y', dy + 'px');
|
|---|
| 400 | const progress = Math.max(0, 1 - dy / sheetPanel.offsetHeight);
|
|---|
| 401 | sheetBackdrop.style.setProperty('--pcms-sheet-progress', String(progress));
|
|---|
| 402 | }
|
|---|
| 403 | function onPointerUp() {
|
|---|
| 404 | if (!isDragging) return;
|
|---|
| 405 | isDragging = false;
|
|---|
| 406 | sheetPanel.classList.remove('is-dragging');
|
|---|
| 407 | sheetBackdrop.classList.remove('is-dragging');
|
|---|
| 408 | const dy = dragLastY - dragStartY;
|
|---|
| 409 | if (dy > 100) {
|
|---|
| 410 | closeSheet();
|
|---|
| 411 | } else {
|
|---|
| 412 | sheetPanel.style.removeProperty('--pcms-drag-y');
|
|---|
| 413 | sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
|
|---|
| 414 | }
|
|---|
| 415 | }
|
|---|
| 416 | if (window.PointerEvent) {
|
|---|
| 417 | dragZone.addEventListener('pointerdown', onPointerDown);
|
|---|
| 418 | document.addEventListener('pointermove', onPointerMove);
|
|---|
| 419 | document.addEventListener('pointerup', onPointerUp);
|
|---|
| 420 | document.addEventListener('pointercancel', onPointerUp);
|
|---|
| 421 | }
|
|---|
| 422 |
|
|---|
| 423 | // ============================================================
|
|---|
| 424 | // 8. Hook up post-audio-track + post-album-cover-btn + .pat-row + .post-album-playall
|
|---|
| 425 | // ============================================================
|
|---|
| 426 | // Four entry points fire the same play action:
|
|---|
| 427 | // - .pat-play → single-track widget in a post
|
|---|
| 428 | // - .pat-row → track row inside an album/playlist tracklist
|
|---|
| 429 | // - .post-album-cover-btn → big cover button (plays album from track 0)
|
|---|
| 430 | // - .post-album-playall → "Speel album" / "Speel playlist" button
|
|---|
| 431 | //
|
|---|
| 432 | // For .pat-play the metadata lives on the surrounding .post-audio-track
|
|---|
| 433 | // wrapper. For the other three the data is on the button itself. The
|
|---|
| 434 | // handler reads from button-first, falls back to wrapper.
|
|---|
| 435 | function attachListeners() {
|
|---|
| 436 | const playBtns = document.querySelectorAll(
|
|---|
| 437 | '.post-audio-track .pat-play, ' +
|
|---|
| 438 | '.post-album-tracks .pat-row, ' +
|
|---|
| 439 | '.post-album-cover-btn, ' +
|
|---|
| 440 | '.post-album-playall'
|
|---|
| 441 | );
|
|---|
| 442 | if (playBtns.length) console.log('[pcms-audio] attaching to', playBtns.length, 'play buttons');
|
|---|
| 443 |
|
|---|
| 444 | playBtns.forEach((btn) => {
|
|---|
| 445 | if (btn.dataset.pcmsAttached) return;
|
|---|
| 446 | btn.dataset.pcmsAttached = '1';
|
|---|
| 447 |
|
|---|
| 448 | btn.addEventListener('click', (e) => {
|
|---|
| 449 | e.preventDefault();
|
|---|
| 450 | e.stopPropagation();
|
|---|
| 451 | // Resolve metadata: button-first, then closest .post-audio-track wrapper
|
|---|
| 452 | // (only inline single-track widgets put the data on the wrapper).
|
|---|
| 453 | const wrapper = btn.closest('.post-audio-track');
|
|---|
| 454 | const albumId = btn.dataset.pcmsAlbumId || (wrapper && wrapper.dataset.pcmsAlbumId);
|
|---|
| 455 | const trackData = btn.dataset.pcmsTrack || (wrapper && wrapper.dataset.pcmsTrack);
|
|---|
| 456 | const trackUrl = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
|
|---|
| 457 | console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
|
|---|
| 458 |
|
|---|
| 459 | if (albumId) {
|
|---|
| 460 | const album = document.getElementById(albumId);
|
|---|
| 461 | if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
|
|---|
| 462 | try {
|
|---|
| 463 | const tracks = JSON.parse(album.dataset.pcmsAlbum);
|
|---|
| 464 | // Start at the clicked track if we know its URL, else start at 0
|
|---|
| 465 | // (cover-btn and playall both want to start from the beginning).
|
|---|
| 466 | const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
|
|---|
| 467 | setQueue(tracks, startIdx >= 0 ? startIdx : 0, { albumName: album.dataset.pcmsAlbumTitle || '' });
|
|---|
| 468 | } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
|
|---|
| 469 | } else if (trackData) {
|
|---|
| 470 | try {
|
|---|
| 471 | const t = JSON.parse(trackData);
|
|---|
| 472 | setQueue([t], 0);
|
|---|
| 473 | } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
|
|---|
| 474 | } else if (trackUrl) {
|
|---|
| 475 | // Fallback: at minimum we have the signed URL
|
|---|
| 476 | setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '' }], 0);
|
|---|
| 477 | } else {
|
|---|
| 478 | console.error('[pcms-audio] no track data or url on button or wrapper', btn);
|
|---|
| 479 | }
|
|---|
| 480 | });
|
|---|
| 481 | });
|
|---|
| 482 | }
|
|---|
| 483 |
|
|---|
| 484 | attachListeners();
|
|---|
| 485 | document.body.addEventListener('htmx:afterSettle', attachListeners);
|
|---|
| 486 |
|
|---|
| 487 | // ============================================================
|
|---|
| 488 | // 8b. Admin playlist delete (event delegation)
|
|---|
| 489 | // ============================================================
|
|---|
| 490 | // The post-album embed renders a [data-pcms-playlist-delete] button
|
|---|
| 491 | // top-right when the viewer is admin (server decides; not client).
|
|---|
| 492 | // We delegate from document.body so HTMX-swapped content works too.
|
|---|
| 493 | document.body.addEventListener('click', async (e) => {
|
|---|
| 494 | const btn = e.target.closest('[data-pcms-playlist-delete]');
|
|---|
| 495 | if (!btn) return;
|
|---|
| 496 | e.preventDefault();
|
|---|
| 497 | e.stopPropagation();
|
|---|
| 498 | const id = btn.dataset.pcmsPlaylistDelete;
|
|---|
| 499 | const title = btn.dataset.pcmsPlaylistTitle || id;
|
|---|
| 500 | if (!id) return;
|
|---|
| 501 | if (!confirm(`Playlist "${title}" verwijderen? De embed in deze post toont vanaf nu een placeholder.`)) return;
|
|---|
| 502 | btn.disabled = true;
|
|---|
| 503 | try {
|
|---|
| 504 | const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
|
|---|
| 505 | method: 'POST',
|
|---|
| 506 | headers: { 'X-CSRF-Token': '' },
|
|---|
| 507 | credentials: 'same-origin',
|
|---|
| 508 | });
|
|---|
| 509 | const j = await r.json().catch(() => ({}));
|
|---|
| 510 | if (j && j.ok) {
|
|---|
| 511 | location.reload();
|
|---|
| 512 | } else {
|
|---|
| 513 | alert('Verwijderen mislukt: ' + ((j && j.error) || 'onbekende fout'));
|
|---|
| 514 | btn.disabled = false;
|
|---|
| 515 | }
|
|---|
| 516 | } catch (err) {
|
|---|
| 517 | alert('Verwijderen mislukt: ' + err.message);
|
|---|
| 518 | btn.disabled = false;
|
|---|
| 519 | }
|
|---|
| 520 | });
|
|---|
| 521 |
|
|---|
| 522 | // ============================================================
|
|---|
| 523 | // 9. Public API
|
|---|
| 524 | // ============================================================
|
|---|
| 525 | window.pcmsAudioPlayer = {
|
|---|
| 526 | setQueue, play, pause, next, prev, close, openSheet, closeSheet,
|
|---|
| 527 | isPlaying: () => isPlaying,
|
|---|
| 528 | currentTrack: () => queue[currentIndex] || null,
|
|---|
| 529 | };
|
|---|
| 530 |
|
|---|
| 531 | // ============================================================
|
|---|
| 532 | // 10. Site-level pre-seed (window.PCMS_SITE_TRACKS)
|
|---|
| 533 | // ============================================================
|
|---|
| 534 | if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
|
|---|
| 535 | queue = window.PCMS_SITE_TRACKS.map(t => ({
|
|---|
| 536 | url: t.media_url || t.url,
|
|---|
| 537 | title: t.title || 'Untitled',
|
|---|
| 538 | artist: t.artist || '',
|
|---|
| 539 | cover: t.cover_url || t.cover || '',
|
|---|
| 540 | }));
|
|---|
| 541 | if (queue.length) loadTrack(0);
|
|---|
| 542 | }
|
|---|
| 543 | })();
|
|---|