Index: src/assets/js/audio-player.js
===================================================================
--- src/assets/js/audio-player.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/assets/js/audio-player.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,4 +1,4 @@
 /**
- * Klonkt Audio Player — v9 mini-player + Spotify-style sheet.
+ * PrutCMS v10 Audio Player — v9 mini-player + Spotify-style sheet.
  *
  * Two surfaces:
@@ -13,5 +13,5 @@
  *  - body.has-audio-player adds bottom padding when player visible
  *  - body.audio-sheet-locked prevents body scroll when sheet open
- *  - Survives HTMX swaps + history-restores via event delegation on document.body
+ *  - Survives HTMX swaps via htmx:afterSettle re-attach
  *
  * Singleton — guards against double-init.
@@ -27,7 +27,4 @@
     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>',
     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>',
-    // Dubbele driehoeken: het teken voor spoelen, niet voor overslaan.
-    rew:   '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 12l9-7v14zM2 12l9-7v14z" fill="currentColor"/></svg>',
-    ff:    '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 12L4 5v14zM22 12l-9-7v14z" fill="currentColor"/></svg>',
     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>',
   };
@@ -131,8 +128,4 @@
   const prevBtn = $('audio-prev');
   const nextBtn = $('audio-next');
-  // De vier knoppen die in bandmodus van betekenis veranderen: vorige/volgende
-  // worden terugspoelen/vooruitspoelen. Ze staan hier bij elkaar zodat het
-  // omzetten op EEN plek gebeurt.
-  const SPOELKNOPPEN = ['audio-prev', 'audio-next', 'audio-sheet-prev', 'audio-sheet-next'];
   const muteBtn = $('audio-mute');
   const volumeSlider = $('audio-volume');
@@ -165,41 +158,4 @@
   let isPlaying = false;
   let albumName = '';
-  // BANDMODUS (Robins eis, 21-8): een mixtape is EEN object, geen wachtrij met
-  // nummers. De MSE-keten hieronder maakt van een wachtrij toch al een
-  // doorlopende tijdlijn -- "een trackwissel is een positie, geen omschakeling"
-  // -- dus een bandje is precies die tijdlijn, alleen anders getoond en anders
-  // bediend: je ziet de titel van het bandje, de teller loopt over het geheel,
-  // en spoelen gaat in seconden in plaats van per nummer.
-  let tapeMode = false;
-  // Playback pipeline. We fetch each track's bytes ourselves (X-Audio-Player
-  // gate; no plain media URL is ever exposed to the page) and feed them to the
-  // <audio> element through one of two engines:
-  //
-  //  1. MSE chain (Chrome/Firefox/Android): ONE MediaSource + SourceBuffer
-  //     ('audio/mpeg', sequence mode — every track is uniform transcoder mp3).
-  //     The next track's bytes are APPENDED into the same buffer, so the whole
-  //     queue is one continuous playback session. That is what keeps a
-  //     backgrounded tab/PWA playing across track changes: Chrome's background
-  //     media policy pauses NEW playback sessions started in the background
-  //     (the old per-track src-swap + load() + play()), but never interrupts a
-  //     continuing one. A track change becomes a timeline position, not a swap.
-  //  2. Blob fallback (iOS Safari — no MSE; or MSE failed at runtime): one
-  //     objectURL per track, the previous behaviour.
-  let currentObjectUrl = null;
-  // Monotonic load token: a fast prev/next can fire several loads before an
-  // earlier fetch resolves. Only the latest load may touch the audio pipeline.
-  let loadSeq = 0;
-  // Next-track prefetch (blob engine; the MSE engine appends ahead instead).
-  // Shape: { url, bytes } — bytes is null while the fetch is still in flight.
-  let preload = null;
-  // ── MSE chain state ──
-  const MSE_SUPPORTED = !!(window.MediaSource && MediaSource.isTypeSupported && MediaSource.isTypeSupported('audio/mpeg'));
-  let mseFailed = false;               // runtime bail → blob engine for the rest of this session
-  const useMse = () => MSE_SUPPORTED && !mseFailed;
-  let ms = null;                       // MediaSource
-  let sb = null;                       // SourceBuffer
-  let chain = [];                      // appended segments: { qIndex, start, end } (timeline seconds)
-  let chainFetching = false;           // a fetch+append for the NEXT track is in flight
-  let sbOps = Promise.resolve();       // serializes SourceBuffer operations
 
   // Hide initially
@@ -221,379 +177,5 @@
   }
 
-  // Fetch the track bytes (ArrayBuffer). The X-Audio-Player header +
-  // same-origin credentials get us past the stream route's access gate.
-  // Retries a few times with backoff: a single transient network blip used to
-  // bump the error counter and SKIP the song (auto-advance past it). Now one
-  // hiccup just costs a retry, and we only give up after genuinely failing.
-  async function fetchTrackBytes(url, attempts) {
-    attempts = attempts || 1;
-    let lastErr;
-    for (let i = 0; i < attempts; i++) {
-      try {
-        const r = await fetch(url, {
-          credentials: 'same-origin',
-          headers: { 'X-Audio-Player': '1' },
-        });
-        if (!r.ok) throw new Error('HTTP ' + r.status);
-        return await r.arrayBuffer();
-      } catch (e) {
-        lastErr = e;
-        if (i < attempts - 1) {
-          await new Promise((res) => setTimeout(res, 350 * (i + 1)));
-        }
-      }
-    }
-    throw lastErr;
-  }
-
-  // Blob fallback engine: wrap the bytes in an objectURL and swap audio.src.
-  function applyBlobBytes(bytes, autoplay, mySeq) {
-    if (mySeq !== loadSeq) return;  // superseded
-    const objUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' }));
-    root.classList.remove('audio-loading');
-    // Free the previously-playing track's blob — otherwise each track leaks a
-    // copy. Never the same handle as objUrl (createObjectURL is unique), so this
-    // can't revoke the source we're about to play.
-    if (currentObjectUrl && currentObjectUrl !== objUrl) {
-      try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
-    }
-    currentObjectUrl = objUrl;
-    // Clean transition: pause + load forces a reset of internal state after
-    // multiple src changes (prevents state corruption of the audio element).
-    try { audio.pause(); } catch (e) {}
-    audio.src = objUrl;
-    try { audio.load(); } catch (e) {}
-    if (autoplay) play();
-  }
-
-  function onLoadError(err, mySeq) {
-    if (mySeq !== loadSeq) return;  // superseded — ignore stale failure
-    root.classList.remove('audio-loading');
-    console.error('[pcms-audio] track load failed after retries', err);
-    // Genuine failure (after retries): bump the counter and auto-skip, but stop
-    // after 3 in a row so a fully-broken queue can't loop "next" forever.
-    consecutiveErrors++;
-    if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
-  }
-
-  // Discard any held/in-flight preload (plain bytes now — GC handles them).
-  function dropPreload() { preload = null; }
-
-  // Prefetch the *next* track's bytes in the background. Idempotent: re-calling
-  // while the same track is already cached / in flight is a no-op. Called from
-  // the `playing` event so the network is otherwise idle. Blob engine only —
-  // the MSE engine "preloads" by appending ahead (ensureNextAppended).
-  function preloadNext() {
-    if (queue.length < 2) return;
-    if (tapeMode && currentIndex >= queue.length - 1) return;   // einde band
-    const ni = (currentIndex + 1) % queue.length;
-    const t = queue[ni];
-    if (!t || !t.url) return;
-    if (preload && preload.url === t.url) return;  // already held or in flight
-    const marker = { url: t.url, bytes: null };
-    preload = marker;
-    fetchTrackBytes(t.url, 2).then((bytes) => {
-      // Only keep it if this is still the track we want next.
-      if (preload === marker) marker.bytes = bytes;
-    }).catch(() => { if (preload === marker) preload = null; });
-  }
-
-  // ============================================================
-  // 4a. MSE chain engine — one continuous playback session
-  // ============================================================
-  // All tracks are uniform transcoder mp3 (192kbps), so raw frames can be
-  // appended back-to-back into a single 'audio/mpeg' SourceBuffer (its
-  // byte-stream format generates continuous timestamps — sequence mode).
-  // Auto-advance = playback simply flowing into the next track's region.
-
-  // Strip ID3v2 (leading) / ID3v1 (trailing) tags: tag bytes between two
-  // appended tracks would glitch the MPEG frame parser.
-  function stripId3(buf) {
-    const u8 = new Uint8Array(buf);
-    let start = 0, end = u8.length;
-    if (end > 10 && u8[0] === 0x49 && u8[1] === 0x44 && u8[2] === 0x33) {  // "ID3"
-      const size = ((u8[6] & 0x7f) << 21) | ((u8[7] & 0x7f) << 14) | ((u8[8] & 0x7f) << 7) | (u8[9] & 0x7f);
-      const skip = 10 + size + ((u8[5] & 0x10) ? 10 : 0);  // +10 when a footer is flagged
-      if (skip < end) start = skip;
-    }
-    if (end - start > 128 && u8[end - 128] === 0x54 && u8[end - 127] === 0x41 && u8[end - 126] === 0x47) end -= 128;  // "TAG"
-    return (start === 0 && end === u8.length) ? buf : buf.slice(start, end);
-  }
-
-  function teardownChain() {
-    chain = [];
-    chainFetching = false;
-    sbOps = Promise.resolve();
-    sb = null;
-    ms = null;
-  }
-
-  // Serialize a SourceBuffer operation (append/remove): they throw if issued
-  // while the buffer is still updating, so everything funnels through a queue.
-  function sbRun(fn) {
-    const run = () => new Promise((resolve, reject) => {
-      if (!sb || !ms || ms.readyState !== 'open') return resolve();
-      const ok  = () => { cleanup(); resolve(); };
-      const err = (e) => { cleanup(); reject(e); };
-      function cleanup() { sb.removeEventListener('updateend', ok); sb.removeEventListener('error', err); }
-      sb.addEventListener('updateend', ok);
-      sb.addEventListener('error', err);
-      try { fn(); } catch (e) { cleanup(); reject(e); }
-    });
-    const p = sbOps.then(run, run);
-    sbOps = p.catch(() => {});
-    return p;
-  }
-
-  // Append one track's bytes as the next segment of the chain.
-  async function appendSegment(qIndex, bytes, mySeq) {
-    const clean = stripId3(bytes);
-    try {
-      await sbRun(() => sb.appendBuffer(clean));
-    } catch (e) {
-      if (e && e.name === 'QuotaExceededError' && chain.length > 1) {
-        // Evict already-played data and retry once.
-        const seg = currentSegment();
-        if (seg && seg.start > 1) {
-          await sbRun(() => sb.remove(0, seg.start - 0.5));
-          chain = chain.filter((s) => s.end > seg.start - 0.5);
-        }
-        await sbRun(() => sb.appendBuffer(clean));
-      } else {
-        throw e;
-      }
-    }
-    if (mySeq !== loadSeq || !sb) return;
-    const buffered = sb.buffered;
-    const chainEnd = buffered.length ? buffered.end(buffered.length - 1) : 0;
-    const start = chain.length ? chain[chain.length - 1].end : (buffered.length ? buffered.start(0) : 0);
-    chain.push({ qIndex, start, end: chainEnd });
-    // DE STREAM MOET DICHT ALS ER NIETS MEER KOMT, anders vuurt `ended` nooit.
-    // Bij een wachtrij van een was dat al zo. Een BANDJE heeft nu hetzelfde
-    // nodig: sinds hij niet meer rondloopt haakt de keten na het laatste nummer
-    // niets meer aan, en dan bleef de band aan het eind hangen -- de teller
-    // stilstaand op de laatste seconde, isPlaying() waar, en de spoelen
-    // draaiend. Gemeten op dev (22-8): 125,6 van 125,7 en daar bleef hij.
-    const laatsteVanDeBand = tapeMode && qIndex >= queue.length - 1;
-    if ((queue.length === 1 || laatsteVanDeBand) && ms && ms.readyState === 'open') {
-      // Single-track queue: close the stream so `ended` fires (which replays
-      // it, matching the old engine's behaviour). Bij een bandje stopt `ended`
-      // hem juist, want next() pauzeert daar aan het eind.
-      try { ms.endOfStream(); } catch (e) {}
-    }
-  }
-
-  // Keep exactly one full track appended ahead of the one playing.
-  function ensureNextAppended() {
-    if (!useMse() || !sb || !ms || ms.readyState !== 'open' || chainFetching) return;
-    if (queue.length < 2 || !chain.length) return;
-    const seg = currentSegment();
-    if (!seg || chain.length - 1 - chain.indexOf(seg) >= 1) return;  // already one ahead
-    // EEN BANDJE LOOPT NIET ROND (Robins eis, 22-8). Deze modulo is precies wat
-    // een cassette eindeloos maakte: na het laatste nummer hing hij nummer een
-    // er weer achter, en omdat het een doorlopende keten is merk je dat niet
-    // eens als een trackwissel -- de band gaat gewoon door.
-    const volgendeInRij = chain[chain.length - 1].qIndex + 1;
-    if (tapeMode && volgendeInRij > queue.length - 1) return;   // einde band
-    const nextIdx = volgendeInRij % queue.length;
-    const t = queue[nextIdx];
-    if (!t || !t.url) return;
-    chainFetching = true;
-    const mySeq = loadSeq;
-    const bytesP = (preload && preload.url === t.url && preload.bytes)
-      ? Promise.resolve(preload.bytes)
-      : fetchTrackBytes(t.url, 2);
-    bytesP.then((bytes) => {
-      if (mySeq !== loadSeq) return;
-      if (preload && preload.url === t.url) preload = null;
-      return appendSegment(nextIdx, bytes, mySeq);
-    }).catch((e) => {
-      console.warn('[pcms-audio] next-track append failed', e);
-    }).finally(() => { chainFetching = false; });
-  }
-
-  // Drop played-out data so the buffer holds ~2 tracks at most.
-  function pruneBuffer(curSeg) {
-    if (!useMse() || !sb || !ms || ms.readyState !== 'open') return;
-    const cut = curSeg.start - 0.5;
-    if (cut <= 1) return;
-    sbRun(() => sb.remove(0, cut)).catch(() => {});
-    chain = chain.filter((s) => s.end > cut);
-  }
-
-  function currentSegment() {
-    const t = audio.currentTime || 0;
-    for (let i = 0; i < chain.length; i++) if (t < chain[i].end - 0.05) return chain[i];
-    return chain[chain.length - 1] || null;
-  }
-
-  // Playback flowed across a track boundary (the gapless auto-advance):
-  // update chrome/metadata, top the buffer up, evict what's been played.
-  function maybeCrossBoundary() {
-    const seg = currentSegment();
-    if (!seg || seg.qIndex === currentIndex) return;
-    currentIndex = seg.qIndex;
-    const t = queue[currentIndex];
-    if (t) {
-      console.log('[pcms-audio] gapless auto-advance →', t.title);
-      updateTrackChrome(t);
-    }
-    ensureNextAppended();
-    pruneBuffer(seg);
-    updatePositionState();
-    savePlayerState();
-  }
-
-  // Start a fresh chain at queue[index]. Manual actions only (start/jump/
-  // prev/next/restore) — those happen in the foreground, where starting a
-  // new playback session is allowed.
-  function chainStart(index, autoplay, mySeq, bytes) {
-    teardownChain();
-    ms = new MediaSource();
-    const msUrl = URL.createObjectURL(ms);
-    if (currentObjectUrl && currentObjectUrl !== msUrl) {
-      try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
-    }
-    currentObjectUrl = msUrl;
-    try { audio.pause(); } catch (e) {}
-    audio.src = msUrl;
-    try { audio.load(); } catch (e) {}
-    const bailToBlob = (e) => {
-      console.warn('[pcms-audio] MSE unavailable, using blob playback', e);
-      mseFailed = true;
-      teardownChain();
-      if (mySeq === loadSeq) applyBlobBytes(bytes, autoplay, mySeq);
-    };
-    ms.addEventListener('sourceopen', () => {
-      if (mySeq !== loadSeq || !ms) return;
-      try {
-        sb = ms.addSourceBuffer('audio/mpeg');
-      } catch (e) { return bailToBlob(e); }
-      appendSegment(index, bytes, mySeq).then(() => {
-        if (mySeq !== loadSeq) return;
-        // Session-restore: land at the saved in-track position.
-        if (pendingSeek > 0 && chain.length) {
-          const seg = chain[0];
-          try { audio.currentTime = Math.min(pendingSeek, (seg.end - seg.start) - 0.25); } catch (e) {}
-          pendingSeek = 0;
-        }
-        ensureNextAppended();
-      }).catch(bailToBlob);
-    }, { once: true });
-    if (autoplay) play();
-  }
-
-  // Current position/duration in TRACK coordinates (the MSE timeline is the
-  // whole chain; the UI always shows the single playing track).
-  // De positie BINNEN het huidige nummer. Apart van displayTimes(), want die
-  // geeft in bandmodus de teller over de hele band -- en het opslaan van de
-  // sessie heeft juist de trackpositie nodig: bij het herstellen begint de
-  // keten opnieuw en start dit nummer weer op nul.
-  function trackTijd() {
-    if (useMse() && chain.length) {
-      const seg = currentSegment();
-      if (seg) return { cur: Math.max(0, (audio.currentTime || 0) - seg.start), dur: seg.end - seg.start };
-    }
-    return { cur: audio.currentTime || 0, dur: audio.duration };
-  }
-
-  // De lengte van de hele band, uit de bekende trackduren. Null zodra er van
-  // een nummer geen duur bekend is: een som met gaten is een verzonnen getal,
-  // en dan valt de teller liever terug op wat hij wel zeker weet.
-  function bandDuur() {
-    if (!queue.length) return null;
-    let som = 0;
-    for (const t of queue) {
-      const d = Number(t && t.duration) || 0;
-      if (d <= 0) return null;
-      som += d;
-    }
-    return som;
-  }
-
-  /** Hoeveel band ligt er voor nummer `i`. */
-  function bandOffset(i) {
-    let som = 0;
-    for (let n = 0; n < i && n < queue.length; n++) som += Number(queue[n].duration) || 0;
-    return som;
-  }
-
-  function displayTimes() {
-    // EEN BANDJE HEEFT EEN TELLER, geen nummerpositie. Hij telt door over de
-    // kant heen.
-    //
-    // Uit de trackduren en niet uit de keten, en dat verschil is zichtbaar: de
-    // keten bevat alleen wat gebufferd is, dus het totaal groeide mee tijdens
-    // het luisteren (2:05 met een nummer geladen, 4:07 met drie). En sprong je
-    // naar nummer drie, dan begon de keten daar opnieuw op nul en stond de
-    // teller weer aan het begin van de band.
-    if (tapeMode) {
-      const totaal = bandDuur();
-      if (totaal) return { cur: bandOffset(currentIndex) + (trackTijd().cur || 0), dur: totaal };
-      if (useMse() && chain.length) return { cur: audio.currentTime || 0, dur: chain[chain.length - 1].end };
-    }
-    return trackTijd();
-  }
-
-  // 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().
-  // Persistently mark the current track (stays highlighted as long as it's active).
-  function markPlaying(trackId) {
-    document.querySelectorAll('.pat-playing').forEach((e) => e.classList.remove('pat-playing'));
-    if (!trackId) return;
-    const el = document.getElementById('track-' + trackId);
-    if (el) el.classList.add('pat-playing');
-  }
-  // After an htmx navigation the post DOM is replaced → reapply the highlight.
-  document.body.addEventListener('htmx:afterSettle', () => {
-    const t = queue[currentIndex];
-    if (t) markPlaying(t.id);
-  });
-
-  // Media Session metadata (lock-screen / notification info + artwork). Set per
-  // track; the action handlers are wired once below. Keeping a live media session
-  // is what lets iOS continue a programmatic auto-advance play() instead of
-  // pausing it immediately.
-  function updateMediaMetadata(t) {
-    if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
-    try {
-      const art = [];
-      if (t && t.cover) {
-        let u = t.cover; try { u = new URL(t.cover, location.href).href; } catch (e) {}
-        art.push({ src: u, sizes: '512x512', type: '' });
-      }
-      navigator.mediaSession.metadata = new MediaMetadata({
-        title: (t && t.title) || 'Untitled',
-        artist: (t && t.artist) || '',
-        album: albumName || '',
-        artwork: art,
-      });
-    } catch (e) { /* non-fatal */ }
-  }
-
-  // All the visible per-track chrome: titles, covers, queue highlight, media
-  // session metadata. Called from loadTrack AND from the gapless boundary-cross.
-  function updateTrackChrome(t) {
-    // In bandmodus staat het BANDJE op de speler. Wat er op dit moment klinkt
-    // staat eronder, zoals een cassette een titel op het label heeft en de
-    // nummers op het doosje.
-    const hoofd = tapeMode ? (albumName || 'Mixtape') : (t.title || 'Untitled');
-    const onder = tapeMode ? (t.title || '') : (t.artist || '');
-    titleEl.textContent  = hoofd;
-    artistEl.textContent = onder;
-    sheetTitle.textContent  = hoofd;
-    sheetArtist.textContent = onder;
-    sheetAlbum.textContent  = albumName || '';
-    setCoverImage(cover,      t.cover);
-    setCoverImage(sheetCover, t.cover);
-    root.classList.remove('audio-player-hidden');
-    document.body.classList.add('has-audio-player');
-    renderQueue();
-    markPlaying(t.id);
-    updateMediaMetadata(t);
-  }
-
-  function loadTrack(index, autoplay, metaOnly) {
+  function loadTrack(index) {
     if (!queue[index]) {
       console.warn('[pcms-audio] loadTrack: no track at index', index);
@@ -606,85 +188,31 @@
       return;
     }
-    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.
-    updateTrackChrome(t);
-
-    if (metaOnly) return;
-
-    const mySeq = ++loadSeq;
-
-    // Fast path: the bytes for this exact track were already prefetched while
-    // the previous track played → no gap, no fetch window.
-    let bytesP;
-    if (preload && preload.url === t.url && preload.bytes) {
-      bytesP = Promise.resolve(preload.bytes);
-      preload = null;
-    } else {
-      // Not preloaded (or still in flight) → drop any stale preload and fetch
-      // fresh, retrying transient failures before giving up.
-      dropPreload();
-      root.classList.add('audio-loading');
-      bytesP = fetchTrackBytes(t.url, 3);
-    }
-    bytesP.then((bytes) => {
-      if (mySeq !== loadSeq) return;
-      root.classList.remove('audio-loading');
-      if (useMse()) chainStart(index, autoplay, mySeq, bytes);
-      else applyBlobBytes(bytes, autoplay, mySeq);
-    }).catch((err) => onLoadError(err, mySeq));
-  }
-
-  // True when the viewport is in the mobile sheet-layout — matches the CSS
-  // breakpoint where .audio-sheet slides up full-width from the bottom
-  // (@media max-width:719.98px). On wider/desktop widths the sheet is a centered
-  // panel, so we do NOT auto-open it there.
-  // Three layouts (Robin 2026-06-15): phone (<768) = fullscreen sheet;
-  // tablet/car (768–1199) = large landscape full-player (tablet + car-mode);
-  // desktop (≥1200) = mini-player only, NO full player. matchMedia so this
-  // exactly follows the CSS breakpoints.
-  function playerTier() {
-    if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop';
-    if (window.matchMedia('(min-width: 768px)').matches) return 'tablet';
-    return 'phone';
-  }
-  function hasFullPlayer() { return playerTier() !== 'desktop'; }
-  function isMobileView() { return playerTier() === 'phone'; }
+    console.log('[pcms-audio] loading', t.title, t.url);
+    audio.src = t.url;
+    titleEl.textContent  = t.title  || 'Untitled';
+    artistEl.textContent = t.artist || '';
+    sheetTitle.textContent  = t.title  || 'Untitled';
+    sheetArtist.textContent = t.artist || '';
+    sheetAlbum.textContent  = albumName || '';
+    setCoverImage(cover,      t.cover);
+    setCoverImage(sheetCover, t.cover);
+    root.classList.remove('audio-player-hidden');
+    document.body.classList.add('has-audio-player');
+    renderQueue();
+  }
 
   function setQueue(tracks, startIdx, opts) {
     queue = Array.isArray(tracks) ? tracks.slice() : [];
     albumName = (opts && opts.albumName) || '';
-    tapeMode = !!(opts && opts.asTape);
-    zetKnopStanden();
     if (!queue.length) return;
-    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
-    // Mobile: a track press from an album/playlist auto-opens the full
-    // now-playing sheet (Spotify-style) instead of just the thin mini-strip.
-    // Only here (setQueue = a fresh, user-initiated queue) —
-    // not on next/prev or the site pre-seed — so a sheet the user
-    // deliberately closed doesn't reappear by itself.
-    if (hasFullPlayer()) openSheet();
+    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0);
+    play();
   }
 
   function play() {
-    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;
-    }
+    if (!audio.src) return;
     const p = audio.play();
     if (p && typeof p.catch === 'function') {
-      p.catch((err) => {
-        console.warn('[pcms-audio] play() rejected:', err.name, err.message);
-        // Browser autoplay policy blocked it (typically after 3-4
-        // auto-plays on iOS Safari, or when the tab was temporarily inactive).
-        // Show a visual hint for the user to tap play.
-        if (err && err.name === 'NotAllowedError') {
-          root.classList.add('audio-needs-tap');
-          isPlaying = false;
-          root.classList.remove('is-playing');
-        }
-      });
+      p.catch((err) => console.warn('[pcms-audio] play() rejected', err));
     }
   }
