Index: src/assets/js/audio-player.js
===================================================================
--- src/assets/js/audio-player.js	(revision 8681239c58e5a6f4b403f6d90aa3c3ef9c99c5da)
+++ src/assets/js/audio-player.js	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -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
   }
 })();
Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision 8681239c58e5a6f4b403f6d90aa3c3ef9c99c5da)
+++ src/routes/admin-audio.js	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -20,5 +20,5 @@
 import { requireGod } from '../middleware/auth.js';
 import { transcodeToMp3 } from '../services/AudioTranscoder.js';
-import { signUrl } from '../services/AudioStreamService.js';
+import { audioUrl } from '../services/AudioStreamService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -80,10 +80,8 @@
   `).all(site.id);
 
-  // Sign each track's stream URL so admins can preview audio inline.
-  // Short TTL (default 10 min from AudioStreamService) means the URL on
-  // the page expires if it sits open too long; a refresh re-signs.
+  // Build each track's stream URL so admins can preview audio inline.
   const tracks = rows.map(t => ({
     ...t,
-    stream_url: t.filename ? signUrl(t.filename).url : null,
+    stream_url: t.filename ? audioUrl(t.filename) : null,
   }));
 
@@ -335,6 +333,6 @@
   `).get(req.params.id, site.id);
   if (!t) return res.status(404).json({ error: 'Track niet gevonden' });
-  // Sign the stream URL so the modal can render an inline preview player.
-  const stream_url = t.filename ? signUrl(t.filename).url : null;
+  // Stream URL so the modal can render an inline preview player.
+  const stream_url = t.filename ? audioUrl(t.filename) : null;
   res.json({ ok: true, track: { ...t, stream_url } });
 });
Index: src/routes/admin-playlists.js
===================================================================
--- src/routes/admin-playlists.js	(revision 8681239c58e5a6f4b403f6d90aa3c3ef9c99c5da)
+++ src/routes/admin-playlists.js	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -112,5 +112,5 @@
   if (!site) return res.status(404).json({ error: 'Site required' });
 
-  // Editor needs the raw track-id list (not signed URLs) — pass no signUrl.
+  // Editor needs the raw track-id list (not stream URLs) — pass no urlFor.
   const playlist = PlaylistService.get(site.id, req.params.id, null);
   if (!playlist) return res.status(404).json({ error: 'Playlist niet gevonden' });
Index: src/routes/audio.js
===================================================================
--- src/routes/audio.js	(revision 8681239c58e5a6f4b403f6d90aa3c3ef9c99c5da)
+++ src/routes/audio.js	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -1,11 +1,25 @@
 /**
- * Audio streaming routes — v9-style signed URL + byte-range support.
+ * Audio streaming routes — byte-range streaming.
  *
- * Files live in storage/media/audio/ and are NOT served by the static
- * /media handler — every fetch must go through this verified route.
+ * Files live in storage/audio/ and are NOT served by the static /media
+ * handler — every fetch goes through this route, which adds byte-range
+ * support so HTML5 <audio> can seek.
  *
- * GET /audio/stream/:filename?t=<hmac>&exp=<unix>
- *   Verifies the token. If valid, streams the file with byte-range support
- *   so HTML5 <audio> can seek. Anything invalid returns 403.
+ * GET /audio/stream/:filename
+ *   Streams the file with byte-range support.
+ *
+ * ANTI-THEFT (Spotify-flavoured, step 1 — 2026-05-20):
+ *   The player never exposes this URL to the user — it fetch()es the bytes
+ *   and plays from a blob: object URL (no shareable link, no "save audio as").
+ *   This route additionally refuses anything that isn't a same-origin browser
+ *   fetch, so the raw URL can't be pasted into the address bar, hotlinked from
+ *   another site, or pulled with curl/yt-dlp.
+ *
+ *   A request is allowed when EITHER:
+ *     - it carries the X-Audio-Player header (our fetch sets it), OR
+ *     - Sec-Fetch-Site is same-origin/same-site (covers the admin <audio>
+ *       preview, which can't set custom headers).
+ *   Address-bar paste sends Sec-Fetch-Site: none; hotlinks send cross-site;
+ *   curl/yt-dlp send neither signal → all rejected.
  */
 
