| 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 | // Dubbele driehoeken: het teken voor spoelen, niet voor overslaan.
|
|---|
| 30 | rew: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 12l9-7v14zM2 12l9-7v14z" fill="currentColor"/></svg>',
|
|---|
| 31 | ff: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 12L4 5v14zM22 12l-9-7v14z" fill="currentColor"/></svg>',
|
|---|
| 32 | 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>',
|
|---|
| 33 | };
|
|---|
| 34 |
|
|---|
| 35 | // ============================================================
|
|---|
| 36 | // 1. Build DOM
|
|---|
| 37 | // ============================================================
|
|---|
| 38 | const root = document.createElement('aside');
|
|---|
| 39 | root.id = 'pcms-audio-player';
|
|---|
| 40 | root.className = 'audio-player';
|
|---|
| 41 | root.setAttribute('aria-label', 'Audio speler');
|
|---|
| 42 | root.innerHTML = `
|
|---|
| 43 | <div class="audio-player-inner">
|
|---|
| 44 | <button type="button" class="audio-player-expand-trigger" id="audio-expand-trigger" aria-label="Vergroot speler">
|
|---|
| 45 | <div class="audio-player-cover" id="audio-cover" aria-hidden="true">${SVG.musicNote}</div>
|
|---|
| 46 | <div class="audio-player-meta">
|
|---|
| 47 | <div class="audio-player-title-wrap"><span class="audio-player-title" id="audio-title">No track</span></div>
|
|---|
| 48 | <div class="audio-player-artist" id="audio-artist"></div>
|
|---|
| 49 | </div>
|
|---|
| 50 | </button>
|
|---|
| 51 | <div class="audio-player-controls">
|
|---|
| 52 | <button type="button" class="audio-btn" id="audio-prev" aria-label="Vorige" title="Vorige">${SVG.prev}</button>
|
|---|
| 53 | <button type="button" class="audio-btn audio-btn-play" id="audio-play" aria-label="Afspelen">
|
|---|
| 54 | <span class="icon-play">${SVG.play}</span><span class="icon-pause">${SVG.pause}</span>
|
|---|
| 55 | </button>
|
|---|
| 56 | <button type="button" class="audio-btn" id="audio-next" aria-label="Volgende" title="Volgende">${SVG.next}</button>
|
|---|
| 57 | </div>
|
|---|
| 58 | <div class="audio-player-progress">
|
|---|
| 59 | <span class="audio-time mono" id="audio-current">0:00</span>
|
|---|
| 60 | <div class="audio-seek" id="audio-seek" role="slider" aria-label="Voortgang" tabindex="0">
|
|---|
| 61 | <div class="audio-seek-bar"><div class="audio-seek-fill" id="audio-seek-fill"></div></div>
|
|---|
| 62 | </div>
|
|---|
| 63 | <span class="audio-time mono" id="audio-total">0:00</span>
|
|---|
| 64 | </div>
|
|---|
| 65 | <div class="audio-player-volume">
|
|---|
| 66 | <button type="button" class="audio-btn" id="audio-mute" aria-label="Mute">
|
|---|
| 67 | <span class="icon-vol">${SVG.vol}</span><span class="icon-mute">${SVG.mute}</span>
|
|---|
| 68 | </button>
|
|---|
| 69 | <div class="audio-volume-popup">
|
|---|
| 70 | <input type="range" id="audio-volume" min="0" max="100" value="80" aria-label="Volume">
|
|---|
| 71 | </div>
|
|---|
| 72 | </div>
|
|---|
| 73 | </div>
|
|---|
| 74 | <audio id="audio-element" preload="none" playsinline webkit-playsinline controlsList="nodownload"></audio>
|
|---|
| 75 |
|
|---|
| 76 | <div class="audio-sheet" id="audio-sheet" aria-hidden="true">
|
|---|
| 77 | <div class="audio-sheet-backdrop" id="audio-sheet-backdrop"></div>
|
|---|
| 78 | <div class="audio-sheet-panel" role="dialog" aria-label="Now playing">
|
|---|
| 79 | <div class="audio-sheet-drag-zone" id="audio-sheet-drag-zone">
|
|---|
| 80 | <button type="button" class="audio-sheet-handle" id="audio-sheet-close" aria-label="Speler verkleinen"></button>
|
|---|
| 81 | <div class="audio-sheet-cover" id="audio-sheet-cover" aria-hidden="true">${SVG.musicNote}</div>
|
|---|
| 82 | </div>
|
|---|
| 83 | <div class="audio-sheet-info">
|
|---|
| 84 | <div class="audio-sheet-title" id="audio-sheet-title">—</div>
|
|---|
| 85 | <div class="audio-sheet-artist" id="audio-sheet-artist"></div>
|
|---|
| 86 | <div class="audio-sheet-album" id="audio-sheet-album"></div>
|
|---|
| 87 | </div>
|
|---|
| 88 | <div class="audio-sheet-progress">
|
|---|
| 89 | <span class="audio-time mono" id="audio-sheet-current">0:00</span>
|
|---|
| 90 | <div class="audio-seek" id="audio-sheet-seek" role="slider" aria-label="Voortgang" tabindex="0">
|
|---|
| 91 | <div class="audio-seek-bar"><div class="audio-seek-fill" id="audio-sheet-seek-fill"></div></div>
|
|---|
| 92 | </div>
|
|---|
| 93 | <span class="audio-time mono" id="audio-sheet-total">0:00</span>
|
|---|
| 94 | </div>
|
|---|
| 95 | <div class="audio-sheet-controls">
|
|---|
| 96 | <button type="button" class="audio-btn audio-sheet-btn" id="audio-sheet-prev" aria-label="Vorige">${SVG.prev}</button>
|
|---|
| 97 | <button type="button" class="audio-btn audio-sheet-play" id="audio-sheet-play" aria-label="Afspelen">
|
|---|
| 98 | <span class="icon-play">${SVG.play}</span><span class="icon-pause">${SVG.pause}</span>
|
|---|
| 99 | </button>
|
|---|
| 100 | <button type="button" class="audio-btn audio-sheet-btn" id="audio-sheet-next" aria-label="Volgende">${SVG.next}</button>
|
|---|
| 101 | </div>
|
|---|
| 102 | <div class="audio-sheet-queue" id="audio-sheet-queue" hidden>
|
|---|
| 103 | <div class="audio-sheet-queue-label">Queue</div>
|
|---|
| 104 | <ol class="audio-sheet-queue-list" id="audio-sheet-queue-list"></ol>
|
|---|
| 105 | </div>
|
|---|
| 106 | </div>
|
|---|
| 107 | </div>
|
|---|
| 108 | `;
|
|---|
| 109 | document.body.appendChild(root);
|
|---|
| 110 |
|
|---|
| 111 | // FIX: Move .audio-sheet out of the .audio-player root so its
|
|---|
| 112 | // position:fixed is anchored to the viewport, not to the mini-bar.
|
|---|
| 113 | // The mini-bar has backdrop-filter, which makes it a containing
|
|---|
| 114 | // block for fixed descendants — that broke the sheet's left/right/top/bottom.
|
|---|
| 115 | const _detachedSheet = root.querySelector('.audio-sheet');
|
|---|
| 116 | if (_detachedSheet) document.body.appendChild(_detachedSheet);
|
|---|
| 117 |
|
|---|
| 118 | // ============================================================
|
|---|
| 119 | // 2. Element references
|
|---|
| 120 | // ============================================================
|
|---|
| 121 | const $ = (id) => document.getElementById(id);
|
|---|
| 122 | const audio = $('audio-element');
|
|---|
| 123 | const cover = $('audio-cover');
|
|---|
| 124 | const titleEl = $('audio-title');
|
|---|
| 125 | const artistEl = $('audio-artist');
|
|---|
| 126 | const seek = $('audio-seek');
|
|---|
| 127 | const seekFill = $('audio-seek-fill');
|
|---|
| 128 | const currentEl = $('audio-current');
|
|---|
| 129 | const totalEl = $('audio-total');
|
|---|
| 130 | const playBtn = $('audio-play');
|
|---|
| 131 | const prevBtn = $('audio-prev');
|
|---|
| 132 | const nextBtn = $('audio-next');
|
|---|
| 133 | // De vier knoppen die in bandmodus van betekenis veranderen: vorige/volgende
|
|---|
| 134 | // worden terugspoelen/vooruitspoelen. Ze staan hier bij elkaar zodat het
|
|---|
| 135 | // omzetten op EEN plek gebeurt.
|
|---|
| 136 | const SPOELKNOPPEN = ['audio-prev', 'audio-next', 'audio-sheet-prev', 'audio-sheet-next'];
|
|---|
| 137 | const muteBtn = $('audio-mute');
|
|---|
| 138 | const volumeSlider = $('audio-volume');
|
|---|
| 139 | const expandTrigger = $('audio-expand-trigger');
|
|---|
| 140 |
|
|---|
| 141 | const sheet = $('audio-sheet');
|
|---|
| 142 | const sheetBackdrop = $('audio-sheet-backdrop');
|
|---|
| 143 | const sheetPanel = sheet.querySelector('.audio-sheet-panel');
|
|---|
| 144 | const sheetClose = $('audio-sheet-close');
|
|---|
| 145 | const sheetCover = $('audio-sheet-cover');
|
|---|
| 146 | const sheetTitle = $('audio-sheet-title');
|
|---|
| 147 | const sheetArtist = $('audio-sheet-artist');
|
|---|
| 148 | const sheetAlbum = $('audio-sheet-album');
|
|---|
| 149 | const sheetSeek = $('audio-sheet-seek');
|
|---|
| 150 | const sheetSeekFill = $('audio-sheet-seek-fill');
|
|---|
| 151 | const sheetCurrent = $('audio-sheet-current');
|
|---|
| 152 | const sheetTotal = $('audio-sheet-total');
|
|---|
| 153 | const sheetPlay = $('audio-sheet-play');
|
|---|
| 154 | const sheetPrev = $('audio-sheet-prev');
|
|---|
| 155 | const sheetNext = $('audio-sheet-next');
|
|---|
| 156 | const sheetQueue = $('audio-sheet-queue');
|
|---|
| 157 | const sheetQueueList = $('audio-sheet-queue-list');
|
|---|
| 158 | const dragZone = $('audio-sheet-drag-zone');
|
|---|
| 159 |
|
|---|
| 160 | // ============================================================
|
|---|
| 161 | // 3. State
|
|---|
| 162 | // ============================================================
|
|---|
| 163 | let queue = [];
|
|---|
| 164 | let currentIndex = 0;
|
|---|
| 165 | let isPlaying = false;
|
|---|
| 166 | let albumName = '';
|
|---|
| 167 | // BANDMODUS (Robins eis, 21-8): een mixtape is EEN object, geen wachtrij met
|
|---|
| 168 | // nummers. De MSE-keten hieronder maakt van een wachtrij toch al een
|
|---|
| 169 | // doorlopende tijdlijn -- "een trackwissel is een positie, geen omschakeling"
|
|---|
| 170 | // -- dus een bandje is precies die tijdlijn, alleen anders getoond en anders
|
|---|
| 171 | // bediend: je ziet de titel van het bandje, de teller loopt over het geheel,
|
|---|
| 172 | // en spoelen gaat in seconden in plaats van per nummer.
|
|---|
| 173 | let tapeMode = false;
|
|---|
| 174 | // Playback pipeline. We fetch each track's bytes ourselves (X-Audio-Player
|
|---|
| 175 | // gate; no plain media URL is ever exposed to the page) and feed them to the
|
|---|
| 176 | // <audio> element through one of two engines:
|
|---|
| 177 | //
|
|---|
| 178 | // 1. MSE chain (Chrome/Firefox/Android): ONE MediaSource + SourceBuffer
|
|---|
| 179 | // ('audio/mpeg', sequence mode — every track is uniform transcoder mp3).
|
|---|
| 180 | // The next track's bytes are APPENDED into the same buffer, so the whole
|
|---|
| 181 | // queue is one continuous playback session. That is what keeps a
|
|---|
| 182 | // backgrounded tab/PWA playing across track changes: Chrome's background
|
|---|
| 183 | // media policy pauses NEW playback sessions started in the background
|
|---|
| 184 | // (the old per-track src-swap + load() + play()), but never interrupts a
|
|---|
| 185 | // continuing one. A track change becomes a timeline position, not a swap.
|
|---|
| 186 | // 2. Blob fallback (iOS Safari — no MSE; or MSE failed at runtime): one
|
|---|
| 187 | // objectURL per track, the previous behaviour.
|
|---|
| 188 | let currentObjectUrl = null;
|
|---|
| 189 | // Monotonic load token: a fast prev/next can fire several loads before an
|
|---|
| 190 | // earlier fetch resolves. Only the latest load may touch the audio pipeline.
|
|---|
| 191 | let loadSeq = 0;
|
|---|
| 192 | // Next-track prefetch (blob engine; the MSE engine appends ahead instead).
|
|---|
| 193 | // Shape: { url, bytes } — bytes is null while the fetch is still in flight.
|
|---|
| 194 | let preload = null;
|
|---|
| 195 | // ── MSE chain state ──
|
|---|
| 196 | const MSE_SUPPORTED = !!(window.MediaSource && MediaSource.isTypeSupported && MediaSource.isTypeSupported('audio/mpeg'));
|
|---|
| 197 | let mseFailed = false; // runtime bail → blob engine for the rest of this session
|
|---|
| 198 | const useMse = () => MSE_SUPPORTED && !mseFailed;
|
|---|
| 199 | let ms = null; // MediaSource
|
|---|
| 200 | let sb = null; // SourceBuffer
|
|---|
| 201 | let chain = []; // appended segments: { qIndex, start, end } (timeline seconds)
|
|---|
| 202 | let chainFetching = false; // a fetch+append for the NEXT track is in flight
|
|---|
| 203 | let sbOps = Promise.resolve(); // serializes SourceBuffer operations
|
|---|
| 204 |
|
|---|
| 205 | // Hide initially
|
|---|
| 206 | root.classList.add('audio-player-hidden');
|
|---|
| 207 |
|
|---|
| 208 | // ============================================================
|
|---|
| 209 | // 4. Track / queue loading
|
|---|
| 210 | // ============================================================
|
|---|
| 211 | function setCoverImage(el, url) {
|
|---|
| 212 | if (url) {
|
|---|
| 213 | el.style.backgroundImage = `url("${url}")`;
|
|---|
| 214 | el.classList.add('has-image');
|
|---|
| 215 | el.innerHTML = '';
|
|---|
| 216 | } else {
|
|---|
| 217 | el.style.backgroundImage = '';
|
|---|
| 218 | el.classList.remove('has-image');
|
|---|
| 219 | el.innerHTML = SVG.musicNote;
|
|---|
| 220 | }
|
|---|
| 221 | }
|
|---|
| 222 |
|
|---|
| 223 | // Fetch the track bytes (ArrayBuffer). The X-Audio-Player header +
|
|---|
| 224 | // same-origin credentials get us past the stream route's access gate.
|
|---|
| 225 | // Retries a few times with backoff: a single transient network blip used to
|
|---|
| 226 | // bump the error counter and SKIP the song (auto-advance past it). Now one
|
|---|
| 227 | // hiccup just costs a retry, and we only give up after genuinely failing.
|
|---|
| 228 | async function fetchTrackBytes(url, attempts) {
|
|---|
| 229 | attempts = attempts || 1;
|
|---|
| 230 | let lastErr;
|
|---|
| 231 | for (let i = 0; i < attempts; i++) {
|
|---|
| 232 | try {
|
|---|
| 233 | const r = await fetch(url, {
|
|---|
| 234 | credentials: 'same-origin',
|
|---|
| 235 | headers: { 'X-Audio-Player': '1' },
|
|---|
| 236 | });
|
|---|
| 237 | if (!r.ok) throw new Error('HTTP ' + r.status);
|
|---|
| 238 | return await r.arrayBuffer();
|
|---|
| 239 | } catch (e) {
|
|---|
| 240 | lastErr = e;
|
|---|
| 241 | if (i < attempts - 1) {
|
|---|
| 242 | await new Promise((res) => setTimeout(res, 350 * (i + 1)));
|
|---|
| 243 | }
|
|---|
| 244 | }
|
|---|
| 245 | }
|
|---|
| 246 | throw lastErr;
|
|---|
| 247 | }
|
|---|
| 248 |
|
|---|
| 249 | // Blob fallback engine: wrap the bytes in an objectURL and swap audio.src.
|
|---|
| 250 | function applyBlobBytes(bytes, autoplay, mySeq) {
|
|---|
| 251 | if (mySeq !== loadSeq) return; // superseded
|
|---|
| 252 | const objUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' }));
|
|---|
| 253 | root.classList.remove('audio-loading');
|
|---|
| 254 | // Free the previously-playing track's blob — otherwise each track leaks a
|
|---|
| 255 | // copy. Never the same handle as objUrl (createObjectURL is unique), so this
|
|---|
| 256 | // can't revoke the source we're about to play.
|
|---|
| 257 | if (currentObjectUrl && currentObjectUrl !== objUrl) {
|
|---|
| 258 | try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
|
|---|
| 259 | }
|
|---|
| 260 | currentObjectUrl = objUrl;
|
|---|
| 261 | // Clean transition: pause + load forces a reset of internal state after
|
|---|
| 262 | // multiple src changes (prevents state corruption of the audio element).
|
|---|
| 263 | try { audio.pause(); } catch (e) {}
|
|---|
| 264 | audio.src = objUrl;
|
|---|
| 265 | try { audio.load(); } catch (e) {}
|
|---|
| 266 | if (autoplay) play();
|
|---|
| 267 | }
|
|---|
| 268 |
|
|---|
| 269 | function onLoadError(err, mySeq) {
|
|---|
| 270 | if (mySeq !== loadSeq) return; // superseded — ignore stale failure
|
|---|
| 271 | root.classList.remove('audio-loading');
|
|---|
| 272 | console.error('[pcms-audio] track load failed after retries', err);
|
|---|
| 273 | // Genuine failure (after retries): bump the counter and auto-skip, but stop
|
|---|
| 274 | // after 3 in a row so a fully-broken queue can't loop "next" forever.
|
|---|
| 275 | consecutiveErrors++;
|
|---|
| 276 | if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
|
|---|
| 277 | }
|
|---|
| 278 |
|
|---|
| 279 | // Discard any held/in-flight preload (plain bytes now — GC handles them).
|
|---|
| 280 | function dropPreload() { preload = null; }
|
|---|
| 281 |
|
|---|
| 282 | // Prefetch the *next* track's bytes in the background. Idempotent: re-calling
|
|---|
| 283 | // while the same track is already cached / in flight is a no-op. Called from
|
|---|
| 284 | // the `playing` event so the network is otherwise idle. Blob engine only —
|
|---|
| 285 | // the MSE engine "preloads" by appending ahead (ensureNextAppended).
|
|---|
| 286 | function preloadNext() {
|
|---|
| 287 | if (queue.length < 2) return;
|
|---|
| 288 | if (tapeMode && currentIndex >= queue.length - 1) return; // einde band
|
|---|
| 289 | const ni = (currentIndex + 1) % queue.length;
|
|---|
| 290 | const t = queue[ni];
|
|---|
| 291 | if (!t || !t.url) return;
|
|---|
| 292 | if (preload && preload.url === t.url) return; // already held or in flight
|
|---|
| 293 | const marker = { url: t.url, bytes: null };
|
|---|
| 294 | preload = marker;
|
|---|
| 295 | fetchTrackBytes(t.url, 2).then((bytes) => {
|
|---|
| 296 | // Only keep it if this is still the track we want next.
|
|---|
| 297 | if (preload === marker) marker.bytes = bytes;
|
|---|
| 298 | }).catch(() => { if (preload === marker) preload = null; });
|
|---|
| 299 | }
|
|---|
| 300 |
|
|---|
| 301 | // ============================================================
|
|---|
| 302 | // 4a. MSE chain engine — one continuous playback session
|
|---|
| 303 | // ============================================================
|
|---|
| 304 | // All tracks are uniform transcoder mp3 (192kbps), so raw frames can be
|
|---|
| 305 | // appended back-to-back into a single 'audio/mpeg' SourceBuffer (its
|
|---|
| 306 | // byte-stream format generates continuous timestamps — sequence mode).
|
|---|
| 307 | // Auto-advance = playback simply flowing into the next track's region.
|
|---|
| 308 |
|
|---|
| 309 | // Strip ID3v2 (leading) / ID3v1 (trailing) tags: tag bytes between two
|
|---|
| 310 | // appended tracks would glitch the MPEG frame parser.
|
|---|
| 311 | function stripId3(buf) {
|
|---|
| 312 | const u8 = new Uint8Array(buf);
|
|---|
| 313 | let start = 0, end = u8.length;
|
|---|
| 314 | if (end > 10 && u8[0] === 0x49 && u8[1] === 0x44 && u8[2] === 0x33) { // "ID3"
|
|---|
| 315 | const size = ((u8[6] & 0x7f) << 21) | ((u8[7] & 0x7f) << 14) | ((u8[8] & 0x7f) << 7) | (u8[9] & 0x7f);
|
|---|
| 316 | const skip = 10 + size + ((u8[5] & 0x10) ? 10 : 0); // +10 when a footer is flagged
|
|---|
| 317 | if (skip < end) start = skip;
|
|---|
| 318 | }
|
|---|
| 319 | if (end - start > 128 && u8[end - 128] === 0x54 && u8[end - 127] === 0x41 && u8[end - 126] === 0x47) end -= 128; // "TAG"
|
|---|
| 320 | return (start === 0 && end === u8.length) ? buf : buf.slice(start, end);
|
|---|
| 321 | }
|
|---|
| 322 |
|
|---|
| 323 | function teardownChain() {
|
|---|
| 324 | chain = [];
|
|---|
| 325 | chainFetching = false;
|
|---|
| 326 | sbOps = Promise.resolve();
|
|---|
| 327 | sb = null;
|
|---|
| 328 | ms = null;
|
|---|
| 329 | }
|
|---|
| 330 |
|
|---|
| 331 | // Serialize a SourceBuffer operation (append/remove): they throw if issued
|
|---|
| 332 | // while the buffer is still updating, so everything funnels through a queue.
|
|---|
| 333 | function sbRun(fn) {
|
|---|
| 334 | const run = () => new Promise((resolve, reject) => {
|
|---|
| 335 | if (!sb || !ms || ms.readyState !== 'open') return resolve();
|
|---|
| 336 | const ok = () => { cleanup(); resolve(); };
|
|---|
| 337 | const err = (e) => { cleanup(); reject(e); };
|
|---|
| 338 | function cleanup() { sb.removeEventListener('updateend', ok); sb.removeEventListener('error', err); }
|
|---|
| 339 | sb.addEventListener('updateend', ok);
|
|---|
| 340 | sb.addEventListener('error', err);
|
|---|
| 341 | try { fn(); } catch (e) { cleanup(); reject(e); }
|
|---|
| 342 | });
|
|---|
| 343 | const p = sbOps.then(run, run);
|
|---|
| 344 | sbOps = p.catch(() => {});
|
|---|
| 345 | return p;
|
|---|
| 346 | }
|
|---|
| 347 |
|
|---|
| 348 | // Append one track's bytes as the next segment of the chain.
|
|---|
| 349 | async function appendSegment(qIndex, bytes, mySeq) {
|
|---|
| 350 | const clean = stripId3(bytes);
|
|---|
| 351 | try {
|
|---|
| 352 | await sbRun(() => sb.appendBuffer(clean));
|
|---|
| 353 | } catch (e) {
|
|---|
| 354 | if (e && e.name === 'QuotaExceededError' && chain.length > 1) {
|
|---|
| 355 | // Evict already-played data and retry once.
|
|---|
| 356 | const seg = currentSegment();
|
|---|
| 357 | if (seg && seg.start > 1) {
|
|---|
| 358 | await sbRun(() => sb.remove(0, seg.start - 0.5));
|
|---|
| 359 | chain = chain.filter((s) => s.end > seg.start - 0.5);
|
|---|
| 360 | }
|
|---|
| 361 | await sbRun(() => sb.appendBuffer(clean));
|
|---|
| 362 | } else {
|
|---|
| 363 | throw e;
|
|---|
| 364 | }
|
|---|
| 365 | }
|
|---|
| 366 | if (mySeq !== loadSeq || !sb) return;
|
|---|
| 367 | const buffered = sb.buffered;
|
|---|
| 368 | const chainEnd = buffered.length ? buffered.end(buffered.length - 1) : 0;
|
|---|
| 369 | const start = chain.length ? chain[chain.length - 1].end : (buffered.length ? buffered.start(0) : 0);
|
|---|
| 370 | chain.push({ qIndex, start, end: chainEnd });
|
|---|
| 371 | // DE STREAM MOET DICHT ALS ER NIETS MEER KOMT, anders vuurt `ended` nooit.
|
|---|
| 372 | // Bij een wachtrij van een was dat al zo. Een BANDJE heeft nu hetzelfde
|
|---|
| 373 | // nodig: sinds hij niet meer rondloopt haakt de keten na het laatste nummer
|
|---|
| 374 | // niets meer aan, en dan bleef de band aan het eind hangen -- de teller
|
|---|
| 375 | // stilstaand op de laatste seconde, isPlaying() waar, en de spoelen
|
|---|
| 376 | // draaiend. Gemeten op dev (22-8): 125,6 van 125,7 en daar bleef hij.
|
|---|
| 377 | const laatsteVanDeBand = tapeMode && qIndex >= queue.length - 1;
|
|---|
| 378 | if ((queue.length === 1 || laatsteVanDeBand) && ms && ms.readyState === 'open') {
|
|---|
| 379 | // Single-track queue: close the stream so `ended` fires (which replays
|
|---|
| 380 | // it, matching the old engine's behaviour). Bij een bandje stopt `ended`
|
|---|
| 381 | // hem juist, want next() pauzeert daar aan het eind.
|
|---|
| 382 | try { ms.endOfStream(); } catch (e) {}
|
|---|
| 383 | }
|
|---|
| 384 | }
|
|---|
| 385 |
|
|---|
| 386 | // Keep exactly one full track appended ahead of the one playing.
|
|---|
| 387 | function ensureNextAppended() {
|
|---|
| 388 | if (!useMse() || !sb || !ms || ms.readyState !== 'open' || chainFetching) return;
|
|---|
| 389 | if (queue.length < 2 || !chain.length) return;
|
|---|
| 390 | const seg = currentSegment();
|
|---|
| 391 | if (!seg || chain.length - 1 - chain.indexOf(seg) >= 1) return; // already one ahead
|
|---|
| 392 | // EEN BANDJE LOOPT NIET ROND (Robins eis, 22-8). Deze modulo is precies wat
|
|---|
| 393 | // een cassette eindeloos maakte: na het laatste nummer hing hij nummer een
|
|---|
| 394 | // er weer achter, en omdat het een doorlopende keten is merk je dat niet
|
|---|
| 395 | // eens als een trackwissel -- de band gaat gewoon door.
|
|---|
| 396 | const volgendeInRij = chain[chain.length - 1].qIndex + 1;
|
|---|
| 397 | if (tapeMode && volgendeInRij > queue.length - 1) return; // einde band
|
|---|
| 398 | const nextIdx = volgendeInRij % queue.length;
|
|---|
| 399 | const t = queue[nextIdx];
|
|---|
| 400 | if (!t || !t.url) return;
|
|---|
| 401 | chainFetching = true;
|
|---|
| 402 | const mySeq = loadSeq;
|
|---|
| 403 | const bytesP = (preload && preload.url === t.url && preload.bytes)
|
|---|
| 404 | ? Promise.resolve(preload.bytes)
|
|---|
| 405 | : fetchTrackBytes(t.url, 2);
|
|---|
| 406 | bytesP.then((bytes) => {
|
|---|
| 407 | if (mySeq !== loadSeq) return;
|
|---|
| 408 | if (preload && preload.url === t.url) preload = null;
|
|---|
| 409 | return appendSegment(nextIdx, bytes, mySeq);
|
|---|
| 410 | }).catch((e) => {
|
|---|
| 411 | console.warn('[pcms-audio] next-track append failed', e);
|
|---|
| 412 | }).finally(() => { chainFetching = false; });
|
|---|
| 413 | }
|
|---|
| 414 |
|
|---|
| 415 | // Drop played-out data so the buffer holds ~2 tracks at most.
|
|---|
| 416 | function pruneBuffer(curSeg) {
|
|---|
| 417 | if (!useMse() || !sb || !ms || ms.readyState !== 'open') return;
|
|---|
| 418 | const cut = curSeg.start - 0.5;
|
|---|
| 419 | if (cut <= 1) return;
|
|---|
| 420 | sbRun(() => sb.remove(0, cut)).catch(() => {});
|
|---|
| 421 | chain = chain.filter((s) => s.end > cut);
|
|---|
| 422 | }
|
|---|
| 423 |
|
|---|
| 424 | function currentSegment() {
|
|---|
| 425 | const t = audio.currentTime || 0;
|
|---|
| 426 | for (let i = 0; i < chain.length; i++) if (t < chain[i].end - 0.05) return chain[i];
|
|---|
| 427 | return chain[chain.length - 1] || null;
|
|---|
| 428 | }
|
|---|
| 429 |
|
|---|
| 430 | // Playback flowed across a track boundary (the gapless auto-advance):
|
|---|
| 431 | // update chrome/metadata, top the buffer up, evict what's been played.
|
|---|
| 432 | function maybeCrossBoundary() {
|
|---|
| 433 | const seg = currentSegment();
|
|---|
| 434 | if (!seg || seg.qIndex === currentIndex) return;
|
|---|
| 435 | currentIndex = seg.qIndex;
|
|---|
| 436 | const t = queue[currentIndex];
|
|---|
| 437 | if (t) {
|
|---|
| 438 | console.log('[pcms-audio] gapless auto-advance →', t.title);
|
|---|
| 439 | updateTrackChrome(t);
|
|---|
| 440 | }
|
|---|
| 441 | ensureNextAppended();
|
|---|
| 442 | pruneBuffer(seg);
|
|---|
| 443 | updatePositionState();
|
|---|
| 444 | savePlayerState();
|
|---|
| 445 | }
|
|---|
| 446 |
|
|---|
| 447 | // Start a fresh chain at queue[index]. Manual actions only (start/jump/
|
|---|
| 448 | // prev/next/restore) — those happen in the foreground, where starting a
|
|---|
| 449 | // new playback session is allowed.
|
|---|
| 450 | function chainStart(index, autoplay, mySeq, bytes) {
|
|---|
| 451 | teardownChain();
|
|---|
| 452 | ms = new MediaSource();
|
|---|
| 453 | const msUrl = URL.createObjectURL(ms);
|
|---|
| 454 | if (currentObjectUrl && currentObjectUrl !== msUrl) {
|
|---|
| 455 | try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
|
|---|
| 456 | }
|
|---|
| 457 | currentObjectUrl = msUrl;
|
|---|
| 458 | try { audio.pause(); } catch (e) {}
|
|---|
| 459 | audio.src = msUrl;
|
|---|
| 460 | try { audio.load(); } catch (e) {}
|
|---|
| 461 | const bailToBlob = (e) => {
|
|---|
| 462 | console.warn('[pcms-audio] MSE unavailable, using blob playback', e);
|
|---|
| 463 | mseFailed = true;
|
|---|
| 464 | teardownChain();
|
|---|
| 465 | if (mySeq === loadSeq) applyBlobBytes(bytes, autoplay, mySeq);
|
|---|
| 466 | };
|
|---|
| 467 | ms.addEventListener('sourceopen', () => {
|
|---|
| 468 | if (mySeq !== loadSeq || !ms) return;
|
|---|
| 469 | try {
|
|---|
| 470 | sb = ms.addSourceBuffer('audio/mpeg');
|
|---|
| 471 | } catch (e) { return bailToBlob(e); }
|
|---|
| 472 | appendSegment(index, bytes, mySeq).then(() => {
|
|---|
| 473 | if (mySeq !== loadSeq) return;
|
|---|
| 474 | // Session-restore: land at the saved in-track position.
|
|---|
| 475 | if (pendingSeek > 0 && chain.length) {
|
|---|
| 476 | const seg = chain[0];
|
|---|
| 477 | try { audio.currentTime = Math.min(pendingSeek, (seg.end - seg.start) - 0.25); } catch (e) {}
|
|---|
| 478 | pendingSeek = 0;
|
|---|
| 479 | }
|
|---|
| 480 | ensureNextAppended();
|
|---|
| 481 | }).catch(bailToBlob);
|
|---|
| 482 | }, { once: true });
|
|---|
| 483 | if (autoplay) play();
|
|---|
| 484 | }
|
|---|
| 485 |
|
|---|
| 486 | // Current position/duration in TRACK coordinates (the MSE timeline is the
|
|---|
| 487 | // whole chain; the UI always shows the single playing track).
|
|---|
| 488 | // De positie BINNEN het huidige nummer. Apart van displayTimes(), want die
|
|---|
| 489 | // geeft in bandmodus de teller over de hele band -- en het opslaan van de
|
|---|
| 490 | // sessie heeft juist de trackpositie nodig: bij het herstellen begint de
|
|---|
| 491 | // keten opnieuw en start dit nummer weer op nul.
|
|---|
| 492 | function trackTijd() {
|
|---|
| 493 | if (useMse() && chain.length) {
|
|---|
| 494 | const seg = currentSegment();
|
|---|
| 495 | if (seg) return { cur: Math.max(0, (audio.currentTime || 0) - seg.start), dur: seg.end - seg.start };
|
|---|
| 496 | }
|
|---|
| 497 | return { cur: audio.currentTime || 0, dur: audio.duration };
|
|---|
| 498 | }
|
|---|
| 499 |
|
|---|
| 500 | // De lengte van de hele band, uit de bekende trackduren. Null zodra er van
|
|---|
| 501 | // een nummer geen duur bekend is: een som met gaten is een verzonnen getal,
|
|---|
| 502 | // en dan valt de teller liever terug op wat hij wel zeker weet.
|
|---|
| 503 | function bandDuur() {
|
|---|
| 504 | if (!queue.length) return null;
|
|---|
| 505 | let som = 0;
|
|---|
| 506 | for (const t of queue) {
|
|---|
| 507 | const d = Number(t && t.duration) || 0;
|
|---|
| 508 | if (d <= 0) return null;
|
|---|
| 509 | som += d;
|
|---|
| 510 | }
|
|---|
| 511 | return som;
|
|---|
| 512 | }
|
|---|
| 513 |
|
|---|
| 514 | /** Hoeveel band ligt er voor nummer `i`. */
|
|---|
| 515 | function bandOffset(i) {
|
|---|
| 516 | let som = 0;
|
|---|
| 517 | for (let n = 0; n < i && n < queue.length; n++) som += Number(queue[n].duration) || 0;
|
|---|
| 518 | return som;
|
|---|
| 519 | }
|
|---|
| 520 |
|
|---|
| 521 | function displayTimes() {
|
|---|
| 522 | // EEN BANDJE HEEFT EEN TELLER, geen nummerpositie. Hij telt door over de
|
|---|
| 523 | // kant heen.
|
|---|
| 524 | //
|
|---|
| 525 | // Uit de trackduren en niet uit de keten, en dat verschil is zichtbaar: de
|
|---|
| 526 | // keten bevat alleen wat gebufferd is, dus het totaal groeide mee tijdens
|
|---|
| 527 | // het luisteren (2:05 met een nummer geladen, 4:07 met drie). En sprong je
|
|---|
| 528 | // naar nummer drie, dan begon de keten daar opnieuw op nul en stond de
|
|---|
| 529 | // teller weer aan het begin van de band.
|
|---|
| 530 | if (tapeMode) {
|
|---|
| 531 | const totaal = bandDuur();
|
|---|
| 532 | if (totaal) return { cur: bandOffset(currentIndex) + (trackTijd().cur || 0), dur: totaal };
|
|---|
| 533 | if (useMse() && chain.length) return { cur: audio.currentTime || 0, dur: chain[chain.length - 1].end };
|
|---|
| 534 | }
|
|---|
| 535 | return trackTijd();
|
|---|
| 536 | }
|
|---|
| 537 |
|
|---|
| 538 | // metaOnly: show the track in the UI but DON'T download its bytes yet.
|
|---|
| 539 | // Used by the site pre-seed so opening a page doesn't auto-download audio;
|
|---|
| 540 | // the blob is fetched lazily on the first play().
|
|---|
| 541 | // Persistently mark the current track (stays highlighted as long as it's active).
|
|---|
| 542 | function markPlaying(trackId) {
|
|---|
| 543 | document.querySelectorAll('.pat-playing').forEach((e) => e.classList.remove('pat-playing'));
|
|---|
| 544 | if (!trackId) return;
|
|---|
| 545 | const el = document.getElementById('track-' + trackId);
|
|---|
| 546 | if (el) el.classList.add('pat-playing');
|
|---|
| 547 | }
|
|---|
| 548 | // After an htmx navigation the post DOM is replaced → reapply the highlight.
|
|---|
| 549 | document.body.addEventListener('htmx:afterSettle', () => {
|
|---|
| 550 | const t = queue[currentIndex];
|
|---|
| 551 | if (t) markPlaying(t.id);
|
|---|
| 552 | });
|
|---|
| 553 |
|
|---|
| 554 | // Media Session metadata (lock-screen / notification info + artwork). Set per
|
|---|
| 555 | // track; the action handlers are wired once below. Keeping a live media session
|
|---|
| 556 | // is what lets iOS continue a programmatic auto-advance play() instead of
|
|---|
| 557 | // pausing it immediately.
|
|---|
| 558 | function updateMediaMetadata(t) {
|
|---|
| 559 | if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
|
|---|
| 560 | try {
|
|---|
| 561 | const art = [];
|
|---|
| 562 | if (t && t.cover) {
|
|---|
| 563 | let u = t.cover; try { u = new URL(t.cover, location.href).href; } catch (e) {}
|
|---|
| 564 | art.push({ src: u, sizes: '512x512', type: '' });
|
|---|
| 565 | }
|
|---|
| 566 | navigator.mediaSession.metadata = new MediaMetadata({
|
|---|
| 567 | title: (t && t.title) || 'Untitled',
|
|---|
| 568 | artist: (t && t.artist) || '',
|
|---|
| 569 | album: albumName || '',
|
|---|
| 570 | artwork: art,
|
|---|
| 571 | });
|
|---|
| 572 | } catch (e) { /* non-fatal */ }
|
|---|
| 573 | }
|
|---|
| 574 |
|
|---|
| 575 | // All the visible per-track chrome: titles, covers, queue highlight, media
|
|---|
| 576 | // session metadata. Called from loadTrack AND from the gapless boundary-cross.
|
|---|
| 577 | function updateTrackChrome(t) {
|
|---|
| 578 | // In bandmodus staat het BANDJE op de speler. Wat er op dit moment klinkt
|
|---|
| 579 | // staat eronder, zoals een cassette een titel op het label heeft en de
|
|---|
| 580 | // nummers op het doosje.
|
|---|
| 581 | const hoofd = tapeMode ? (albumName || 'Mixtape') : (t.title || 'Untitled');
|
|---|
| 582 | const onder = tapeMode ? (t.title || '') : (t.artist || '');
|
|---|
| 583 | titleEl.textContent = hoofd;
|
|---|
| 584 | artistEl.textContent = onder;
|
|---|
| 585 | sheetTitle.textContent = hoofd;
|
|---|
| 586 | sheetArtist.textContent = onder;
|
|---|
| 587 | sheetAlbum.textContent = albumName || '';
|
|---|
| 588 | setCoverImage(cover, t.cover);
|
|---|
| 589 | setCoverImage(sheetCover, t.cover);
|
|---|
| 590 | root.classList.remove('audio-player-hidden');
|
|---|
| 591 | document.body.classList.add('has-audio-player');
|
|---|
| 592 | renderQueue();
|
|---|
| 593 | markPlaying(t.id);
|
|---|
| 594 | updateMediaMetadata(t);
|
|---|
| 595 | }
|
|---|
| 596 |
|
|---|
| 597 | function loadTrack(index, autoplay, metaOnly) {
|
|---|
| 598 | if (!queue[index]) {
|
|---|
| 599 | console.warn('[pcms-audio] loadTrack: no track at index', index);
|
|---|
| 600 | return;
|
|---|
| 601 | }
|
|---|
| 602 | currentIndex = index;
|
|---|
| 603 | const t = queue[index];
|
|---|
| 604 | if (!t.url) {
|
|---|
| 605 | console.error('[pcms-audio] track has no url', t);
|
|---|
| 606 | return;
|
|---|
| 607 | }
|
|---|
| 608 | console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : '');
|
|---|
| 609 | // Metadata + chrome update synchronously so the UI reacts instantly while
|
|---|
| 610 | // the bytes download.
|
|---|
| 611 | updateTrackChrome(t);
|
|---|
| 612 |
|
|---|
| 613 | if (metaOnly) return;
|
|---|
| 614 |
|
|---|
| 615 | const mySeq = ++loadSeq;
|
|---|
| 616 |
|
|---|
| 617 | // Fast path: the bytes for this exact track were already prefetched while
|
|---|
| 618 | // the previous track played → no gap, no fetch window.
|
|---|
| 619 | let bytesP;
|
|---|
| 620 | if (preload && preload.url === t.url && preload.bytes) {
|
|---|
| 621 | bytesP = Promise.resolve(preload.bytes);
|
|---|
| 622 | preload = null;
|
|---|
| 623 | } else {
|
|---|
| 624 | // Not preloaded (or still in flight) → drop any stale preload and fetch
|
|---|
| 625 | // fresh, retrying transient failures before giving up.
|
|---|
| 626 | dropPreload();
|
|---|
| 627 | root.classList.add('audio-loading');
|
|---|
| 628 | bytesP = fetchTrackBytes(t.url, 3);
|
|---|
| 629 | }
|
|---|
| 630 | bytesP.then((bytes) => {
|
|---|
| 631 | if (mySeq !== loadSeq) return;
|
|---|
| 632 | root.classList.remove('audio-loading');
|
|---|
| 633 | if (useMse()) chainStart(index, autoplay, mySeq, bytes);
|
|---|
| 634 | else applyBlobBytes(bytes, autoplay, mySeq);
|
|---|
| 635 | }).catch((err) => onLoadError(err, mySeq));
|
|---|
| 636 | }
|
|---|
| 637 |
|
|---|
| 638 | // True when the viewport is in the mobile sheet-layout — matches the CSS
|
|---|
| 639 | // breakpoint where .audio-sheet slides up full-width from the bottom
|
|---|
| 640 | // (@media max-width:719.98px). On wider/desktop widths the sheet is a centered
|
|---|
| 641 | // panel, so we do NOT auto-open it there.
|
|---|
| 642 | // Three layouts (Robin 2026-06-15): phone (<768) = fullscreen sheet;
|
|---|
| 643 | // tablet/car (768–1199) = large landscape full-player (tablet + car-mode);
|
|---|
| 644 | // desktop (≥1200) = mini-player only, NO full player. matchMedia so this
|
|---|
| 645 | // exactly follows the CSS breakpoints.
|
|---|
| 646 | function playerTier() {
|
|---|
| 647 | if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop';
|
|---|
| 648 | if (window.matchMedia('(min-width: 768px)').matches) return 'tablet';
|
|---|
| 649 | return 'phone';
|
|---|
| 650 | }
|
|---|
| 651 | function hasFullPlayer() { return playerTier() !== 'desktop'; }
|
|---|
| 652 | function isMobileView() { return playerTier() === 'phone'; }
|
|---|
| 653 |
|
|---|
| 654 | function setQueue(tracks, startIdx, opts) {
|
|---|
| 655 | queue = Array.isArray(tracks) ? tracks.slice() : [];
|
|---|
| 656 | albumName = (opts && opts.albumName) || '';
|
|---|
| 657 | tapeMode = !!(opts && opts.asTape);
|
|---|
| 658 | zetKnopStanden();
|
|---|
| 659 | if (!queue.length) return;
|
|---|
| 660 | loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
|
|---|
| 661 | // Mobile: a track press from an album/playlist auto-opens the full
|
|---|
| 662 | // now-playing sheet (Spotify-style) instead of just the thin mini-strip.
|
|---|
| 663 | // Only here (setQueue = a fresh, user-initiated queue) —
|
|---|
| 664 | // not on next/prev or the site pre-seed — so a sheet the user
|
|---|
| 665 | // deliberately closed doesn't reappear by itself.
|
|---|
| 666 | if (hasFullPlayer()) openSheet();
|
|---|
| 667 | }
|
|---|
| 668 |
|
|---|
| 669 | function play() {
|
|---|
| 670 | if (!audio.src) {
|
|---|
| 671 | // Nothing fetched yet (pre-seed showed metadata only, or a load is still
|
|---|
| 672 | // in flight). Kick off the blob load for the current track and autoplay.
|
|---|
| 673 | if (queue[currentIndex]) loadTrack(currentIndex, true);
|
|---|
| 674 | return;
|
|---|
| 675 | }
|
|---|
| 676 | const p = audio.play();
|
|---|
| 677 | if (p && typeof p.catch === 'function') {
|
|---|
| 678 | p.catch((err) => {
|
|---|
| 679 | console.warn('[pcms-audio] play() rejected:', err.name, err.message);
|
|---|
| 680 | // Browser autoplay policy blocked it (typically after 3-4
|
|---|
| 681 | // auto-plays on iOS Safari, or when the tab was temporarily inactive).
|
|---|
| 682 | // Show a visual hint for the user to tap play.
|
|---|
| 683 | if (err && err.name === 'NotAllowedError') {
|
|---|
| 684 | root.classList.add('audio-needs-tap');
|
|---|
| 685 | isPlaying = false;
|
|---|
| 686 | root.classList.remove('is-playing');
|
|---|
| 687 | }
|
|---|
| 688 | });
|
|---|
| 689 | }
|
|---|
| 690 | }
|
|---|
| 691 | function pause() { audio.pause(); }
|
|---|
| 692 | function togglePlay() { audio.paused ? play() : pause(); }
|
|---|
| 693 | function next() {
|
|---|
| 694 | if (!queue.length) return;
|
|---|
| 695 | // Aan het eind van een bandje: stoppen. `ended` roept deze functie aan, dus
|
|---|
| 696 | // zonder deze tak begint de band na het laatste nummer weer vooraan.
|
|---|
| 697 | if (tapeMode && currentIndex >= queue.length - 1) { pause(); return; }
|
|---|
| 698 | loadTrack((currentIndex + 1) % queue.length, true);
|
|---|
| 699 | }
|
|---|
| 700 | function prev() {
|
|---|
| 701 | if (!queue.length) return;
|
|---|
| 702 | // En aan het begin ook niet omlopen. Terugspoelen voorbij het begin levert
|
|---|
| 703 | // de kop van de band op, niet het laatste nummer.
|
|---|
| 704 | if (tapeMode && currentIndex === 0) {
|
|---|
| 705 | try { audio.currentTime = 0; } catch (e) { /* nog niets geladen */ }
|
|---|
| 706 | return;
|
|---|
| 707 | }
|
|---|
| 708 | loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
|
|---|
| 709 | }
|
|---|
| 710 | function close() {
|
|---|
| 711 | pause();
|
|---|
| 712 | mediaRegistry().release(registrySelf);
|
|---|
| 713 | root.classList.add('audio-player-hidden');
|
|---|
| 714 | document.body.classList.remove('has-audio-player');
|
|---|
| 715 | closeSheet();
|
|---|
| 716 | queue = [];
|
|---|
| 717 | albumName = '';
|
|---|
| 718 | tapeMode = false;
|
|---|
| 719 | zetKnopStanden();
|
|---|
| 720 | loadSeq++; // cancel any in-flight load
|
|---|
| 721 | dropPreload();
|
|---|
| 722 | teardownChain();
|
|---|
| 723 | if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
|
|---|
| 724 | currentObjectUrl = null;
|
|---|
| 725 | try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
|
|---|
| 726 | }
|
|---|
| 727 |
|
|---|
| 728 | function renderQueue() {
|
|---|
| 729 | if (!queue.length) { sheetQueue.hidden = true; return; }
|
|---|
| 730 | sheetQueueList.innerHTML = '';
|
|---|
| 731 | queue.forEach((t, i) => {
|
|---|
| 732 | const li = document.createElement('li');
|
|---|
| 733 | li.className = 'audio-sheet-queue-item' + (i === currentIndex ? ' is-current' : '');
|
|---|
| 734 | li.dataset.idx = String(i);
|
|---|
| 735 | li.innerHTML = `<span class="aqi-num">${i + 1}.</span> <span class="aqi-title">${escapeHtml(t.title || 'Untitled')}</span>`
|
|---|
| 736 | + (t.artist ? `<span class="aqi-artist">${escapeHtml(t.artist)}</span>` : '');
|
|---|
| 737 | sheetQueueList.appendChild(li);
|
|---|
| 738 | });
|
|---|
| 739 | // OP EEN BANDJE KIES JE NIET. De lijst blijft staan -- je mag zien wat
|
|---|
| 740 | // erop staat -- maar zonder klik en zonder de opmaak die belooft dat het
|
|---|
| 741 | // kan. Dat is hetzelfde onderscheid als bij de cassette in de post.
|
|---|
| 742 | sheetQueueList.classList.toggle('is-tape', tapeMode);
|
|---|
| 743 | if (tapeMode) return;
|
|---|
| 744 | sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
|
|---|
| 745 | li.addEventListener('click', () => {
|
|---|
| 746 | const idx = parseInt(li.dataset.idx, 10);
|
|---|
| 747 | if (!isNaN(idx) && idx !== currentIndex) {
|
|---|
| 748 | loadTrack(idx, true);
|
|---|
| 749 | }
|
|---|
| 750 | });
|
|---|
| 751 | });
|
|---|
| 752 | sheetQueue.hidden = queue.length < 2;
|
|---|
| 753 | }
|
|---|
| 754 |
|
|---|
| 755 | function escapeHtml(s) {
|
|---|
| 756 | return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|---|
| 757 | }
|
|---|
| 758 |
|
|---|
| 759 | // ============================================================
|
|---|
| 760 | // 4b. Mutual exclusion — shared media registry (see embed-player.js).
|
|---|
| 761 | // ============================================================
|
|---|
| 762 | // All players (this site player + YouTube/SoundCloud/Spotify embeds)
|
|---|
| 763 | // register themselves in window.pcmsMediaRegistry. Starting one pauses
|
|---|
| 764 | // the previous. This is the precise replacement for the old focus/blur
|
|---|
| 765 | // heuristic for embeds with a real JS API. (The blur fallback below stays
|
|---|
| 766 | // for iframe-only embeds without an API: Bandcamp/Apple Music/Vimeo.)
|
|---|
| 767 | function mediaRegistry() {
|
|---|
| 768 | if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
|
|---|
| 769 | const r = {
|
|---|
| 770 | _active: null,
|
|---|
| 771 | setActive(player) {
|
|---|
| 772 | if (this._active && this._active !== player && this._active.pause) {
|
|---|
| 773 | try { this._active.pause(); } catch (e) {}
|
|---|
| 774 | }
|
|---|
| 775 | this._active = player;
|
|---|
| 776 | },
|
|---|
| 777 | release(player) { if (this._active === player) this._active = null; },
|
|---|
| 778 | };
|
|---|
| 779 | window.pcmsMediaRegistry = r;
|
|---|
| 780 | return r;
|
|---|
| 781 | }
|
|---|
| 782 | const registrySelf = { pause() { try { audio.pause(); } catch (e) {} } };
|
|---|
| 783 |
|
|---|
| 784 | // ============================================================
|
|---|
| 785 | // 5. Audio element events → UI sync
|
|---|
| 786 | // ============================================================
|
|---|
| 787 | // Error counter prevents an infinite loop when ALL tracks are broken.
|
|---|
| 788 | let consecutiveErrors = 0;
|
|---|
| 789 |
|
|---|
| 790 | audio.addEventListener('play', () => {
|
|---|
| 791 | isPlaying = true;
|
|---|
| 792 | root.classList.add('is-playing');
|
|---|
| 793 | root.classList.remove('audio-needs-tap'); // hide tap hint
|
|---|
| 794 | mediaRegistry().setActive(registrySelf); // pause any currently playing embeds
|
|---|
| 795 | if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'playing'; } catch (e) {} }
|
|---|
| 796 | });
|
|---|
| 797 |
|
|---|
| 798 | // Media Session action handlers (wired once): lock-screen / headset / car
|
|---|
| 799 | // controls, and — crucially — an active session so iOS keeps a programmatic
|
|---|
| 800 | // auto-advance playing instead of pausing it the instant it starts.
|
|---|
| 801 | if ('mediaSession' in navigator) {
|
|---|
| 802 | const ms = navigator.mediaSession;
|
|---|
| 803 | const wire = (action, fn) => { try { ms.setActionHandler(action, fn); } catch (e) { /* unsupported action */ } };
|
|---|
| 804 | wire('play', () => play());
|
|---|
| 805 | wire('pause', () => pause());
|
|---|
| 806 | wire('previoustrack', () => prev());
|
|---|
| 807 | wire('nexttrack', () => next());
|
|---|
| 808 | wire('seekto', (e) => {
|
|---|
| 809 | if (!e || e.seekTime == null) return;
|
|---|
| 810 | // Lock-screen scrubber works in TRACK coordinates (positionState below).
|
|---|
| 811 | if (useMse() && chain.length) {
|
|---|
| 812 | const seg = currentSegment();
|
|---|
| 813 | if (seg) { try { audio.currentTime = seg.start + Math.min(e.seekTime, seg.end - seg.start - 0.1); } catch (er) {} }
|
|---|
| 814 | return;
|
|---|
| 815 | }
|
|---|
| 816 | if (audio.duration) { try { audio.currentTime = e.seekTime; } catch (er) {} }
|
|---|
| 817 | });
|
|---|
| 818 | }
|
|---|
| 819 | // Lock-screen / notification scrubber: report per-track position, not the
|
|---|
| 820 | // whole-chain timeline.
|
|---|
| 821 | function updatePositionState() {
|
|---|
| 822 | if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
|
|---|
| 823 | try {
|
|---|
| 824 | const dt = displayTimes();
|
|---|
| 825 | if (!isFinite(dt.dur) || !dt.dur) return;
|
|---|
| 826 | navigator.mediaSession.setPositionState({
|
|---|
| 827 | duration: dt.dur,
|
|---|
| 828 | playbackRate: audio.playbackRate || 1,
|
|---|
| 829 | position: Math.min(dt.cur, dt.dur),
|
|---|
| 830 | });
|
|---|
| 831 | } catch (e) { /* non-fatal */ }
|
|---|
| 832 | }
|
|---|
| 833 | // Reset the error counter only on a REAL playback start (`playing`), not the
|
|---|
| 834 | // eager `play` event. `play` fires before any network/decode error, so resetting
|
|---|
| 835 | // there would prevent the 3-strikes stop from ever triggering on a broken
|
|---|
| 836 | // track → infinite "next" loop. `playing` only fires when audio is actually playing.
|
|---|
| 837 | audio.addEventListener('playing', () => {
|
|---|
| 838 | consecutiveErrors = 0;
|
|---|
| 839 | if (useMse()) ensureNextAppended(); else preloadNext();
|
|---|
| 840 | updatePositionState();
|
|---|
| 841 | });
|
|---|
| 842 | audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'paused'; } catch (e) {} } });
|
|---|
| 843 | audio.addEventListener('ended', next);
|
|---|
| 844 | audio.addEventListener('error', (e) => {
|
|---|
| 845 | const code = audio.error ? audio.error.code : '?';
|
|---|
| 846 | console.error('[pcms-audio] playback error', code, audio.src, e);
|
|---|
| 847 | if (useMse() && ms) {
|
|---|
| 848 | // The MSE pipeline failed (decode/append) → permanently fall back to the
|
|---|
| 849 | // blob engine for this session and retry the SAME track.
|
|---|
| 850 | console.warn('[pcms-audio] MSE failed, falling back to blob playback');
|
|---|
| 851 | mseFailed = true;
|
|---|
| 852 | teardownChain();
|
|---|
| 853 | if (queue[currentIndex]) loadTrack(currentIndex, true);
|
|---|
| 854 | return;
|
|---|
| 855 | }
|
|---|
| 856 | consecutiveErrors++;
|
|---|
| 857 | // On network/decode error: skip to next track instead of stalling.
|
|---|
| 858 | // Max 3 consecutive errors before giving up (otherwise infinite loop).
|
|---|
| 859 | if (consecutiveErrors < 3 && queue.length > 1) {
|
|---|
| 860 | console.warn('[pcms-audio] auto-skip to next after error', consecutiveErrors);
|
|---|
| 861 | setTimeout(next, 400);
|
|---|
| 862 | }
|
|---|
| 863 | });
|
|---|
| 864 | audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled at', audio.currentTime));
|
|---|
| 865 | audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
|
|---|
| 866 | audio.addEventListener('timeupdate', () => {
|
|---|
| 867 | // Gapless boundary: in MSE mode a track change is just the timeline
|
|---|
| 868 | // flowing past a segment edge — detect it here and update the chrome.
|
|---|
| 869 | if (useMse() && chain.length) maybeCrossBoundary();
|
|---|
| 870 | const dt = displayTimes();
|
|---|
| 871 | if (!dt.dur || isNaN(dt.dur) || !isFinite(dt.dur)) return;
|
|---|
| 872 | const pct = (dt.cur / dt.dur) * 100;
|
|---|
| 873 | seekFill.style.width = pct + '%';
|
|---|
| 874 | sheetSeekFill.style.width = pct + '%';
|
|---|
| 875 | currentEl.textContent = formatTime(dt.cur);
|
|---|
| 876 | totalEl.textContent = formatTime(dt.dur);
|
|---|
| 877 | sheetCurrent.textContent = formatTime(dt.cur);
|
|---|
| 878 | sheetTotal.textContent = formatTime(dt.dur);
|
|---|
| 879 | });
|
|---|
| 880 |
|
|---|
| 881 | function formatTime(s) {
|
|---|
| 882 | if (!s || isNaN(s)) return '0:00';
|
|---|
| 883 | const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
|---|
| 884 | return m + ':' + (sec < 10 ? '0' : '') + sec;
|
|---|
| 885 | }
|
|---|
| 886 |
|
|---|
| 887 | /**
|
|---|
| 888 | * Waar op de BAND ligt deze verhouding? Geeft het nummer en de positie erin.
|
|---|
| 889 | *
|
|---|
| 890 | * Dit is de hele truc van hele-band-seek. De balk toont al de hele band (de
|
|---|
| 891 | * teller rekent met de trackduren), maar aanklikken werkte binnen het lopende
|
|---|
| 892 | * nummer -- en de keten houdt maar een nummer vooruit, dus een balk die de
|
|---|
| 893 | * hele band belooft reikte in werkelijkheid tot nummer twee. Dat is erger dan
|
|---|
| 894 | * geen balk, want het ziet eruit alsof het werkt.
|
|---|
| 895 | *
|
|---|
| 896 | * Omrekenen kan alleen met de echte duren, en die hebben we sinds de teller
|
|---|
| 897 | * uit bandDuur()/bandOffset() komt. Dezelfde omrekening werkt op BEIDE
|
|---|
| 898 | * motoren: de blob-motor heeft helemaal geen doorlopende tijdlijn, dus daar
|
|---|
| 899 | * is dit niet alleen de beste maar de enige manier.
|
|---|
| 900 | */
|
|---|
| 901 | function bandPositie(ratio) {
|
|---|
| 902 | const totaal = bandDuur();
|
|---|
| 903 | if (!totaal) return null;
|
|---|
| 904 | const doel = Math.max(0, Math.min(ratio, 1)) * totaal;
|
|---|
| 905 | for (let i = 0; i < queue.length; i++) {
|
|---|
| 906 | const start = bandOffset(i);
|
|---|
| 907 | const eind = start + (Number(queue[i].duration) || 0);
|
|---|
| 908 | if (doel < eind || i === queue.length - 1) {
|
|---|
| 909 | return { index: i, binnen: Math.max(0, Math.min(doel - start, (Number(queue[i].duration) || 0) - 0.25)) };
|
|---|
| 910 | }
|
|---|
| 911 | }
|
|---|
| 912 | return null;
|
|---|
| 913 | }
|
|---|
| 914 |
|
|---|
| 915 | function attachSeek(seekEl) {
|
|---|
| 916 | seekEl.addEventListener('click', (e) => {
|
|---|
| 917 | const rect = seekEl.getBoundingClientRect();
|
|---|
| 918 | const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|---|
| 919 |
|
|---|
| 920 | // BANDMODUS: de balk is de hele band, dus de klik ook.
|
|---|
| 921 | const plek = tapeMode ? bandPositie(ratio) : null;
|
|---|
| 922 | if (plek) {
|
|---|
| 923 | if (plek.index === currentIndex) {
|
|---|
| 924 | // Binnen het lopende nummer: gewoon verzetten, geen herlaadsprong.
|
|---|
| 925 | if (useMse() && chain.length) {
|
|---|
| 926 | const seg = currentSegment();
|
|---|
| 927 | if (seg) { try { audio.currentTime = seg.start + plek.binnen; } catch (er) {} }
|
|---|
| 928 | } else {
|
|---|
| 929 | try { audio.currentTime = plek.binnen; } catch (er) {}
|
|---|
| 930 | }
|
|---|
| 931 | updatePositionState();
|
|---|
| 932 | return;
|
|---|
| 933 | }
|
|---|
| 934 | // Een ander nummer: laden en er meteen in springen. pendingSeek is de
|
|---|
| 935 | // bestaande weg daarvoor -- het sessieherstel doet precies dit -- en
|
|---|
| 936 | // wordt toegepast zodra de metadata er is. Er valt een hoorbaar gat bij
|
|---|
| 937 | // de sprong; dat hoort bij spoelen naar een plek die nog niet in de
|
|---|
| 938 | // buffer zit, en is eerlijker dan een balk die daar niet komt.
|
|---|
| 939 | pendingSeek = plek.binnen;
|
|---|
| 940 | loadTrack(plek.index, !audio.paused);
|
|---|
| 941 | return;
|
|---|
| 942 | }
|
|---|
| 943 |
|
|---|
| 944 | if (useMse() && chain.length) {
|
|---|
| 945 | // Seek within the CURRENT track's segment of the chain timeline.
|
|---|
| 946 | const seg = currentSegment();
|
|---|
| 947 | if (seg) {
|
|---|
| 948 | try { audio.currentTime = seg.start + ratio * (seg.end - seg.start); } catch (er) {}
|
|---|
| 949 | updatePositionState();
|
|---|
| 950 | }
|
|---|
| 951 | return;
|
|---|
| 952 | }
|
|---|
| 953 | if (!audio.duration) return;
|
|---|
| 954 | audio.currentTime = ratio * audio.duration;
|
|---|
| 955 | });
|
|---|
| 956 | }
|
|---|
| 957 | attachSeek(seek);
|
|---|
| 958 | attachSeek(sheetSeek);
|
|---|
| 959 |
|
|---|
| 960 | // Volume + mute persist across sessions/pages via localStorage.
|
|---|
| 961 | const VOL_KEY = 'pcmsVolume', MUTE_KEY = 'pcmsMuted';
|
|---|
| 962 | const saveVol = () => { try { localStorage.setItem(VOL_KEY, String(audio.volume)); localStorage.setItem(MUTE_KEY, audio.muted ? '1' : '0'); } catch (e) { /* private mode */ } };
|
|---|
| 963 | let _initVol = parseFloat(localStorage.getItem(VOL_KEY));
|
|---|
| 964 | if (!isFinite(_initVol) || _initVol < 0 || _initVol > 1) _initVol = 0.8;
|
|---|
| 965 | audio.volume = _initVol;
|
|---|
| 966 | volumeSlider.value = Math.round(_initVol * 100);
|
|---|
| 967 | if (localStorage.getItem(MUTE_KEY) === '1') audio.muted = true;
|
|---|
| 968 | root.classList.toggle('is-muted', audio.muted || audio.volume === 0);
|
|---|
| 969 | volumeSlider.addEventListener('input', () => {
|
|---|
| 970 | audio.volume = volumeSlider.value / 100;
|
|---|
| 971 | if (volumeSlider.value > 0) audio.muted = false;
|
|---|
| 972 | saveVol();
|
|---|
| 973 | });
|
|---|
| 974 | muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; saveVol(); });
|
|---|
| 975 |
|
|---|
| 976 | // ============================================================
|
|---|
| 977 | // 6. Control wiring
|
|---|
| 978 | // ============================================================
|
|---|
| 979 | playBtn.addEventListener('click', togglePlay);
|
|---|
| 980 | /**
|
|---|
| 981 | * De vier knoppen in de juiste stand zetten. In bandmodus zijn het
|
|---|
| 982 | * spoelknoppen: ander teken, ander woord, en vasthouden spoelt door.
|
|---|
| 983 | */
|
|---|
| 984 | function zetKnopStanden() {
|
|---|
| 985 | for (const id of SPOELKNOPPEN) {
|
|---|
| 986 | const b = $(id);
|
|---|
| 987 | if (!b) continue;
|
|---|
| 988 | const vooruit = id.endsWith('next');
|
|---|
| 989 | b.innerHTML = tapeMode ? (vooruit ? SVG.ff : SVG.rew) : (vooruit ? SVG.next : SVG.prev);
|
|---|
| 990 | const label = tapeMode ? (vooruit ? 'Vooruitspoelen' : 'Terugspoelen') : (vooruit ? 'Volgende' : 'Vorige');
|
|---|
| 991 | b.setAttribute('aria-label', label);
|
|---|
| 992 | b.setAttribute('title', label);
|
|---|
| 993 | b.classList.toggle('is-wind', tapeMode);
|
|---|
| 994 | }
|
|---|
| 995 | }
|
|---|
| 996 |
|
|---|
| 997 | // Een KLIK: een nummer verder, of in bandmodus een stukje spoelen. Dat laatste
|
|---|
| 998 | // is er voor toetsenbord en schermlezer, want vasthouden is met een
|
|---|
| 999 | // spatiebalk geen gebaar.
|
|---|
| 1000 | const TIK_SPOEL_S = 5;
|
|---|
| 1001 | prevBtn.addEventListener('click', () => (tapeMode ? seekBy(-TIK_SPOEL_S) : prev()));
|
|---|
| 1002 | nextBtn.addEventListener('click', () => (tapeMode ? seekBy(TIK_SPOEL_S) : next()));
|
|---|
| 1003 | sheetPlay.addEventListener('click', togglePlay);
|
|---|
| 1004 | sheetPrev.addEventListener('click', () => (tapeMode ? seekBy(-TIK_SPOEL_S) : prev()));
|
|---|
| 1005 | sheetNext.addEventListener('click', () => (tapeMode ? seekBy(TIK_SPOEL_S) : next()));
|
|---|
| 1006 |
|
|---|
| 1007 | // VASTHOUDEN spoelt door. Op de knop zelf indrukken, maar loslaten op
|
|---|
| 1008 | // document: glijdt je vinger van de knop af, dan hoort de band te stoppen en
|
|---|
| 1009 | // niet door te blijven spoelen.
|
|---|
| 1010 | for (const id of SPOELKNOPPEN) {
|
|---|
| 1011 | const b = $(id);
|
|---|
| 1012 | if (!b) continue;
|
|---|
| 1013 | b.addEventListener('pointerdown', () => { if (tapeMode) startWind(id.endsWith('next') ? 1 : -1); });
|
|---|
| 1014 | }
|
|---|
| 1015 | document.addEventListener('pointerup', stopWind);
|
|---|
| 1016 | document.addEventListener('pointercancel', stopWind);
|
|---|
| 1017 |
|
|---|
| 1018 | // ============================================================
|
|---|
| 1019 | // 7. Sheet expand/close + drag-down-to-close
|
|---|
| 1020 | // ============================================================
|
|---|
| 1021 | // Back button closes the sheet on mobile: on open we push a history entry
|
|---|
| 1022 | // so the phone back button (popstate) closes the sheet first instead of
|
|---|
| 1023 | // leaving the page. We balance it on a UI-initiated close via history.back().
|
|---|
| 1024 | let sheetHistoryPushed = false;
|
|---|
| 1025 |
|
|---|
| 1026 | function openSheet() {
|
|---|
| 1027 | if (!hasFullPlayer()) return; // desktop (≥1200): no full player, mini-player only
|
|---|
| 1028 | if (sheet.classList.contains('is-open')) return;
|
|---|
| 1029 | sheet.classList.add('is-open');
|
|---|
| 1030 | sheet.setAttribute('aria-hidden', 'false');
|
|---|
| 1031 | document.body.classList.add('audio-sheet-locked');
|
|---|
| 1032 | // Phone + tablet: push a history entry so the back button closes the sheet first.
|
|---|
| 1033 | try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {}
|
|---|
| 1034 | }
|
|---|
| 1035 | function closeSheet(fromPopstate) {
|
|---|
| 1036 | if (!sheet.classList.contains('is-open')) return;
|
|---|
| 1037 | sheet.classList.remove('is-open');
|
|---|
| 1038 | sheet.setAttribute('aria-hidden', 'true');
|
|---|
| 1039 | document.body.classList.remove('audio-sheet-locked');
|
|---|
| 1040 | sheetPanel.style.removeProperty('--pcms-drag-y');
|
|---|
| 1041 | sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
|
|---|
| 1042 | // UI close (X / swipe / backdrop / Esc): pop our own history entry so the
|
|---|
| 1043 | // next back button navigates normally. On a popstate close (back button itself)
|
|---|
| 1044 | // the entry is already popped.
|
|---|
| 1045 | const wasPushed = sheetHistoryPushed;
|
|---|
| 1046 | sheetHistoryPushed = false;
|
|---|
| 1047 | if (wasPushed && !fromPopstate) { try { history.back(); } catch (e) {} }
|
|---|
| 1048 | }
|
|---|
| 1049 | window.addEventListener('popstate', () => {
|
|---|
| 1050 | if (sheet.classList.contains('is-open')) closeSheet(true);
|
|---|
| 1051 | });
|
|---|
| 1052 | // Clicking the mini-player track info:
|
|---|
| 1053 | // - DESKTOP (≥1200px): jump to the post the track came from (if known),
|
|---|
| 1054 | // via htmx so audio keeps playing. No post known → fall back to the sheet.
|
|---|
| 1055 | // - MOBILE/TABLET: always open the full now-playing sheet.
|
|---|
| 1056 | function scrollToTrack(trackId) {
|
|---|
| 1057 | if (!trackId) { window.scrollTo(0, 0); return; }
|
|---|
| 1058 | const el = document.getElementById('track-' + trackId);
|
|---|
| 1059 | if (!el) { window.scrollTo(0, 0); return; }
|
|---|
| 1060 | el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|---|
| 1061 | el.classList.add('pat-flash');
|
|---|
| 1062 | setTimeout(() => el.classList.remove('pat-flash'), 1600);
|
|---|
| 1063 | }
|
|---|
| 1064 | function goToPost(url, trackId) {
|
|---|
| 1065 | const hash = trackId ? ('#track-' + trackId) : '';
|
|---|
| 1066 | if (window.htmx && url.charAt(0) === '/') {
|
|---|
| 1067 | try {
|
|---|
| 1068 | const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' });
|
|---|
| 1069 | history.pushState({}, '', url + hash);
|
|---|
| 1070 | // Scroll to the track after the swap (small delay so the global
|
|---|
| 1071 | // afterSwap scroll-to-top runs first); fall back to top if not found.
|
|---|
| 1072 | const go = () => setTimeout(() => scrollToTrack(trackId), 60);
|
|---|
| 1073 | if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150);
|
|---|
| 1074 | return;
|
|---|
| 1075 | } catch (e) { /* fall back to full navigation */ }
|
|---|
| 1076 | }
|
|---|
| 1077 | location.href = url + hash;
|
|---|
| 1078 | }
|
|---|
| 1079 | expandTrigger.addEventListener('click', () => {
|
|---|
| 1080 | const t = queue[currentIndex];
|
|---|
| 1081 | // Only on true desktop (≥1200, no full-player) do we jump to the post.
|
|---|
| 1082 | // Tablet + phone have a full-player → open it (same as mobile behaviour).
|
|---|
| 1083 | const jumpToPost = !hasFullPlayer();
|
|---|
| 1084 | if (jumpToPost && t) {
|
|---|
| 1085 | // 1) Track played from a post → we already know that URL.
|
|---|
| 1086 | if (t.postUrl) { goToPost(t.postUrl, t.id); return; }
|
|---|
| 1087 | // 2) Site-wide track (no postUrl) → look up the post via the track id.
|
|---|
| 1088 | if (t.id) {
|
|---|
| 1089 | fetch('/audio/track/' + encodeURIComponent(t.id) + '/post')
|
|---|
| 1090 | .then((r) => (r.ok ? r.json() : null))
|
|---|
| 1091 | .then((d) => { if (d && d.url) goToPost(d.url, t.id); else openSheet(); })
|
|---|
| 1092 | .catch(() => openSheet());
|
|---|
| 1093 | return;
|
|---|
| 1094 | }
|
|---|
| 1095 | }
|
|---|
| 1096 | openSheet();
|
|---|
| 1097 | });
|
|---|
| 1098 | sheetClose.addEventListener('click', () => closeSheet());
|
|---|
| 1099 | sheetBackdrop.addEventListener('click', () => closeSheet());
|
|---|
| 1100 | document.addEventListener('keydown', (e) => {
|
|---|
| 1101 | if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
|
|---|
| 1102 | });
|
|---|
| 1103 | // Resized to desktop width (≥1200) while the full player is open? Close it —
|
|---|
| 1104 | // the full player doesn't exist on desktop.
|
|---|
| 1105 | window.addEventListener('resize', () => {
|
|---|
| 1106 | if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet();
|
|---|
| 1107 | });
|
|---|
| 1108 |
|
|---|
| 1109 | // Fallback for mutual exclusion. For YouTube/SoundCloud/Spotify embeds the
|
|---|
| 1110 | // registry already handles this precisely (real play events). But for
|
|---|
| 1111 | // iframe-only embeds WITHOUT a JS API (Bandcamp/Apple/Vimeo) and for the
|
|---|
| 1112 | // iframe FALLBACK (when an ad-blocker blocks the player API) there is no
|
|---|
| 1113 | // play event: we catch those via focus. User clicks such an iframe →
|
|---|
| 1114 | // window 'blur' → pause our player. (For API embeds this is at worst a
|
|---|
| 1115 | // harmless double-pause.)
|
|---|
| 1116 | window.addEventListener('blur', () => {
|
|---|
| 1117 | setTimeout(() => {
|
|---|
| 1118 | const el = document.activeElement;
|
|---|
| 1119 | // Only embed iframes (inside .folio-embed) pause the player — not a
|
|---|
| 1120 | // random iframe (captcha/ad/map) that happens to receive focus.
|
|---|
| 1121 | if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
|
|---|
| 1122 | pause();
|
|---|
| 1123 | }
|
|---|
| 1124 | }, 0);
|
|---|
| 1125 | });
|
|---|
| 1126 |
|
|---|
| 1127 | // Drag-down-to-close on touch devices.
|
|---|
| 1128 | //
|
|---|
| 1129 | // We set --pcms-drag-y as a CSS custom prop instead of writing
|
|---|
| 1130 | // sheetPanel.style.transform directly. The reason: on desktop the panel
|
|---|
| 1131 | // uses `transform: translate(-50%, 0)` for horizontal centering. Writing
|
|---|
| 1132 | // `style.transform = translateY(...)` would obliterate the -50% and the
|
|---|
| 1133 | // panel would jump rightward. With a custom prop, audio.css composes the
|
|---|
| 1134 | // final transform per breakpoint:
|
|---|
| 1135 | // mobile: transform: translateY(var(--pcms-drag-y, 0))
|
|---|
| 1136 | // desktop: transform: translate(-50%, var(--pcms-drag-y, 0))
|
|---|
| 1137 | let dragStartY = 0, dragLastY = 0, isDragging = false;
|
|---|
| 1138 | function onPointerDown(e) {
|
|---|
| 1139 | if (e.pointerType !== 'touch') return;
|
|---|
| 1140 | if (sheetPanel.scrollTop > 0) return; // queue is scrolled, don't drag
|
|---|
| 1141 | dragStartY = dragLastY = e.clientY;
|
|---|
| 1142 | isDragging = true;
|
|---|
| 1143 | sheetPanel.classList.add('is-dragging');
|
|---|
| 1144 | sheetBackdrop.classList.add('is-dragging');
|
|---|
| 1145 | }
|
|---|
| 1146 | function onPointerMove(e) {
|
|---|
| 1147 | if (!isDragging) return;
|
|---|
| 1148 | dragLastY = e.clientY;
|
|---|
| 1149 | const dy = Math.max(0, dragLastY - dragStartY);
|
|---|
| 1150 | sheetPanel.style.setProperty('--pcms-drag-y', dy + 'px');
|
|---|
| 1151 | const progress = Math.max(0, 1 - dy / sheetPanel.offsetHeight);
|
|---|
| 1152 | sheetBackdrop.style.setProperty('--pcms-sheet-progress', String(progress));
|
|---|
| 1153 | }
|
|---|
| 1154 | function onPointerUp() {
|
|---|
| 1155 | if (!isDragging) return;
|
|---|
| 1156 | isDragging = false;
|
|---|
| 1157 | sheetPanel.classList.remove('is-dragging');
|
|---|
| 1158 | sheetBackdrop.classList.remove('is-dragging');
|
|---|
| 1159 | const dy = dragLastY - dragStartY;
|
|---|
| 1160 | if (dy > 100) {
|
|---|
| 1161 | closeSheet();
|
|---|
| 1162 | } else {
|
|---|
| 1163 | sheetPanel.style.removeProperty('--pcms-drag-y');
|
|---|
| 1164 | sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
|
|---|
| 1165 | }
|
|---|
| 1166 | }
|
|---|
| 1167 | if (window.PointerEvent) {
|
|---|
| 1168 | dragZone.addEventListener('pointerdown', onPointerDown);
|
|---|
| 1169 | document.addEventListener('pointermove', onPointerMove);
|
|---|
| 1170 | document.addEventListener('pointerup', onPointerUp);
|
|---|
| 1171 | document.addEventListener('pointercancel', onPointerUp);
|
|---|
| 1172 | }
|
|---|
| 1173 |
|
|---|
| 1174 | // ============================================================
|
|---|
| 1175 | // 8. Hook up post-audio-track + post-album-cover-btn + .pat-row + .post-album-playall
|
|---|
| 1176 | // ============================================================
|
|---|
| 1177 | // Four entry points fire the same play action:
|
|---|
| 1178 | // - .pat-play → single-track widget in a post
|
|---|
| 1179 | // - .pat-row → track row inside an album/playlist tracklist
|
|---|
| 1180 | // - .post-album-cover-btn → big cover button (plays album from track 0)
|
|---|
| 1181 | // - .post-album-playall → "Speel album" / "Speel playlist" button
|
|---|
| 1182 | //
|
|---|
| 1183 | // For .pat-play the metadata lives on the surrounding .post-audio-track
|
|---|
| 1184 | // wrapper. For the other three the data is on the button itself. The
|
|---|
| 1185 | // handler reads from button-first, falls back to wrapper.
|
|---|
| 1186 | // Event delegation on document.body instead of per-button listeners. This
|
|---|
| 1187 | // survives HTMX history-restores: the mobile back button (popstate) lets HTMX
|
|---|
| 1188 | // restore #pcms-main from its snapshot; a per-element `data-pcms-attached` flag
|
|---|
| 1189 | // would leave dead buttons (flag baked into the snapshot, listener gone). One
|
|---|
| 1190 | // delegated listener works regardless of how many times the DOM is (re)swapped.
|
|---|
| 1191 | // WELKE KNOPPEN DEZE SPELER BEDIENT. Let op: dit is een LIJST MET NAMEN, geen
|
|---|
| 1192 | // regel over data-attributen. Een nieuwe knop die keurig data-pcms-track-url
|
|---|
| 1193 | // en data-pcms-album-id draagt doet dus niets zolang hij hier niet bij staat.
|
|---|
| 1194 | // Precies daar liep de cassetteknop op vast (21-8): de opmaak klopte, de
|
|---|
| 1195 | // gegevens klopten, en er gebeurde niets.
|
|---|
| 1196 | const PLAY_SELECTOR =
|
|---|
| 1197 | '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall, .tape-btn--play';
|
|---|
| 1198 | document.body.addEventListener('click', (e) => {
|
|---|
| 1199 | const btn = e.target.closest(PLAY_SELECTOR);
|
|---|
| 1200 | if (!btn) return;
|
|---|
| 1201 | e.preventDefault();
|
|---|
| 1202 | e.stopPropagation();
|
|---|
| 1203 | // The post you're playing FROM = the current page (embeds live in post content).
|
|---|
| 1204 | // Store it on the track(s) so the desktop mini-player can jump back to it —
|
|---|
| 1205 | // survives the sessionStorage resume as well.
|
|---|
| 1206 | const postUrl = location.pathname + location.search;
|
|---|
| 1207 | // Resolve metadata: button-first, then closest .post-audio-track wrapper
|
|---|
| 1208 | // (only inline single-track widgets put the data on the wrapper).
|
|---|
| 1209 | const wrapper = btn.closest('.post-audio-track');
|
|---|
| 1210 | const albumId = btn.dataset.pcmsAlbumId || (wrapper && wrapper.dataset.pcmsAlbumId);
|
|---|
| 1211 | const trackData = btn.dataset.pcmsTrack || (wrapper && wrapper.dataset.pcmsTrack);
|
|---|
| 1212 | const trackUrl = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
|
|---|
| 1213 | console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
|
|---|
| 1214 |
|
|---|
| 1215 | if (albumId) {
|
|---|
| 1216 | const album = document.getElementById(albumId);
|
|---|
| 1217 | if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
|
|---|
| 1218 | try {
|
|---|
| 1219 | const tracks = JSON.parse(album.dataset.pcmsAlbum);
|
|---|
| 1220 | tracks.forEach((t) => { t.postUrl = postUrl; });
|
|---|
| 1221 | // Start at the clicked track if we know its URL, else start at 0
|
|---|
| 1222 | // (cover-btn and playall both want to start from the beginning).
|
|---|
| 1223 | const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
|
|---|
| 1224 | // Een mixtape gaat als EEN object de speler in: de titel van het bandje
|
|---|
| 1225 | // op de speler, de teller over de hele band, en spoelen in seconden.
|
|---|
| 1226 | // De soort staat al op het blok (data-pcms-album-kind), dus hier is het
|
|---|
| 1227 | // een doorgeefje en geen tweede plek die iets afleidt.
|
|---|
| 1228 | setQueue(tracks, startIdx >= 0 ? startIdx : 0, {
|
|---|
| 1229 | albumName: album.dataset.pcmsAlbumTitle || '',
|
|---|
| 1230 | asTape: album.dataset.pcmsAlbumKind === 'mixtape',
|
|---|
| 1231 | });
|
|---|
| 1232 | } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
|
|---|
| 1233 | } else if (trackData) {
|
|---|
| 1234 | try {
|
|---|
| 1235 | const t = JSON.parse(trackData);
|
|---|
| 1236 | t.postUrl = postUrl;
|
|---|
| 1237 | setQueue([t], 0);
|
|---|
| 1238 | } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
|
|---|
| 1239 | } else if (trackUrl) {
|
|---|
| 1240 | // Fallback: at minimum we have the signed URL
|
|---|
| 1241 | setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '', postUrl }], 0);
|
|---|
| 1242 | } else {
|
|---|
| 1243 | console.error('[pcms-audio] no track data or url on button or wrapper', btn);
|
|---|
| 1244 | }
|
|---|
| 1245 | });
|
|---|
| 1246 |
|
|---|
| 1247 | // ============================================================
|
|---|
| 1248 | // 8b. Admin playlist delete (event delegation)
|
|---|
| 1249 | // ============================================================
|
|---|
| 1250 | // The post-album embed renders a [data-pcms-playlist-delete] button
|
|---|
| 1251 | // top-right when the viewer is admin (server decides; not client).
|
|---|
| 1252 | // We delegate from document.body so HTMX-swapped content works too.
|
|---|
| 1253 | document.body.addEventListener('click', async (e) => {
|
|---|
| 1254 | const btn = e.target.closest('[data-pcms-playlist-delete]');
|
|---|
| 1255 | if (!btn) return;
|
|---|
| 1256 | e.preventDefault();
|
|---|
| 1257 | e.stopPropagation();
|
|---|
| 1258 | const id = btn.dataset.pcmsPlaylistDelete;
|
|---|
| 1259 | const title = btn.dataset.pcmsPlaylistTitle || id;
|
|---|
| 1260 | if (!id) return;
|
|---|
| 1261 | if (!confirm(`Playlist "${title}" verwijderen? De embed in deze post toont vanaf nu een placeholder.`)) return;
|
|---|
| 1262 | btn.disabled = true;
|
|---|
| 1263 | try {
|
|---|
| 1264 | const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
|
|---|
| 1265 | method: 'POST',
|
|---|
| 1266 | headers: { 'X-CSRF-Token': '' },
|
|---|
| 1267 | credentials: 'same-origin',
|
|---|
| 1268 | });
|
|---|
| 1269 | const j = await r.json().catch(() => ({}));
|
|---|
| 1270 | if (j && j.ok) {
|
|---|
| 1271 | location.reload();
|
|---|
| 1272 | } else {
|
|---|
| 1273 | alert('Verwijderen mislukt: ' + ((j && j.error) || 'onbekende fout'));
|
|---|
| 1274 | btn.disabled = false;
|
|---|
| 1275 | }
|
|---|
| 1276 | } catch (err) {
|
|---|
| 1277 | alert('Verwijderen mislukt: ' + err.message);
|
|---|
| 1278 | btn.disabled = false;
|
|---|
| 1279 | }
|
|---|
| 1280 | });
|
|---|
| 1281 |
|
|---|
| 1282 | // ============================================================
|
|---|
| 1283 | // 9. Public API
|
|---|
| 1284 | // ============================================================
|
|---|
| 1285 | /**
|
|---|
| 1286 | * SPOELEN, in seconden over de hele band.
|
|---|
| 1287 | *
|
|---|
| 1288 | * Op de MSE-motor is dit precies wat een cassette doet: `audio.currentTime`
|
|---|
| 1289 | * IS de tijdlijn van de hele keten, dus over een nummergrens heen spoelen is
|
|---|
| 1290 | * gewoon doortellen. Geen trackwissel, geen nieuwe afspeelsessie.
|
|---|
| 1291 | *
|
|---|
| 1292 | * Op de blob-motor (iOS Safari, geen MSE) bestaat die doorlopende tijdlijn
|
|---|
| 1293 | * niet: daar is elk nummer een eigen bron. Spoelen loopt daar dus tot de rand
|
|---|
| 1294 | * van het huidige nummer en stapt dan naar de buur. Grover, maar het is
|
|---|
| 1295 | * eerlijker dan doen alsof de band doorloopt terwijl hij dat niet doet.
|
|---|
| 1296 | */
|
|---|
| 1297 | function seekBy(seconden) {
|
|---|
| 1298 | const d = Number(seconden) || 0;
|
|---|
| 1299 | if (!d || !audio) return;
|
|---|
| 1300 | if (useMse() && chain.length) {
|
|---|
| 1301 | const eind = chain[chain.length - 1].end;
|
|---|
| 1302 | const doel = Math.max(0, Math.min((audio.currentTime || 0) + d, Math.max(0, eind - 0.25)));
|
|---|
| 1303 | try { audio.currentTime = doel; } catch (e) { /* buffer nog niet zover */ }
|
|---|
| 1304 | updatePositionState();
|
|---|
| 1305 | return;
|
|---|
| 1306 | }
|
|---|
| 1307 | // Blob-motor: binnen het nummer blijven, en anders naar de buur.
|
|---|
| 1308 | const duur = audio.duration || 0;
|
|---|
| 1309 | const nu = audio.currentTime || 0;
|
|---|
| 1310 | if (duur && nu + d >= duur) {
|
|---|
| 1311 | // Vooruit voorbij het eind. In bandmodus stopt next() aan het eind van de
|
|---|
| 1312 | // band; daarbuiten loopt hij door naar het volgende nummer.
|
|---|
| 1313 | next();
|
|---|
| 1314 | return;
|
|---|
| 1315 | }
|
|---|
| 1316 | if (nu + d < 0) {
|
|---|
| 1317 | // TERUGSPOELEN VOORBIJ HET BEGIN moet in het vorige nummer landen aan het
|
|---|
| 1318 | // EIND, niet aan het begin -- dat is wat terugspoelen doet. Zonder dit
|
|---|
| 1319 | // sprong je bij elke druk naar de kop van het vorige nummer en kwam je
|
|---|
| 1320 | // nooit ergens in het midden uit.
|
|---|
| 1321 | if (!(tapeMode && currentIndex === 0)) pendingSeek = Number.MAX_SAFE_INTEGER;
|
|---|
| 1322 | prev();
|
|---|
| 1323 | return;
|
|---|
| 1324 | }
|
|---|
| 1325 | try { audio.currentTime = Math.max(0, nu + d); } catch (e) {}
|
|---|
| 1326 | }
|
|---|
| 1327 |
|
|---|
| 1328 | /**
|
|---|
| 1329 | * SPOELEN, met de knop ingedrukt.
|
|---|
| 1330 | *
|
|---|
| 1331 | * Stond eerst in assets/js/mod/tape.js, want daar zat de cassette. Nu de
|
|---|
| 1332 | * speler zelf ook spoelknoppen krijgt zou dezelfde lus op twee plekken staan,
|
|---|
| 1333 | * en dan lopen ze uit elkaar zodra iemand aan de versnelling draait. Hier is
|
|---|
| 1334 | * de enige plek; de cassette op de pagina roept deze aan.
|
|---|
| 1335 | */
|
|---|
| 1336 | const WIND_START = 4; // maal de normale snelheid bij het indrukken
|
|---|
| 1337 | const WIND_MAX = 16;
|
|---|
| 1338 | const WIND_VERSNELLING = 1.35;
|
|---|
| 1339 | const WIND_STAP_MS = 120;
|
|---|
| 1340 | let winder = null;
|
|---|
| 1341 |
|
|---|
| 1342 | function stopWind() {
|
|---|
| 1343 | if (winder) { clearInterval(winder.timer); winder = null; }
|
|---|
| 1344 | }
|
|---|
| 1345 |
|
|---|
| 1346 | function startWind(richting) {
|
|---|
| 1347 | stopWind();
|
|---|
| 1348 | const r = richting < 0 ? -1 : 1;
|
|---|
| 1349 | let snelheid = WIND_START;
|
|---|
| 1350 | let vorige = null;
|
|---|
| 1351 | let stil = 0;
|
|---|
| 1352 | const timer = setInterval(() => {
|
|---|
| 1353 | snelheid = Math.min(WIND_MAX, snelheid * WIND_VERSNELLING);
|
|---|
| 1354 | seekBy(r * snelheid * (WIND_STAP_MS / 1000));
|
|---|
| 1355 | // AAN DE KOP EN DE STAART STOPPEN. De positie wordt afgeklemd, dus daar
|
|---|
| 1356 | // gebeurt niets meer -- maar zonder deze controle blijft de knop malen en
|
|---|
| 1357 | // draaien de spoelen door alsof er nog band is.
|
|---|
| 1358 | const nu = displayTimes().cur;
|
|---|
| 1359 | if (vorige !== null && Math.abs(nu - vorige) < 0.05) {
|
|---|
| 1360 | if (++stil >= 2) { stopWind(); return; }
|
|---|
| 1361 | } else stil = 0;
|
|---|
| 1362 | vorige = nu;
|
|---|
| 1363 | }, WIND_STAP_MS);
|
|---|
| 1364 | winder = { timer, richting: r };
|
|---|
| 1365 | }
|
|---|
| 1366 |
|
|---|
| 1367 | /** Waar staat de teller, over het hele bandje. */
|
|---|
| 1368 | function tapeTijden() { return displayTimes(); }
|
|---|
| 1369 |
|
|---|
| 1370 | window.pcmsAudioPlayer = {
|
|---|
| 1371 | setQueue, play, pause, next, prev, close, openSheet, closeSheet, seekBy, tapeTijden,
|
|---|
| 1372 | startWind, stopWind,
|
|---|
| 1373 | isTape: () => tapeMode,
|
|---|
| 1374 | isWinding: () => !!winder,
|
|---|
| 1375 | isPlaying: () => isPlaying,
|
|---|
| 1376 | currentTrack: () => queue[currentIndex] || null,
|
|---|
| 1377 | };
|
|---|
| 1378 |
|
|---|
| 1379 | // ============================================================
|
|---|
| 1380 | // 10. Session persistence — player "survives" across page navigations
|
|---|
| 1381 | // ============================================================
|
|---|
| 1382 | // An <audio> element doesn't survive a full page load (and cross-context
|
|---|
| 1383 | // navigation — e.g. to the headerless hub overview — is intentionally a
|
|---|
| 1384 | // full-nav). We save the session to sessionStorage and restore + resume it
|
|---|
| 1385 | // on the next page: the player comes back with the same track at the same
|
|---|
| 1386 | // position. In Chrome (high media engagement) it resumes immediately;
|
|---|
| 1387 | // if the browser blocks autoplay it waits at that position (one tap = play).
|
|---|
| 1388 | const PLAYER_STATE_KEY = 'pcms-player-state';
|
|---|
| 1389 | let pendingSeek = 0;
|
|---|
| 1390 | function savePlayerState() {
|
|---|
| 1391 | try {
|
|---|
| 1392 | if (!queue.length) { sessionStorage.removeItem(PLAYER_STATE_KEY); return; }
|
|---|
| 1393 | sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
|
|---|
| 1394 | queue, currentIndex, albumName, tapeMode,
|
|---|
| 1395 | // In-TRACK position (the MSE timeline spans the whole chain; a restore
|
|---|
| 1396 | // starts a fresh chain where this track begins at 0).
|
|---|
| 1397 | time: trackTijd().cur || 0,
|
|---|
| 1398 | playing: !!audio.src && !audio.paused,
|
|---|
| 1399 | }));
|
|---|
| 1400 | } catch (e) {}
|
|---|
| 1401 | }
|
|---|
| 1402 | window.addEventListener('pagehide', savePlayerState);
|
|---|
| 1403 | window.addEventListener('beforeunload', savePlayerState);
|
|---|
| 1404 | audio.addEventListener('play', savePlayerState);
|
|---|
| 1405 | audio.addEventListener('pause', savePlayerState);
|
|---|
| 1406 | audio.addEventListener('ended', savePlayerState);
|
|---|
| 1407 | setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000);
|
|---|
| 1408 | // Apply the restored position once track metadata is available.
|
|---|
| 1409 | audio.addEventListener('loadedmetadata', () => {
|
|---|
| 1410 | if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) {
|
|---|
| 1411 | try { audio.currentTime = Math.min(pendingSeek, audio.duration - 0.25); } catch (e) {}
|
|---|
| 1412 | pendingSeek = 0;
|
|---|
| 1413 | }
|
|---|
| 1414 | });
|
|---|
| 1415 | function restorePlayerState() {
|
|---|
| 1416 | let s = null;
|
|---|
| 1417 | try { s = JSON.parse(sessionStorage.getItem(PLAYER_STATE_KEY) || 'null'); } catch (e) { return false; }
|
|---|
| 1418 | if (!s || !Array.isArray(s.queue) || !s.queue.length) return false;
|
|---|
| 1419 | queue = s.queue;
|
|---|
| 1420 | albumName = s.albumName || '';
|
|---|
| 1421 | tapeMode = !!s.tapeMode;
|
|---|
| 1422 | zetKnopStanden();
|
|---|
| 1423 | pendingSeek = s.time || 0;
|
|---|
| 1424 | const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
|
|---|
| 1425 | // playing → fetch + (attempt to) resume; paused → meta-only.
|
|---|
| 1426 | loadTrack(idx, !!s.playing, !s.playing);
|
|---|
| 1427 | return true;
|
|---|
| 1428 | }
|
|---|
| 1429 |
|
|---|
| 1430 | // ============================================================
|
|---|
| 1431 | // 11. Site-level pre-seed (window.PCMS_SITE_TRACKS)
|
|---|
| 1432 | // ============================================================
|
|---|
| 1433 | // An active session (restore) wins over the page seed, so music that is
|
|---|
| 1434 | // already playing continues instead of being replaced by the new page's tracks.
|
|---|
| 1435 | if (!restorePlayerState()) {
|
|---|
| 1436 | if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
|
|---|
| 1437 | queue = window.PCMS_SITE_TRACKS.map(t => ({
|
|---|
| 1438 | id: t.id || null,
|
|---|
| 1439 | url: t.media_url || t.url,
|
|---|
| 1440 | title: t.title || 'Untitled',
|
|---|
| 1441 | artist: t.artist || '',
|
|---|
| 1442 | cover: t.cover_url || t.cover || '',
|
|---|
| 1443 | }));
|
|---|
| 1444 | // Only prime the queue — the player bar appears only on the first audio click
|
|---|
| 1445 | // (.post-audio-track or the mini-player play button calls setQueue/loadTrack,
|
|---|
| 1446 | // which shows the bar). No more pre-seed bar on page load.
|
|---|
| 1447 | currentIndex = 0;
|
|---|
| 1448 | }
|
|---|
| 1449 | }
|
|---|
| 1450 | })();
|
|---|