/** * PrutCMS v10 Audio Player — v9 mini-player + Spotify-style sheet. * * Two surfaces: * 1. .audio-player — bottom strip: cover + meta + controls + progress + volume * 2. .audio-sheet — full-height now-playing panel (slides up from bottom) * * Features: * - Click cover/meta on mini-player → open sheet * - Sheet handle (pill) or backdrop click → close * - Touch swipe-down on the drag-zone → close (mobile only; desktop has the X) * - Reads data-pcms-track-url + data-pcms-track + data-pcms-album from posts * - body.has-audio-player adds bottom padding when player visible * - body.audio-sheet-locked prevents body scroll when sheet open * - Survives HTMX swaps via htmx:afterSettle re-attach * * Singleton — guards against double-init. */ (function() { if (window.pcmsAudioPlayer) return; const SVG = { play: '', pause: '', prev: '', next: '', vol: '', mute: '', musicNote: '', }; // ============================================================ // 1. Build DOM // ============================================================ const root = document.createElement('aside'); root.id = 'pcms-audio-player'; root.className = 'audio-player'; root.setAttribute('aria-label', 'Audio speler'); root.innerHTML = `
0:00
0:00
`; document.body.appendChild(root); // FIX: Move .audio-sheet out of the .audio-player root so its // position:fixed is anchored to the viewport, not to the mini-bar. // The mini-bar has backdrop-filter, which makes it a containing // block for fixed descendants — that broke the sheet's left/right/top/bottom. const _detachedSheet = root.querySelector('.audio-sheet'); if (_detachedSheet) document.body.appendChild(_detachedSheet); // ============================================================ // 2. Element references // ============================================================ const $ = (id) => document.getElementById(id); const audio = $('audio-element'); const cover = $('audio-cover'); const titleEl = $('audio-title'); const artistEl = $('audio-artist'); const seek = $('audio-seek'); const seekFill = $('audio-seek-fill'); const currentEl = $('audio-current'); const totalEl = $('audio-total'); const playBtn = $('audio-play'); const prevBtn = $('audio-prev'); const nextBtn = $('audio-next'); const muteBtn = $('audio-mute'); const volumeSlider = $('audio-volume'); const expandTrigger = $('audio-expand-trigger'); const sheet = $('audio-sheet'); const sheetBackdrop = $('audio-sheet-backdrop'); const sheetPanel = sheet.querySelector('.audio-sheet-panel'); const sheetClose = $('audio-sheet-close'); const sheetCover = $('audio-sheet-cover'); const sheetTitle = $('audio-sheet-title'); const sheetArtist = $('audio-sheet-artist'); const sheetAlbum = $('audio-sheet-album'); const sheetSeek = $('audio-sheet-seek'); const sheetSeekFill = $('audio-sheet-seek-fill'); const sheetCurrent = $('audio-sheet-current'); const sheetTotal = $('audio-sheet-total'); const sheetPlay = $('audio-sheet-play'); const sheetPrev = $('audio-sheet-prev'); const sheetNext = $('audio-sheet-next'); const sheetQueue = $('audio-sheet-queue'); const sheetQueueList = $('audio-sheet-queue-list'); const dragZone = $('audio-sheet-drag-zone'); // ============================================================ // 3. State // ============================================================ let queue = []; let currentIndex = 0; let isPlaying = false; let albumName = ''; // Blob playback state. We fetch each track's bytes and play from a blob: // object URL — no plain media URL is ever exposed to the page. currentObjectUrl // is revoked when we move on, so we don't leak one Blob per track in memory. let currentObjectUrl = null; // Monotonic load token: a fast prev/next can fire several loads before an // earlier fetch resolves. Only the latest load may set audio.src. let loadSeq = 0; // Next-track prefetch. While the current track plays we download the *next* // track's bytes into a held blob, so `ended` → next() can swap src instantly // (no silent gap, and no long async window where the browser's autoplay // activation can lapse and reject play()). At most one track ahead is held. // Shape: { url, objUrl } — objUrl is null while the fetch is still in flight. let preload = null; // Hide initially root.classList.add('audio-player-hidden'); // ============================================================ // 4. Track / queue loading // ============================================================ function setCoverImage(el, url) { if (url) { el.style.backgroundImage = `url("${url}")`; el.classList.add('has-image'); el.innerHTML = ''; } else { el.style.backgroundImage = ''; el.classList.remove('has-image'); el.innerHTML = SVG.musicNote; } } // Fetch the track bytes and hand back a blob: object URL. The X-Audio-Player // header + same-origin credentials get us past the stream route's access gate. // Retries a few times with backoff: a single transient network blip used to // bump the error counter and SKIP the song (auto-advance past it). Now one // hiccup just costs a retry, and we only give up after genuinely failing. async function fetchAsObjectUrl(url, attempts) { attempts = attempts || 1; let lastErr; for (let i = 0; i < attempts; i++) { try { const r = await fetch(url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' }, }); if (!r.ok) throw new Error('HTTP ' + r.status); const blob = await r.blob(); return URL.createObjectURL(blob); } catch (e) { lastErr = e; if (i < attempts - 1) { await new Promise((res) => setTimeout(res, 350 * (i + 1))); } } } throw lastErr; } // Apply a ready blob URL to the