@@ -14,9 +28,8 @@
 import path from 'path';
 import { fileURLToPath } from 'url';
-import { verifyToken } from '../services/AudioStreamService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 // Audio files live OUTSIDE storage/media — the public /media static handler
-// cannot reach them. Every fetch must go through this signed route.
+// cannot reach them. Every fetch must go through this gated route.
 const AUDIO_DIR = path.resolve(
   process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
@@ -39,15 +52,21 @@
 };
 
+// Access gate: allow only same-origin browser fetches / media loads.
+function isAllowedAudioRequest(req) {
+  if (req.get('X-Audio-Player') === '1') return true;  // our blob fetch
+  const site = req.get('Sec-Fetch-Site');              // set by modern browsers
+  return site === 'same-origin' || site === 'same-site';
+}
+
 router.get('/stream/:filename', (req, res) => {
   const { filename } = req.params;
-  const { t, exp } = req.query;
+
+  if (!isAllowedAudioRequest(req)) {
+    return res.status(403).send('Direct access not allowed');
+  }
 
   // Sanity: no path traversal, no slashes
   if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
     return res.status(400).send('Bad filename');
-  }
-
-  if (!verifyToken(filename, t, exp)) {
-    return res.status(403).send('Invalid or expired token');
   }
 
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 8681239c58e5a6f4b403f6d90aa3c3ef9c99c5da)
+++ src/routes/posts.js	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -13,5 +13,5 @@
 import AudioEmbedService from '../services/AudioEmbedService.js';
 import PlaylistService from '../services/PlaylistService.js';
-import { signUrl } from '../services/AudioStreamService.js';
+import { audioUrl } from '../services/AudioStreamService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -419,5 +419,5 @@
           artist: r.artist,
           cover: r.cover_url,
-          url: signUrl(r.filename).url,
+          url: audioUrl(r.filename),
         };
       });
@@ -439,5 +439,5 @@
         if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
         byAlbum.get(r.album).push({
-          url: signUrl(r.filename).url,
+          url: audioUrl(r.filename),
           title: r.title || 'Untitled',
           artist: r.artist || '',
@@ -464,5 +464,5 @@
       const isAdmin = req.session?.user?.role === 'god';
       html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
-        return PlaylistService.get(site.id, id, signUrl);
+        return PlaylistService.get(site.id, id, audioUrl);
       }, { isAdmin });
     }
Index: src/services/AudioStreamService.js
===================================================================
--- src/services/AudioStreamService.js	(revision 8681239c58e5a6f4b403f6d90aa3c3ef9c99c5da)
+++ src/services/AudioStreamService.js	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -1,112 +1,33 @@
 /**
- * AudioStreamService — Signed audio streaming, v9-style.
+ * AudioStreamService — builds URLs for the audio streaming route.
  *
- * The src of <audio> is /audio/stream/:filename?t=HMAC&exp=TIMESTAMP.
- * HMAC = SHA256(filename|exp|AUDIO_SECRET).
- *
- * Defeats hotlinking, scrapers, casual URL sharing — not state actors.
- * Token TTL: 10 minutes (long enough for a track, short enough that a
- * shared link expires before anyone can use it).
- *
- * AUDIO_SECRET comes from env. If missing on first boot, generate one
- * and persist to storage/.audio-secret so it survives restarts.
+ * ┌─ ANTI-THEFT MODEL (Spotify-flavoured, step 1 — 2026-05-20) ─────────────┐
+ * │ audioUrl() returns a plain /audio/stream/<filename> path. There is NO   │
+ * │ signed/expiring token in the URL — that earlier design baked a single   │
+ * │ 10-min deadline into a whole queue at render time, so later tracks'     │
+ * │ tokens expired mid-session and the player looped "next" forever.        │
+ * │                                                                          │
+ * │ Protection now lives in two NON-expiring layers, so it can't cause that │
+ * │ failure again:                                                           │
+ * │   1. Client (audio-player.js) fetch()es the bytes and plays from a      │
+ * │      blob: object URL — no shareable link, no "save audio as".          │
+ * │   2. Server (routes/audio.js) gates /audio/stream to same-origin        │
+ * │      browser fetches — blocks address-bar paste, hotlinks, curl/yt-dlp. │
+ * │                                                                          │
+ * │ FUTURE STEPS (deliberate, tested one at a time):                         │
+ * │   - step 2: MSE chunked/progressive streaming (true Spotify feel)        │
+ * │   - step 3: per-session short-lived token in a header, minted JIT        │
+ * │   - step 4: light byte obfuscation (XOR/key) on the wire                 │
+ * └──────────────────────────────────────────────────────────────────────┘
  */
 