@@ -693,22 +221,14 @@
   function next() {
     if (!queue.length) return;
-    // Aan het eind van een bandje: stoppen. `ended` roept deze functie aan, dus
-    // zonder deze tak begint de band na het laatste nummer weer vooraan.
-    if (tapeMode && currentIndex >= queue.length - 1) { pause(); return; }
-    loadTrack((currentIndex + 1) % queue.length, true);
+    loadTrack((currentIndex + 1) % queue.length);
+    play();
   }
   function prev() {
     if (!queue.length) return;
-    // En aan het begin ook niet omlopen. Terugspoelen voorbij het begin levert
-    // de kop van de band op, niet het laatste nummer.
-    if (tapeMode && currentIndex === 0) {
-      try { audio.currentTime = 0; } catch (e) { /* nog niets geladen */ }
-      return;
-    }
-    loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
+    loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1);
+    play();
   }
   function close() {
     pause();
-    mediaRegistry().release(registrySelf);
     root.classList.add('audio-player-hidden');
     document.body.classList.remove('has-audio-player');
@@ -716,12 +236,4 @@
     queue = [];
     albumName = '';
-    tapeMode = false;
-    zetKnopStanden();
-    loadSeq++;  // cancel any in-flight load
-    dropPreload();
-    teardownChain();
-    if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
-    currentObjectUrl = null;
-    try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
   }
 
