Index: src/assets/js/audio-player.js
===================================================================
--- src/assets/js/audio-player.js	(revision 8681239c58e5a6f4b403f6d90aa3c3ef9c99c5da)
+++ src/assets/js/audio-player.js	(revision c88783ea96376fcf4e77aedc4e82bad5d86fbaf0)
@@ -158,4 +158,11 @@
   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;
 
   // Hide initially
@@ -177,5 +184,20 @@
   }
 
-  function loadTrack(index) {
+  // 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.
+  async function fetchAsObjectUrl(url) {
+    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);
+  }
+
+  // metaOnly: show the track in the UI but DON'T download its bytes yet.
+  // Used by the site pre-seed so opening a page doesn't auto-download audio;
+  // the blob is fetched lazily on the first play().
+  function loadTrack(index, autoplay, metaOnly) {
     if (!queue[index]) {
       console.warn('[pcms-audio] loadTrack: no track at index', index);
@@ -188,11 +210,7 @@
       return;
     }
-    console.log('[pcms-audio] loading', t.title, t.url);
-    // Schone overgang: pause + reset voorkomt state-corruption van het
-    // audio-element na meerdere src-changes (bug die continuous playback
-    // brak na 3-4 tracks). audio.load() forceert reset van internal state.
-    try { audio.pause(); } catch (e) {}
-    audio.src = t.url;
-    try { audio.load(); } catch (e) {}
+    console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : '');
+    // Metadata + chrome update synchronously so the UI reacts instantly while
+    // the bytes download.
     titleEl.textContent  = t.title  || 'Untitled';
     artistEl.textContent = t.artist || '';
@@ -205,4 +223,30 @@
     document.body.classList.add('has-audio-player');
     renderQueue();
+
+    if (metaOnly) return;
+
+    const mySeq = ++loadSeq;
+    root.classList.add('audio-loading');
+    fetchAsObjectUrl(t.url).then((objUrl) => {
+      if (mySeq !== loadSeq) { URL.revokeObjectURL(objUrl); return; }  // superseded
+      root.classList.remove('audio-loading');
+      // Free the previous track's blob — otherwise every track leaks a copy.
+      if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
+      currentObjectUrl = objUrl;
+      // Schone overgang: pause + load forceert reset van internal state na
+      // meerdere src-changes (voorkomt state-corruption van het audio-element).
+      try { audio.pause(); } catch (e) {}
+      audio.src = objUrl;
+      try { audio.load(); } catch (e) {}
+      if (autoplay) play();
+    }).catch((err) => {
+      if (mySeq !== loadSeq) return;  // superseded — ignore stale failure
+      root.classList.remove('audio-loading');
+      console.error('[pcms-audio] track fetch failed', err);
+      // Treat a failed download like a playback error: bump the counter and
+      // auto-skip, but stop after 3 in a row so we never loop forever.
+      consecutiveErrors++;
+      if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
+    });
   }
 
@@ -211,10 +255,14 @@
     albumName = (opts && opts.albumName) || '';
     if (!queue.length) return;
-    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0);
-    play();
+    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
   }
 
   function play() {
-    if (!audio.src) return;
+    if (!audio.src) {
+      // Nothing fetched yet (pre-seed showed metadata only, or a load is still
+      // in flight). Kick off the blob load for the current track and autoplay.
+      if (queue[currentIndex]) loadTrack(currentIndex, true);
+      return;
+    }
     const p = audio.play();
     if (p && typeof p.catch === 'function') {
@@ -236,11 +284,9 @@
   function next() {
     if (!queue.length) return;
-    loadTrack((currentIndex + 1) % queue.length);
-    play();
+    loadTrack((currentIndex + 1) % queue.length, true);
   }
   function prev() {
     if (!queue.length) return;
-    loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1);
-    play();
+    loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
   }
   function close() {
@@ -251,4 +297,8 @@
     queue = [];
     albumName = '';
+    loadSeq++;  // cancel any in-flight load
+    if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
+    currentObjectUrl = null;
+    try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
   }
 
@@ -268,6 +318,5 @@
         const idx = parseInt(li.dataset.idx, 10);
         if (!isNaN(idx) && idx !== currentIndex) {
-          loadTrack(idx);
-          play();
+          loadTrack(idx, true);
         }
       });
@@ -288,8 +337,13 @@
   audio.addEventListener('play',  () => {
     isPlaying = true;
-    consecutiveErrors = 0;  // reset bij succesvolle play
     root.classList.add('is-playing');
     root.classList.remove('audio-needs-tap');  // verstop tap-hint
   });
+  // Reset de error-teller pas bij ECHTE playback-start (`playing`), niet bij
+  // het eager `play`-event. `play` vuurt vóór een eventuele netwerk-/decode-
+  // fout, dus resetten daar zou de 3-strikes-stop nooit laten triggeren bij
+  // een kapotte track → infinite "next"-loop. `playing` vuurt alleen als er
+  // daadwerkelijk audio speelt.
+  audio.addEventListener('playing', () => { consecutiveErrors = 0; });
   audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
   audio.addEventListener('ended', next);
@@ -539,5 +593,5 @@
       cover:  t.cover_url || t.cover || '',
     }));
-    if (queue.length) loadTrack(0);
+    if (queue.length) loadTrack(0, false, true);  // metadata only — fetch on first play
   }
 })();