-import crypto from 'crypto';
-import fs from 'fs';
-import path from 'path';
-import { fileURLToPath } from 'url';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const SECRET_FILE = path.join(__dirname, '..', '..', 'storage', '.audio-secret');
-
-export const TOKEN_TTL_SECONDS = 600;
-
-let cachedSecret = null;
-
-function loadOrGenerateSecret() {
-  if (cachedSecret) return cachedSecret;
-
-  // 1. Env wins
-  if (process.env.AUDIO_SECRET && process.env.AUDIO_SECRET.length >= 32) {
-    cachedSecret = process.env.AUDIO_SECRET;
-    return cachedSecret;
-  }
-
-  // 2. Persisted file
-  try {
-    const fromDisk = fs.readFileSync(SECRET_FILE, 'utf-8').trim();
-    if (fromDisk.length >= 32) {
-      cachedSecret = fromDisk;
-      return cachedSecret;
-    }
-  } catch (e) { /* file missing — generate */ }
-
-  // 3. Generate + persist
-  const generated = crypto.randomBytes(32).toString('hex');
-  try {
-    fs.mkdirSync(path.dirname(SECRET_FILE), { recursive: true });
-    fs.writeFileSync(SECRET_FILE, generated, { mode: 0o600 });
-    console.log('AudioStreamService: generated new audio secret at', SECRET_FILE);
-  } catch (e) {
-    console.error('AudioStreamService: could not persist audio secret:', e.message);
-  }
-  cachedSecret = generated;
-  return cachedSecret;
+/**
+ * Build the public stream URL for an audio filename.
+ * Returns null for a falsy filename so callers can guard playability.
+ */
+export function audioUrl(filename) {
+  if (!filename) return null;
+  return `/audio/stream/${encodeURIComponent(filename)}`;
 }
 
-function makeHmac(filename, exp) {
-  const secret = loadOrGenerateSecret();
-  return crypto
-    .createHmac('sha256', secret)
-    .update(`${filename}|${exp}`)
-    .digest('hex');
-}
-
-/**
- * Sign a filename → returns { url, exp, t } so callers can build the URL.
- * The full URL is /audio/stream/<filename>?t=<t>&exp=<exp>.
- */
-export function signUrl(filename, ttlSeconds = TOKEN_TTL_SECONDS) {
-  const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
-  const t = makeHmac(filename, exp);
-  const safe = encodeURIComponent(filename);
-  return {
-    url: `/audio/stream/${safe}?t=${t}&exp=${exp}`,
-    exp,
-    t,
-  };
-}
-
-/**
- * Verify a token for a filename. Returns true iff exp is in the future
- * AND the HMAC matches.
- */
-export function verifyToken(filename, t, exp) {
-  if (!filename || !t || !exp) return false;
-  const expNum = Number(exp);
-  if (!Number.isFinite(expNum)) return false;
-  if (expNum < Math.floor(Date.now() / 1000)) return false;
-
-  const expected = makeHmac(filename, expNum);
-  // timingSafeEqual requires equal-length buffers
-  try {
-    const a = Buffer.from(t, 'hex');
-    const b = Buffer.from(expected, 'hex');
-    if (a.length !== b.length) return false;
-    return crypto.timingSafeEqual(a, b);
-  } catch (e) {
-    return false;
-  }
-}
-
-/**
- * Force-rotate the secret. Invalidates all outstanding tokens.
- */
-export function rotateSecret() {
-  cachedSecret = null;
-  try { fs.unlinkSync(SECRET_FILE); } catch (e) {}
-  return loadOrGenerateSecret();
-}
-
-export default { signUrl, verifyToken, rotateSecret, TOKEN_TTL_SECONDS };
+export default { audioUrl };
Index: src/services/PlaylistService.js
===================================================================
--- src/services/PlaylistService.js	(revision 8681239c58e5a6f4b403f6d90aa3c3ef9c99c5da)
+++ src/services/PlaylistService.js	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -77,10 +77,10 @@
    * Returns null if the playlist doesn't exist.
    *
-   * `signUrl` is an optional callback that takes a media filename and returns
-   * a (possibly signed) URL. If not provided, tracks come back with no `url`
-   * and the caller has to resolve them. The render pipeline in posts.js
-   * always passes signUrl.
-   */
-  static get(siteId, id, signUrl) {
+   * `urlFor` is an optional callback that takes a media filename and returns
+   * its stream URL. If not provided, tracks come back with no `url` and the
+   * caller has to resolve them. The render pipeline in posts.js always passes
+   * urlFor.
+   */
+  static get(siteId, id, urlFor) {
     id = this.normalizeId(id);
     if (!id) return null;
@@ -117,5 +117,5 @@
           cover: t.cover_url || p.cover_url || '',
           duration: t.duration || 0,
-          url: signUrl ? signUrl(t.filename).url : null,
+          url: urlFor ? urlFor(t.filename) : null,
         })),
     };