@@ -737,14 +249,10 @@
       sheetQueueList.appendChild(li);
     });
-    // OP EEN BANDJE KIES JE NIET. De lijst blijft staan -- je mag zien wat
-    // erop staat -- maar zonder klik en zonder de opmaak die belooft dat het
-    // kan. Dat is hetzelfde onderscheid als bij de cassette in de post.
-    sheetQueueList.classList.toggle('is-tape', tapeMode);
-    if (tapeMode) return;
     sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
       li.addEventListener('click', () => {
         const idx = parseInt(li.dataset.idx, 10);
         if (!isNaN(idx) && idx !== currentIndex) {
-          loadTrack(idx, true);
+          loadTrack(idx);
+          play();
         }
       });
@@ -758,123 +266,24 @@
 
   // ============================================================
-  // 4b. Mutual exclusion — shared media registry (see embed-player.js).
-  // ============================================================
-  // All players (this site player + YouTube/SoundCloud/Spotify embeds)
-  // register themselves in window.pcmsMediaRegistry. Starting one pauses
-  // the previous. This is the precise replacement for the old focus/blur
-  // heuristic for embeds with a real JS API. (The blur fallback below stays
-  // for iframe-only embeds without an API: Bandcamp/Apple Music/Vimeo.)
-  function mediaRegistry() {
-    if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
-    const r = {
-      _active: null,
-      setActive(player) {
-        if (this._active && this._active !== player && this._active.pause) {
-          try { this._active.pause(); } catch (e) {}
-        }
-        this._active = player;
-      },
-      release(player) { if (this._active === player) this._active = null; },
-    };
-    window.pcmsMediaRegistry = r;
-    return r;
-  }
-  const registrySelf = { pause() { try { audio.pause(); } catch (e) {} } };
-
-  // ============================================================
   // 5. Audio element events → UI sync
   // ============================================================
