Index: src/assets/js/audio-player.js
===================================================================
--- src/assets/js/audio-player.js	(revision 421046ce80a3254724b8e5bd17b73954a9de1e37)
+++ src/assets/js/audio-player.js	(revision e679bce2a7d2484f5395220f40517ea38e74c0da)
@@ -158,4 +158,11 @@
   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
@@ -457,5 +464,9 @@
   // Current position/duration in TRACK coordinates (the MSE timeline is the
   // whole chain; the UI always shows the single playing track).
-  function displayTimes() {
+  // 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();
@@ -463,4 +474,14 @@
     }
     return { cur: audio.currentTime || 0, dur: audio.duration };
+  }
+
+  function displayTimes() {
+    // Een bandje heeft een teller, geen nummerpositie: hij telt door over de
+    // kant heen. Dat is letterlijk de ketentijdlijn, dus hier juist NIET
+    // terugrekenen naar trackcoordinaten.
+    if (tapeMode && useMse() && chain.length) {
+      return { cur: audio.currentTime || 0, dur: chain[chain.length - 1].end };
+    }
+    return trackTijd();
   }
 
@@ -505,8 +526,13 @@
   // session metadata. Called from loadTrack AND from the gapless boundary-cross.
   function updateTrackChrome(t) {
-    titleEl.textContent  = t.title  || 'Untitled';
-    artistEl.textContent = t.artist || '';
-    sheetTitle.textContent  = t.title  || 'Untitled';
-    sheetArtist.textContent = t.artist || '';
+    // 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);
@@ -579,4 +605,5 @@
     queue = Array.isArray(tracks) ? tracks.slice() : [];
     albumName = (opts && opts.albumName) || '';
+    tapeMode = !!(opts && opts.asTape);
     if (!queue.length) return;
     loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
@@ -629,4 +656,5 @@
     queue = [];
     albumName = '';
+    tapeMode = false;
     loadSeq++;  // cancel any in-flight load
     dropPreload();
@@ -1010,6 +1038,11 @@
   // 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';
+    '.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);
@@ -1038,5 +1071,12 @@
         // (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 || '' });
+        // 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) {
@@ -1092,6 +1132,40 @@
   // 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) { next(); return; }
+    if (nu + d < 0) { prev(); return; }
+    try { audio.currentTime = Math.max(0, nu + d); } catch (e) {}
+  }
+
+  /** Waar staat de teller, over het hele bandje. */
+  function tapeTijden() { return displayTimes(); }
+
   window.pcmsAudioPlayer = {
-    setQueue, play, pause, next, prev, close, openSheet, closeSheet,
+    setQueue, play, pause, next, prev, close, openSheet, closeSheet, seekBy, tapeTijden,
+    isTape: () => tapeMode,
     isPlaying: () => isPlaying,
     currentTrack: () => queue[currentIndex] || null,
@@ -1113,8 +1187,8 @@
       if (!queue.length) { sessionStorage.removeItem(PLAYER_STATE_KEY); return; }
       sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
-        queue, currentIndex, albumName,
+        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: displayTimes().cur || 0,
+        time: trackTijd().cur || 0,
         playing: !!audio.src && !audio.paused,
       }));
@@ -1140,4 +1214,5 @@
     queue = s.queue;
     albumName = s.albumName || '';
+    tapeMode = !!s.tapeMode;
     pendingSeek = s.time || 0;
     const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