Index: src/views/pages/admin-audio.ejs
===================================================================
--- src/views/pages/admin-audio.ejs	(revision 8681239c58e5a6f4b403f6d90aa3c3ef9c99c5da)
+++ src/views/pages/admin-audio.ejs	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -583,8 +583,12 @@
     function resyncAll() {
       const audio = document.getElementById('audio-element');
+      const player = window.pcmsAudioPlayer;
       const playing = audio && !audio.paused && !audio.ended;
-      const currentSrc = audio ? audio.src : '';
+      // audio.src is now a blob: URL (Spotify-style playback), so compare
+      // against the player's logical track URL, not the element src.
+      const cur = player && player.currentTrack();
+      const curUrl = cur ? cur.url : '';
       buttons.forEach(b => {
-        const isThisOne = playing && currentSrc.endsWith(b.dataset.streamUrl);
+        const isThisOne = playing && curUrl === b.dataset.streamUrl;
         setIcon(b, isThisOne);
       });
@@ -617,10 +621,10 @@
         };
 
-        // If this exact track is already playing, toggle pause/play instead
-        // of restarting from zero.
-        const audio = document.getElementById('audio-element');
-        if (audio && audio.src.endsWith(url)) {
-          if (audio.paused) player.play();
-          else              player.pause();
+        // If this exact track is already current, toggle pause/play instead
+        // of restarting from zero. Compare logical URLs (audio.src is a blob:).
+        const cur = player.currentTrack();
+        if (cur && cur.url === url) {
+          if (player.isPlaying()) player.pause();
+          else                    player.play();
           return;
         }
Index: src/views/partials/track-editor.ejs
===================================================================
--- src/views/partials/track-editor.ejs	(revision 8681239c58e5a6f4b403f6d90aa3c3ef9c99c5da)
+++ src/views/partials/track-editor.ejs	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -445,6 +445,9 @@
       };
       const isOurTrack = () => {
-        const audio = document.getElementById('audio-element');
-        return audio && track.stream_url && audio.src.endsWith(track.stream_url);
+        // audio.src is a blob: URL (Spotify-style playback) — compare against
+        // the player's logical current-track URL instead.
+        const player = window.pcmsAudioPlayer;
+        const cur = player && player.currentTrack();
+        return !!(cur && track.stream_url && cur.url === track.stream_url);
       };
       const resync = () => {
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 8681239c58e5a6f4b403f6d90aa3c3ef9c99c5da)
+++ src/views/shell.ejs	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -277,5 +277,5 @@
      ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
      Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
-<script src="/assets/js/audio-player.js?v=3"></script>
+<script src="/assets/js/audio-player.js?v=5"></script>
 <% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
   <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