-  // Error counter prevents an infinite loop when ALL tracks are broken.
-  let consecutiveErrors = 0;
-
-  audio.addEventListener('play',  () => {
-    isPlaying = true;
-    root.classList.add('is-playing');
-    root.classList.remove('audio-needs-tap');  // hide tap hint
-    mediaRegistry().setActive(registrySelf);   // pause any currently playing embeds
-    if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'playing'; } catch (e) {} }
-  });
-
-  // Media Session action handlers (wired once): lock-screen / headset / car
-  // controls, and — crucially — an active session so iOS keeps a programmatic
-  // auto-advance playing instead of pausing it the instant it starts.
-  if ('mediaSession' in navigator) {
-    const ms = navigator.mediaSession;
-    const wire = (action, fn) => { try { ms.setActionHandler(action, fn); } catch (e) { /* unsupported action */ } };
-    wire('play', () => play());
-    wire('pause', () => pause());
-    wire('previoustrack', () => prev());
-    wire('nexttrack', () => next());
-    wire('seekto', (e) => {
-      if (!e || e.seekTime == null) return;
-      // Lock-screen scrubber works in TRACK coordinates (positionState below).
-      if (useMse() && chain.length) {
-        const seg = currentSegment();
-        if (seg) { try { audio.currentTime = seg.start + Math.min(e.seekTime, seg.end - seg.start - 0.1); } catch (er) {} }
-        return;
-      }
-      if (audio.duration) { try { audio.currentTime = e.seekTime; } catch (er) {} }
-    });
-  }
-  // Lock-screen / notification scrubber: report per-track position, not the
-  // whole-chain timeline.
-  function updatePositionState() {
-    if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
-    try {
-      const dt = displayTimes();
-      if (!isFinite(dt.dur) || !dt.dur) return;
-      navigator.mediaSession.setPositionState({
-        duration: dt.dur,
-        playbackRate: audio.playbackRate || 1,
-        position: Math.min(dt.cur, dt.dur),
-      });
-    } catch (e) { /* non-fatal */ }
-  }
-  // Reset the error counter only on a REAL playback start (`playing`), not the
-  // eager `play` event. `play` fires before any network/decode error, so resetting
-  // there would prevent the 3-strikes stop from ever triggering on a broken
-  // track → infinite "next" loop. `playing` only fires when audio is actually playing.
-  audio.addEventListener('playing', () => {
-    consecutiveErrors = 0;
-    if (useMse()) ensureNextAppended(); else preloadNext();
-    updatePositionState();
-  });
-  audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'paused'; } catch (e) {} } });
+  audio.addEventListener('play',  () => { isPlaying = true;  root.classList.add('is-playing'); });
+  audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
   audio.addEventListener('ended', next);
   audio.addEventListener('error', (e) => {
     const code = audio.error ? audio.error.code : '?';
     console.error('[pcms-audio] playback error', code, audio.src, e);
-    if (useMse() && ms) {
-      // The MSE pipeline failed (decode/append) → permanently fall back to the
-      // blob engine for this session and retry the SAME track.
-      console.warn('[pcms-audio] MSE failed, falling back to blob playback');
-      mseFailed = true;
-      teardownChain();
-      if (queue[currentIndex]) loadTrack(currentIndex, true);
-      return;
-    }
-    consecutiveErrors++;
-    // On network/decode error: skip to next track instead of stalling.
-    // Max 3 consecutive errors before giving up (otherwise infinite loop).
-    if (consecutiveErrors < 3 && queue.length > 1) {
-      console.warn('[pcms-audio] auto-skip to next after error', consecutiveErrors);
-      setTimeout(next, 400);
-    }
   });
-  audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled at', audio.currentTime));
+  audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled'));
   audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
   audio.addEventListener('timeupdate', () => {
-    // Gapless boundary: in MSE mode a track change is just the timeline
-    // flowing past a segment edge — detect it here and update the chrome.
-    if (useMse() && chain.length) maybeCrossBoundary();
-    const dt = displayTimes();
-    if (!dt.dur || isNaN(dt.dur) || !isFinite(dt.dur)) return;
-    const pct = (dt.cur / dt.dur) * 100;
+    if (!audio.duration || isNaN(audio.duration)) return;
+    const pct = (audio.currentTime / audio.duration) * 100;
     seekFill.style.width = pct + '%';
     sheetSeekFill.style.width = pct + '%';
-    currentEl.textContent  = formatTime(dt.cur);
-    totalEl.textContent    = formatTime(dt.dur);
-    sheetCurrent.textContent = formatTime(dt.cur);
-    sheetTotal.textContent   = formatTime(dt.dur);
+    currentEl.textContent  = formatTime(audio.currentTime);
+    totalEl.textContent    = formatTime(audio.duration);
+    sheetCurrent.textContent = formatTime(audio.currentTime);
+    sheetTotal.textContent   = formatTime(audio.duration);
   });
 
@@ -885,71 +294,9 @@
   }
 
-  /**
-   * Waar op de BAND ligt deze verhouding? Geeft het nummer en de positie erin.
-   *
-   * Dit is de hele truc van hele-band-seek. De balk toont al de hele band (de
-   * teller rekent met de trackduren), maar aanklikken werkte binnen het lopende
-   * nummer -- en de keten houdt maar een nummer vooruit, dus een balk die de
-   * hele band belooft reikte in werkelijkheid tot nummer twee. Dat is erger dan
-   * geen balk, want het ziet eruit alsof het werkt.
-   *
-   * Omrekenen kan alleen met de echte duren, en die hebben we sinds de teller
-   * uit bandDuur()/bandOffset() komt. Dezelfde omrekening werkt op BEIDE
-   * motoren: de blob-motor heeft helemaal geen doorlopende tijdlijn, dus daar
-   * is dit niet alleen de beste maar de enige manier.
-   */
-  function bandPositie(ratio) {
-    const totaal = bandDuur();
-    if (!totaal) return null;
-    const doel = Math.max(0, Math.min(ratio, 1)) * totaal;
-    for (let i = 0; i < queue.length; i++) {
-      const start = bandOffset(i);
-      const eind = start + (Number(queue[i].duration) || 0);
-      if (doel < eind || i === queue.length - 1) {
-        return { index: i, binnen: Math.max(0, Math.min(doel - start, (Number(queue[i].duration) || 0) - 0.25)) };
-      }
-    }
-    return null;
-  }
-
   function attachSeek(seekEl) {
     seekEl.addEventListener('click', (e) => {
+      if (!audio.duration) return;
       const rect = seekEl.getBoundingClientRect();
       const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
-
-      // BANDMODUS: de balk is de hele band, dus de klik ook.
-      const plek = tapeMode ? bandPositie(ratio) : null;
-      if (plek) {
-        if (plek.index === currentIndex) {
-          // Binnen het lopende nummer: gewoon verzetten, geen herlaadsprong.
-          if (useMse() && chain.length) {
-            const seg = currentSegment();
-            if (seg) { try { audio.currentTime = seg.start + plek.binnen; } catch (er) {} }
-          } else {
-            try { audio.currentTime = plek.binnen; } catch (er) {}
-          }
-          updatePositionState();
-          return;
-        }
-        // Een ander nummer: laden en er meteen in springen. pendingSeek is de
-        // bestaande weg daarvoor -- het sessieherstel doet precies dit -- en
-        // wordt toegepast zodra de metadata er is. Er valt een hoorbaar gat bij
-        // de sprong; dat hoort bij spoelen naar een plek die nog niet in de
-        // buffer zit, en is eerlijker dan een balk die daar niet komt.
-        pendingSeek = plek.binnen;
-        loadTrack(plek.index, !audio.paused);
-        return;
-      }
-
-      if (useMse() && chain.length) {
-        // Seek within the CURRENT track's segment of the chain timeline.
-        const seg = currentSegment();
-        if (seg) {
-          try { audio.currentTime = seg.start + ratio * (seg.end - seg.start); } catch (er) {}
-          updatePositionState();
-        }
-        return;
-      }
-      if (!audio.duration) return;
       audio.currentTime = ratio * audio.duration;
     });
@@ -958,19 +305,10 @@
   attachSeek(sheetSeek);
 
-  // Volume + mute persist across sessions/pages via localStorage.
-  const VOL_KEY = 'pcmsVolume', MUTE_KEY = 'pcmsMuted';
-  const saveVol = () => { try { localStorage.setItem(VOL_KEY, String(audio.volume)); localStorage.setItem(MUTE_KEY, audio.muted ? '1' : '0'); } catch (e) { /* private mode */ } };
-  let _initVol = parseFloat(localStorage.getItem(VOL_KEY));
-  if (!isFinite(_initVol) || _initVol < 0 || _initVol > 1) _initVol = 0.8;
-  audio.volume = _initVol;
-  volumeSlider.value = Math.round(_initVol * 100);
-  if (localStorage.getItem(MUTE_KEY) === '1') audio.muted = true;
-  root.classList.toggle('is-muted', audio.muted || audio.volume === 0);
+  audio.volume = 0.8;
   volumeSlider.addEventListener('input', () => {
     audio.volume = volumeSlider.value / 100;
     if (volumeSlider.value > 0) audio.muted = false;
-    saveVol();
   });
-  muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; saveVol(); });
+  muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; });
 
   // ============================================================
@@ -978,61 +316,19 @@
   // ============================================================
   playBtn.addEventListener('click', togglePlay);
-  /**
-   * De vier knoppen in de juiste stand zetten. In bandmodus zijn het
-   * spoelknoppen: ander teken, ander woord, en vasthouden spoelt door.
-   */
-  function zetKnopStanden() {
-    for (const id of SPOELKNOPPEN) {
-      const b = $(id);
-      if (!b) continue;
-      const vooruit = id.endsWith('next');
-      b.innerHTML = tapeMode ? (vooruit ? SVG.ff : SVG.rew) : (vooruit ? SVG.next : SVG.prev);
-      const label = tapeMode ? (vooruit ? 'Vooruitspoelen' : 'Terugspoelen') : (vooruit ? 'Volgende' : 'Vorige');
-      b.setAttribute('aria-label', label);
-      b.setAttribute('title', label);
-      b.classList.toggle('is-wind', tapeMode);
-    }
-  }
-
-  // Een KLIK: een nummer verder, of in bandmodus een stukje spoelen. Dat laatste
-  // is er voor toetsenbord en schermlezer, want vasthouden is met een
-  // spatiebalk geen gebaar.
-  const TIK_SPOEL_S = 5;
-  prevBtn.addEventListener('click', () => (tapeMode ? seekBy(-TIK_SPOEL_S) : prev()));
-  nextBtn.addEventListener('click', () => (tapeMode ? seekBy(TIK_SPOEL_S) : next()));
+  prevBtn.addEventListener('click', prev);
+  nextBtn.addEventListener('click', next);
   sheetPlay.addEventListener('click', togglePlay);
-  sheetPrev.addEventListener('click', () => (tapeMode ? seekBy(-TIK_SPOEL_S) : prev()));
-  sheetNext.addEventListener('click', () => (tapeMode ? seekBy(TIK_SPOEL_S) : next()));
-
-  // VASTHOUDEN spoelt door. Op de knop zelf indrukken, maar loslaten op
-  // document: glijdt je vinger van de knop af, dan hoort de band te stoppen en
-  // niet door te blijven spoelen.
-  for (const id of SPOELKNOPPEN) {
-    const b = $(id);
-    if (!b) continue;
-    b.addEventListener('pointerdown', () => { if (tapeMode) startWind(id.endsWith('next') ? 1 : -1); });
-  }
-  document.addEventListener('pointerup', stopWind);
-  document.addEventListener('pointercancel', stopWind);
+  sheetPrev.addEventListener('click', prev);
+  sheetNext.addEventListener('click', next);
 
   // ============================================================
   // 7. Sheet expand/close + drag-down-to-close
   // ============================================================
-  // Back button closes the sheet on mobile: on open we push a history entry
-  // so the phone back button (popstate) closes the sheet first instead of
-  // leaving the page. We balance it on a UI-initiated close via history.back().
-  let sheetHistoryPushed = false;
-
   function openSheet() {
-    if (!hasFullPlayer()) return; // desktop (≥1200): no full player, mini-player only
-    if (sheet.classList.contains('is-open')) return;
     sheet.classList.add('is-open');
     sheet.setAttribute('aria-hidden', 'false');
     document.body.classList.add('audio-sheet-locked');
-    // Phone + tablet: push a history entry so the back button closes the sheet first.
-    try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {}
-  }
-  function closeSheet(fromPopstate) {
-    if (!sheet.classList.contains('is-open')) return;
+  }
+  function closeSheet() {
     sheet.classList.remove('is-open');
     sheet.setAttribute('aria-hidden', 'true');
@@ -1040,87 +336,10 @@
     sheetPanel.style.removeProperty('--pcms-drag-y');
     sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
-    // UI close (X / swipe / backdrop / Esc): pop our own history entry so the
-    // next back button navigates normally. On a popstate close (back button itself)
-    // the entry is already popped.
-    const wasPushed = sheetHistoryPushed;
-    sheetHistoryPushed = false;
-    if (wasPushed && !fromPopstate) { try { history.back(); } catch (e) {} }
-  }
-  window.addEventListener('popstate', () => {
-    if (sheet.classList.contains('is-open')) closeSheet(true);
-  });
-  // Clicking the mini-player track info:
-  //  - DESKTOP (≥1200px): jump to the post the track came from (if known),
-  //    via htmx so audio keeps playing. No post known → fall back to the sheet.
-  //  - MOBILE/TABLET: always open the full now-playing sheet.
-  function scrollToTrack(trackId) {
-    if (!trackId) { window.scrollTo(0, 0); return; }
-    const el = document.getElementById('track-' + trackId);
-    if (!el) { window.scrollTo(0, 0); return; }
-    el.scrollIntoView({ block: 'center', behavior: 'smooth' });
-    el.classList.add('pat-flash');
-    setTimeout(() => el.classList.remove('pat-flash'), 1600);
-  }
-  function goToPost(url, trackId) {
-    const hash = trackId ? ('#track-' + trackId) : '';
-    if (window.htmx && url.charAt(0) === '/') {
-      try {
-        const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' });
-        history.pushState({}, '', url + hash);
-        // Scroll to the track after the swap (small delay so the global
-        // afterSwap scroll-to-top runs first); fall back to top if not found.
-        const go = () => setTimeout(() => scrollToTrack(trackId), 60);
-        if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150);
-        return;
-      } catch (e) { /* fall back to full navigation */ }
-    }
-    location.href = url + hash;
-  }
-  expandTrigger.addEventListener('click', () => {
-    const t = queue[currentIndex];
-    // Only on true desktop (≥1200, no full-player) do we jump to the post.
-    // Tablet + phone have a full-player → open it (same as mobile behaviour).
-    const jumpToPost = !hasFullPlayer();
-    if (jumpToPost && t) {
-      // 1) Track played from a post → we already know that URL.
-      if (t.postUrl) { goToPost(t.postUrl, t.id); return; }
-      // 2) Site-wide track (no postUrl) → look up the post via the track id.
-      if (t.id) {
-        fetch('/audio/track/' + encodeURIComponent(t.id) + '/post')
-          .then((r) => (r.ok ? r.json() : null))
-          .then((d) => { if (d && d.url) goToPost(d.url, t.id); else openSheet(); })
-          .catch(() => openSheet());
-        return;
-      }
-    }
-    openSheet();
-  });
-  sheetClose.addEventListener('click', () => closeSheet());
-  sheetBackdrop.addEventListener('click', () => closeSheet());
+  }
+  expandTrigger.addEventListener('click', openSheet);
+  sheetClose.addEventListener('click', closeSheet);
+  sheetBackdrop.addEventListener('click', closeSheet);
   document.addEventListener('keydown', (e) => {
     if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
-  });
-  // Resized to desktop width (≥1200) while the full player is open? Close it —
-  // the full player doesn't exist on desktop.
-  window.addEventListener('resize', () => {
-    if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet();
-  });
-
-  // Fallback for mutual exclusion. For YouTube/SoundCloud/Spotify embeds the
-  // registry already handles this precisely (real play events). But for
-  // iframe-only embeds WITHOUT a JS API (Bandcamp/Apple/Vimeo) and for the
-  // iframe FALLBACK (when an ad-blocker blocks the player API) there is no
-  // play event: we catch those via focus. User clicks such an iframe →
-  // window 'blur' → pause our player. (For API embeds this is at worst a
-  // harmless double-pause.)
-  window.addEventListener('blur', () => {
-    setTimeout(() => {
-      const el = document.activeElement;
-      // Only embed iframes (inside .folio-embed) pause the player — not a
-      // random iframe (captcha/ad/map) that happens to receive focus.
-      if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
-        pause();
-      }
-    }, 0);
   });
 
@@ -1184,64 +403,55 @@
   // wrapper. For the other three the data is on the button itself. The
   // handler reads from button-first, falls back to wrapper.
-  // Event delegation on document.body instead of per-button listeners. This
-  // survives HTMX history-restores: the mobile back button (popstate) lets HTMX
-  // restore #pcms-main from its snapshot; a per-element `data-pcms-attached` flag
-  // would leave dead buttons (flag baked into the snapshot, listener gone). One
-  // delegated listener works regardless of how many times the DOM is (re)swapped.
-  // WELKE KNOPPEN DEZE SPELER BEDIENT. Let op: dit is een LIJST MET NAMEN, geen
-  // regel over data-attributen. Een nieuwe knop die keurig data-pcms-track-url
-  // en data-pcms-album-id draagt doet dus niets zolang hij hier niet bij staat.
-  // Precies daar liep de cassetteknop op vast (21-8): de opmaak klopte, de
-  // gegevens klopten, en er gebeurde niets.
-  const PLAY_SELECTOR =
-    '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall, .tape-btn--play';
-  document.body.addEventListener('click', (e) => {
-    const btn = e.target.closest(PLAY_SELECTOR);
-    if (!btn) return;
-    e.preventDefault();
-    e.stopPropagation();
-    // The post you're playing FROM = the current page (embeds live in post content).
-    // Store it on the track(s) so the desktop mini-player can jump back to it —
-    // survives the sessionStorage resume as well.
-    const postUrl = location.pathname + location.search;
-    // Resolve metadata: button-first, then closest .post-audio-track wrapper
-    // (only inline single-track widgets put the data on the wrapper).
-    const wrapper = btn.closest('.post-audio-track');
-    const albumId   = btn.dataset.pcmsAlbumId  || (wrapper && wrapper.dataset.pcmsAlbumId);
-    const trackData = btn.dataset.pcmsTrack    || (wrapper && wrapper.dataset.pcmsTrack);
-    const trackUrl  = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
-    console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
-
-    if (albumId) {
-      const album = document.getElementById(albumId);
-      if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
-      try {
-        const tracks = JSON.parse(album.dataset.pcmsAlbum);
-        tracks.forEach((t) => { t.postUrl = postUrl; });
-        // Start at the clicked track if we know its URL, else start at 0
-        // (cover-btn and playall both want to start from the beginning).
-        const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
-        // Een mixtape gaat als EEN object de speler in: de titel van het bandje
-        // op de speler, de teller over de hele band, en spoelen in seconden.
-        // De soort staat al op het blok (data-pcms-album-kind), dus hier is het
-        // een doorgeefje en geen tweede plek die iets afleidt.
-        setQueue(tracks, startIdx >= 0 ? startIdx : 0, {
-          albumName: album.dataset.pcmsAlbumTitle || '',
-          asTape: album.dataset.pcmsAlbumKind === 'mixtape',
-        });
-      } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
-    } else if (trackData) {
-      try {
-        const t = JSON.parse(trackData);
-        t.postUrl = postUrl;
-        setQueue([t], 0);
-      } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
-    } else if (trackUrl) {
-      // Fallback: at minimum we have the signed URL
-      setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '', postUrl }], 0);
-    } else {
-      console.error('[pcms-audio] no track data or url on button or wrapper', btn);
-    }
-  });
+  function attachListeners() {
+    const playBtns = document.querySelectorAll(
+      '.post-audio-track .pat-play, ' +
+      '.post-album-tracks .pat-row, ' +
+      '.post-album-cover-btn, ' +
+      '.post-album-playall'
+    );
+    if (playBtns.length) console.log('[pcms-audio] attaching to', playBtns.length, 'play buttons');
+
+    playBtns.forEach((btn) => {
+      if (btn.dataset.pcmsAttached) return;
+      btn.dataset.pcmsAttached = '1';
+
+      btn.addEventListener('click', (e) => {
+        e.preventDefault();
+        e.stopPropagation();
+        // Resolve metadata: button-first, then closest .post-audio-track wrapper
+        // (only inline single-track widgets put the data on the wrapper).
+        const wrapper = btn.closest('.post-audio-track');
+        const albumId   = btn.dataset.pcmsAlbumId  || (wrapper && wrapper.dataset.pcmsAlbumId);
+        const trackData = btn.dataset.pcmsTrack    || (wrapper && wrapper.dataset.pcmsTrack);
+        const trackUrl  = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
+        console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
+
+        if (albumId) {
+          const album = document.getElementById(albumId);
+          if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
+          try {
+            const tracks = JSON.parse(album.dataset.pcmsAlbum);
+            // Start at the clicked track if we know its URL, else start at 0
+            // (cover-btn and playall both want to start from the beginning).
+            const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
+            setQueue(tracks, startIdx >= 0 ? startIdx : 0, { albumName: album.dataset.pcmsAlbumTitle || '' });
+          } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
+        } else if (trackData) {
+          try {
+            const t = JSON.parse(trackData);
+            setQueue([t], 0);
+          } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
+        } else if (trackUrl) {
+          // Fallback: at minimum we have the signed URL
+          setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '' }], 0);
+        } else {
+          console.error('[pcms-audio] no track data or url on button or wrapper', btn);
+        }
+      });
+    });
+  }
+
+  attachListeners();
+  document.body.addEventListener('htmx:afterSettle', attachListeners);
 
   // ============================================================
@@ -1283,94 +493,6 @@
   // 9. Public API
   // ============================================================
-  /**
-   * SPOELEN, in seconden over de hele band.
-   *
-   * Op de MSE-motor is dit precies wat een cassette doet: `audio.currentTime`
-   * IS de tijdlijn van de hele keten, dus over een nummergrens heen spoelen is
-   * gewoon doortellen. Geen trackwissel, geen nieuwe afspeelsessie.
-   *
-   * Op de blob-motor (iOS Safari, geen MSE) bestaat die doorlopende tijdlijn
-   * niet: daar is elk nummer een eigen bron. Spoelen loopt daar dus tot de rand
-   * van het huidige nummer en stapt dan naar de buur. Grover, maar het is
-   * eerlijker dan doen alsof de band doorloopt terwijl hij dat niet doet.
-   */
-  function seekBy(seconden) {
-    const d = Number(seconden) || 0;
-    if (!d || !audio) return;
-    if (useMse() && chain.length) {
-      const eind = chain[chain.length - 1].end;
-      const doel = Math.max(0, Math.min((audio.currentTime || 0) + d, Math.max(0, eind - 0.25)));
-      try { audio.currentTime = doel; } catch (e) { /* buffer nog niet zover */ }
-      updatePositionState();
-      return;
-    }
-    // Blob-motor: binnen het nummer blijven, en anders naar de buur.
-    const duur = audio.duration || 0;
-    const nu = audio.currentTime || 0;
-    if (duur && nu + d >= duur) {
-      // Vooruit voorbij het eind. In bandmodus stopt next() aan het eind van de
-      // band; daarbuiten loopt hij door naar het volgende nummer.
-      next();
-      return;
-    }
-    if (nu + d < 0) {
-      // TERUGSPOELEN VOORBIJ HET BEGIN moet in het vorige nummer landen aan het
-      // EIND, niet aan het begin -- dat is wat terugspoelen doet. Zonder dit
-      // sprong je bij elke druk naar de kop van het vorige nummer en kwam je
-      // nooit ergens in het midden uit.
-      if (!(tapeMode && currentIndex === 0)) pendingSeek = Number.MAX_SAFE_INTEGER;
-      prev();
-      return;
-    }
-    try { audio.currentTime = Math.max(0, nu + d); } catch (e) {}
-  }
-
-  /**
-   * SPOELEN, met de knop ingedrukt.
-   *
-   * Stond eerst in assets/js/mod/tape.js, want daar zat de cassette. Nu de
-   * speler zelf ook spoelknoppen krijgt zou dezelfde lus op twee plekken staan,
-   * en dan lopen ze uit elkaar zodra iemand aan de versnelling draait. Hier is
-   * de enige plek; de cassette op de pagina roept deze aan.
-   */
-  const WIND_START = 4;        // maal de normale snelheid bij het indrukken
-  const WIND_MAX = 16;
-  const WIND_VERSNELLING = 1.35;
-  const WIND_STAP_MS = 120;
-  let winder = null;
-
-  function stopWind() {
-    if (winder) { clearInterval(winder.timer); winder = null; }
-  }
-
-  function startWind(richting) {
-    stopWind();
-    const r = richting < 0 ? -1 : 1;
-    let snelheid = WIND_START;
-    let vorige = null;
-    let stil = 0;
-    const timer = setInterval(() => {
-      snelheid = Math.min(WIND_MAX, snelheid * WIND_VERSNELLING);
-      seekBy(r * snelheid * (WIND_STAP_MS / 1000));
-      // AAN DE KOP EN DE STAART STOPPEN. De positie wordt afgeklemd, dus daar
-      // gebeurt niets meer -- maar zonder deze controle blijft de knop malen en
-      // draaien de spoelen door alsof er nog band is.
-      const nu = displayTimes().cur;
-      if (vorige !== null && Math.abs(nu - vorige) < 0.05) {
-        if (++stil >= 2) { stopWind(); return; }
-      } else stil = 0;
-      vorige = nu;
-    }, WIND_STAP_MS);
-    winder = { timer, richting: r };
-  }
-
-  /** Waar staat de teller, over het hele bandje. */
-  function tapeTijden() { return displayTimes(); }
-
   window.pcmsAudioPlayer = {
-    setQueue, play, pause, next, prev, close, openSheet, closeSheet, seekBy, tapeTijden,
-    startWind, stopWind,
-    isTape: () => tapeMode,
-    isWinding: () => !!winder,
+    setQueue, play, pause, next, prev, close, openSheet, closeSheet,
     isPlaying: () => isPlaying,
     currentTrack: () => queue[currentIndex] || null,
@@ -1378,73 +500,14 @@
 
   // ============================================================
-  // 10. Session persistence — player "survives" across page navigations
-  // ============================================================
-  // An <audio> element doesn't survive a full page load (and cross-context
-  // navigation — e.g. to the headerless hub overview — is intentionally a
-  // full-nav). We save the session to sessionStorage and restore + resume it
-  // on the next page: the player comes back with the same track at the same
-  // position. In Chrome (high media engagement) it resumes immediately;
-  // if the browser blocks autoplay it waits at that position (one tap = play).
-  const PLAYER_STATE_KEY = 'pcms-player-state';
-  let pendingSeek = 0;
-  function savePlayerState() {
-    try {
-      if (!queue.length) { sessionStorage.removeItem(PLAYER_STATE_KEY); return; }
-      sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
-        queue, currentIndex, albumName, tapeMode,
-        // In-TRACK position (the MSE timeline spans the whole chain; a restore
-        // starts a fresh chain where this track begins at 0).
-        time: trackTijd().cur || 0,
-        playing: !!audio.src && !audio.paused,
-      }));
-    } catch (e) {}
-  }
-  window.addEventListener('pagehide', savePlayerState);
-  window.addEventListener('beforeunload', savePlayerState);
-  audio.addEventListener('play',  savePlayerState);
-  audio.addEventListener('pause', savePlayerState);
-  audio.addEventListener('ended', savePlayerState);
-  setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000);
-  // Apply the restored position once track metadata is available.
-  audio.addEventListener('loadedmetadata', () => {
-    if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) {
-      try { audio.currentTime = Math.min(pendingSeek, audio.duration - 0.25); } catch (e) {}
-      pendingSeek = 0;
-    }
-  });
-  function restorePlayerState() {
-    let s = null;
-    try { s = JSON.parse(sessionStorage.getItem(PLAYER_STATE_KEY) || 'null'); } catch (e) { return false; }
-    if (!s || !Array.isArray(s.queue) || !s.queue.length) return false;
-    queue = s.queue;
-    albumName = s.albumName || '';
-    tapeMode = !!s.tapeMode;
-    zetKnopStanden();
-    pendingSeek = s.time || 0;
-    const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
-    // playing → fetch + (attempt to) resume; paused → meta-only.
-    loadTrack(idx, !!s.playing, !s.playing);
-    return true;
-  }
-
-  // ============================================================
-  // 11. Site-level pre-seed (window.PCMS_SITE_TRACKS)
-  // ============================================================
-  // An active session (restore) wins over the page seed, so music that is
-  // already playing continues instead of being replaced by the new page's tracks.
-  if (!restorePlayerState()) {
-    if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
-      queue = window.PCMS_SITE_TRACKS.map(t => ({
-        id:     t.id || null,
-        url:    t.media_url || t.url,
-        title:  t.title  || 'Untitled',
-        artist: t.artist || '',
-        cover:  t.cover_url || t.cover || '',
-      }));
-      // Only prime the queue — the player bar appears only on the first audio click
-      // (.post-audio-track or the mini-player play button calls setQueue/loadTrack,
-      // which shows the bar). No more pre-seed bar on page load.
-      currentIndex = 0;
-    }
+  // 10. Site-level pre-seed (window.PCMS_SITE_TRACKS)
+  // ============================================================
+  if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
+    queue = window.PCMS_SITE_TRACKS.map(t => ({
+      url:    t.media_url || t.url,
+      title:  t.title  || 'Untitled',
+      artist: t.artist || '',
+      cover:  t.cover_url || t.cover || '',
+    }));
+    if (queue.length) loadTrack(0);
   }
 })();
