Index: src/assets/css/style.css
===================================================================
--- src/assets/css/style.css	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
+++ src/assets/css/style.css	(revision e679bce2a7d2484f5395220f40517ea38e74c0da)
@@ -5078,11 +5078,11 @@
 
 /* ── Het bandje (mixtape) ──────────────────────────────────────────────
-   Zelfde familie als .post-album hierboven: dezelfde rand, dezelfde radius,
-   dezelfde papierkleur. Het verschil zit in wat je ziet draaien, niet in een
-   ander jasje -- een mixtape is een soort uitgave binnen Klonkt, geen ander
-   product.
-
-   Alles hieronder gebruikt de bestaande variabelen (--rule, --paper-2, --ink,
-   --accent), dus het volgt licht en donker en het accent van de site vanzelf. */
+   Een cassette, getekend als SVG op de echte maat: 100,4 x 63,8 mm, dus een
+   viewBox van 314x200. De vorige versie tekende twee schijfjes met CSS; dit is
+   een replica -- behuizing, labelvlak, venster, twee spoelen met tandjes, de
+   schroefjes in de hoeken en de openingen voor de kop en de capstans onderin.
+
+   Alle kleuren komen uit de bestaande variabelen, dus licht, donker en het
+   accent van de site volgen vanzelf. */
 .post-tape {
     position: relative;
@@ -5094,87 +5094,71 @@
 }
 
-/* De cassette zelf. De verhouding is die van een echt bandje (ongeveer 10:6),
-   zodat hij ook op een telefoon als cassette leest en niet als balk. */
-.tape-shell {
-    display: grid;
-    grid-template-rows: auto auto;
-    gap: .5rem;
-    padding: .8rem .8rem .4rem;
-}
-.tape-window {
-    position: relative;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    gap: 12%;
-    padding: .9rem 1.2rem;
-    border-radius: 10px;
-    background: color-mix(in srgb, var(--ink) 8%, transparent);
-    border: 1px solid var(--rule, rgba(0,0,0,.12));
-}
-/* De band tussen de spoelen. Een dun lijntje is genoeg: het gaat om de twee
-   draaiende schijven, dat is wat een cassette een cassette maakt. */
-.tape-band {
+.tape-shell { position: relative; padding: .8rem .8rem .3rem; }
+.tape-svg { display: block; width: 100%; height: auto; }
+
+/* De behuizing. Twee lagen zodat er een randje omheen zit, zoals bij een echt
+   doosje waar de bovenschaal iets uitsteekt. */
+.tape-body { fill: color-mix(in srgb, var(--ink) 82%, var(--paper, #fff)); }
+.tape-body-inner { fill: color-mix(in srgb, var(--ink) 70%, var(--paper, #fff)); }
+.tape-labelplate { fill: color-mix(in srgb, var(--paper, #fff) 92%, var(--accent)); }
+.tape-glass {
+    fill: color-mix(in srgb, var(--ink) 88%, transparent);
+    stroke: color-mix(in srgb, var(--paper, #fff) 25%, transparent);
+    stroke-width: 1.5;
+}
+.tape-pack { fill: color-mix(in srgb, var(--ink) 55%, var(--paper, #fff)); }
+.tape-ribbon { fill: color-mix(in srgb, var(--ink) 45%, var(--paper, #fff)); }
+.tape-hub { fill: var(--paper, #fff); }
+.tape-tooth { fill: color-mix(in srgb, var(--ink) 60%, var(--paper, #fff)); }
+.tape-screw { fill: color-mix(in srgb, var(--ink) 55%, var(--paper, #fff)); }
+.tape-slot { fill: color-mix(in srgb, var(--ink) 92%, transparent); }
+.tape-slot--capstan { fill: color-mix(in srgb, var(--ink) 96%, transparent); }
+
+/* Draaien. De tandjes zitten in dezelfde groep als de naaf, dus de hele spoel
+   draait als een geheel -- daar gaat het om, want aan die tandjes zie je pas
+   DAT hij draait. Het label blijft stil, anders wordt de titel onleesbaar. */
+.post-tape.is-playing .tape-reel { animation: tape-spin 3s linear infinite; }
+.post-tape.is-playing .tape-reel--right { animation-duration: 2.2s; }
+/* Spoelen: veel sneller, en achteruit ook echt achteruit. */
+.post-tape.is-winding .tape-reel { animation-duration: .45s; }
+.post-tape.is-winding-back .tape-reel { animation-direction: reverse; }
+@keyframes tape-spin { to { transform: rotate(360deg); } }
+@media (prefers-reduced-motion: reduce) {
+    .post-tape.is-playing .tape-reel,
+    .post-tape.is-winding .tape-reel { animation: none; }
+}
+
+/* De tekst staat OP het labelvlak van de cassette. Percentages volgen de
+   viewBox, dus het blijft kloppen op elke breedte. */
+.tape-label {
     position: absolute;
-    left: 22%; right: 22%;
-    height: 3px;
-    background: color-mix(in srgb, var(--ink) 35%, transparent);
-    border-radius: 2px;
-}
-.tape-reel {
-    position: relative;
-    width: clamp(48px, 16vw, 76px);
-    aspect-ratio: 1;
-    border-radius: 50%;
-    background:
-      repeating-conic-gradient(from 0deg,
-        color-mix(in srgb, var(--ink) 22%, transparent) 0deg 12deg,
-        transparent 12deg 24deg);
-    border: 2px solid color-mix(in srgb, var(--ink) 30%, transparent);
-    display: grid;
-    place-items: center;
-}
-.tape-reel-hub {
-    width: 34%;
-    aspect-ratio: 1;
-    border-radius: 50%;
-    background: var(--paper, #fff);
-    border: 2px solid color-mix(in srgb, var(--ink) 40%, transparent);
-}
-/* Draaien doet hij alleen als hij speelt. De animatie staat op de spoel en niet
-   op de hele cassette: het label hoort stil te blijven, anders wordt de titel
-   onleesbaar. */
-.post-tape.is-playing .tape-reel { animation: tape-spin 2.4s linear infinite; }
-.post-tape.is-playing .tape-reel--right { animation-duration: 3.2s; }
-@keyframes tape-spin { to { transform: rotate(360deg); } }
-/* Wie liever geen beweging ziet, krijgt geen beweging. De cassette blijft dan
-   gewoon staan; de statusregel eronder vertelt nog steeds wat er speelt. */
-@media (prefers-reduced-motion: reduce) {
-    .post-tape.is-playing .tape-reel { animation: none; }
-}
-
-.tape-label {
-    padding: .5rem .6rem;
-    border-radius: 8px;
-    background-size: cover;
-    background-position: center;
-    position: relative;
-}
-/* Staat er een hoes achter het label, dan moet de tekst leesbaar blijven. */
-.tape-label[style] { color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.7); }
+    left: 8.4%; right: 8.4%;
+    top: calc(.8rem + 11%);
+    height: 31%;
+    padding: .1rem .4rem;
+    color: color-mix(in srgb, var(--ink) 92%, transparent);
+    overflow: hidden;
+}
 .tape-kind {
     margin: 0;
-    font-size: .7rem;
-    letter-spacing: .06em;
+    font-size: clamp(.55rem, 1.6vw, .68rem);
+    letter-spacing: .14em;
     text-transform: uppercase;
-    opacity: .75;
-}
-.tape-title { margin: .1rem 0 0; font-size: 1.05rem; line-height: 1.25; }
-.tape-artist { margin: .05rem 0 0; font-size: .85rem; opacity: .8; }
-.tape-meta { margin: .15rem 0 0; font-size: .75rem; opacity: .65; }
-
-/* De knoppen: terugspoelen, spelen, vooruitspoelen. Meer heeft een bandje niet,
-   en dat is de bedoeling -- er is geen zoekbalk omdat je op een cassette niet
-   kunt zoeken. */
+    opacity: .7;
+}
+.tape-title {
+    margin: 0;
+    font-size: clamp(.85rem, 2.6vw, 1.1rem);
+    line-height: 1.15;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+.tape-artist { margin: 0; font-size: clamp(.65rem, 1.9vw, .8rem); opacity: .8; }
+.tape-meta { margin: 0; font-size: clamp(.6rem, 1.7vw, .72rem); opacity: .6; }
+
+/* De knoppen: terugspoelen, spelen, vooruitspoelen. Meer heeft een cassette
+   niet, en dat is de bedoeling -- er is geen zoekbalk omdat je op een bandje
+   niet kunt zoeken, alleen spoelen. */
 .tape-controls {
     display: flex;
@@ -5182,5 +5166,5 @@
     justify-content: center;
     gap: .6rem;
-    padding: .3rem 0 .7rem;
+    padding: .5rem 0 .6rem;
 }
 .tape-btn {
@@ -5194,4 +5178,6 @@
     cursor: pointer;
     padding: 0;
+    touch-action: manipulation;   /* vasthouden mag geen zoom of scroll worden */
+    user-select: none;
 }
 .tape-btn svg { width: 18px; height: 18px; }
@@ -5205,7 +5191,6 @@
 }
 .tape-btn--play svg { width: 22px; height: 22px; }
-/* Speelt hij, dan is dezelfde knop de pauzeknop. Twee knoppen zou een bandje
-   niet hebben. */
-.post-tape.is-playing .tape-btn--play svg path { d: path("M7 5h4v14H7zM13 5h4v14h-4z"); }
+.post-tape.is-winding .tape-btn--back,
+.post-tape.is-winding .tape-btn--fwd { border-color: var(--accent); color: var(--accent); }
 
 .tape-now {
Index: src/assets/js/audio-player.js
===================================================================
--- src/assets/js/audio-player.js	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
+++ 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));
Index: src/assets/js/mod/tape.js
===================================================================
--- src/assets/js/mod/tape.js	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
+++ src/assets/js/mod/tape.js	(revision e679bce2a7d2484f5395220f40517ea38e74c0da)
@@ -7,9 +7,11 @@
  * een bandgevoel na te doen zou twee dingen tegelijk laten klinken en die hele
  * sessie weggooien. Dus: de afspeelknop draagt dezelfde data-attributen als een
- * albumhoes en wordt door die speler opgepakt, en spoelen is next()/prev().
+ * albumhoes en wordt door die speler opgepakt.
  *
- * DAAROM SPOELT HET PER NUMMER en niet per seconde. Die speler denkt in een
- * wachtrij. Dat is een eerlijke benadering van een cassette: je komt vooruit en
- * achteruit, en je springt niet naar nummer zeven.
+ * SPOELEN GAAT IN SECONDEN, over de nummergrenzen heen. Dat kan omdat die
+ * speler een wachtrij in EEN doorlopende MediaSource-tijdlijn giet: "een
+ * trackwissel is een positie, geen omschakeling". Een bandje is dus letterlijk
+ * die tijdlijn. Vandaar seekBy() en niet next()/prev() -- springen per nummer
+ * is een playlistgebaar, en dan is dit een lijst met een cassetteplaatje.
  *
  * WAAROM ER GEPOLLD WORDT. audio-player.js zendt geen gebeurtenissen uit -- het
@@ -76,4 +78,57 @@
 }
 
+/**
+ * SPOELEN IS VASTHOUDEN, en dat is het verschil met een afspeellijst.
+ *
+ * Eerst deed vooruit en achteruit hier next()/prev(): een nummer verder. Dat is
+ * een playlistgebaar. Op een cassette bestaat "volgend nummer" niet -- je houdt
+ * de knop ingedrukt, de band loopt door, en je laat los waar je bent. Robin
+ * wees daarop (21-8) en hij heeft gelijk: met knoppen die per nummer springen
+ * is het een lijst met een cassetteplaatje erboven.
+ *
+ * Het versnelt terwijl je hem vasthoudt, zoals een echt bandje op gang komt.
+ * Een korte TIK spoelt een klein stukje -- dat is er voor toetsenbord en
+ * schermlezer, want vasthouden is met een spatiebalk geen gebaar.
+ */
+const START_SNELHEID = 4;    // maal de normale snelheid bij het indrukken
+const MAX_SNELHEID = 16;
+const VERSNELLING = 1.35;    // per stap
+const STAP_MS = 120;
+const TIK_STAP_S = 5;        // een losse klik
+
+let winder = null;
+
+function stopWinden(tape) {
+  if (winder) { clearInterval(winder.timer); winder = null; }
+  if (tape) tape.classList.remove('is-winding', 'is-winding-back');
+}
+
+function startWinden(tape, richting) {
+  const p = speler();
+  if (!p || huidigeIndex(tape) < 0) return;
+  stopWinden(tape);
+  let snelheid = START_SNELHEID;
+  tape.classList.add('is-winding');
+  if (richting < 0) tape.classList.add('is-winding-back');
+  const timer = setInterval(() => {
+    snelheid = Math.min(MAX_SNELHEID, snelheid * VERSNELLING);
+    p.seekBy(richting * snelheid * (STAP_MS / 1000));
+    tekenBand(tape);
+  }, STAP_MS);
+  winder = { timer, tape };
+}
+
+function opPointerDown(e) {
+  const knop = e.target.closest && e.target.closest('[data-tape-go]');
+  if (!knop) return;
+  const tape = knop.closest('.post-tape');
+  if (!tape) return;
+  startWinden(tape, knop.dataset.tapeGo === 'fwd' ? 1 : -1);
+}
+
+function opPointerUp() {
+  if (winder) stopWinden(winder.tape);
+}
+
 function opKlik(e) {
   const tape = e.target.closest && e.target.closest('.post-tape');
@@ -83,11 +138,10 @@
   const spoel = e.target.closest('[data-tape-go]');
   if (spoel) {
-    if (!p) return;
-    // Spoelen kan alleen als DIT bandje loopt. Anders zou de knop de wachtrij
-    // van een ander blok verzetten, en dat is niet wat een spoelknop op deze
-    // cassette hoort te doen.
-    if (huidigeIndex(tape) < 0) return;
-    if (spoel.dataset.tapeGo === 'fwd') p.next(); else p.prev();
-    setTimeout(tekenAlles, 60);
+    // Een tik spoelt een stukje. Vasthouden gaat via de pointer-afhandelaars
+    // hierboven; die hebben dan al gespoeld en deze klik doet er nog een klein
+    // beetje bovenop, wat niet stoort.
+    if (!p || huidigeIndex(tape) < 0) return;
+    p.seekBy(spoel.dataset.tapeGo === 'fwd' ? TIK_STAP_S : -TIK_STAP_S);
+    setTimeout(() => tekenBand(tape), 60);
     return;
   }
@@ -96,5 +150,5 @@
   if (play && p) {
     // Loopt dit bandje al, dan is deze knop een pauzeknop. De globale
-    // afhandelaar van audio-player.js zou hem anders opnieuw vanaf nummer een
+    // afhandelaar van audio-player.js zou hem anders opnieuw vanaf het begin
     // starten, want die kent alleen "speel deze wachtrij".
     const i = huidigeIndex(tape);
@@ -106,5 +160,5 @@
     }
     // Staat het bandje stil, dan laten we de klik doorlopen: de speler pakt de
-    // data-attributen op en zet de wachtrij op.
+    // data-attributen op en zet de band op.
   }
 }
@@ -117,5 +171,12 @@
   // die in de leesweergave een keer de hele site heeft vastgezet.
   if (tikker) { clearInterval(tikker); tikker = null; }
-  if (gebonden) { gebonden.removeEventListener('click', opKlik, true); gebonden = null; }
+  stopWinden(null);
+  if (gebonden) {
+    gebonden.removeEventListener('click', opKlik, true);
+    gebonden.removeEventListener('pointerdown', opPointerDown, true);
+    gebonden.removeEventListener('pointerup', opPointerUp, true);
+    gebonden.removeEventListener('pointercancel', opPointerUp, true);
+    gebonden = null;
+  }
   if (!tapes.length) return;
 
@@ -125,4 +186,9 @@
   gebonden = document;
   gebonden.addEventListener('click', opKlik, true);
+  gebonden.addEventListener('pointerdown', opPointerDown, true);
+  // Loslaten telt ook als je BUITEN de knop loslaat, anders blijft de band
+  // doorspoelen als je met je vinger wegglijdt.
+  gebonden.addEventListener('pointerup', opPointerUp, true);
+  gebonden.addEventListener('pointercancel', opPointerUp, true);
 
   tikker = setInterval(tekenAlles, TIK_MS);
Index: src/services/AudioEmbedService.js
===================================================================
--- src/services/AudioEmbedService.js	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
+++ src/services/AudioEmbedService.js	(revision e679bce2a7d2484f5395220f40517ea38e74c0da)
@@ -726,11 +726,42 @@
   </div>
 ` : ''}  <div class="tape-shell">
-    <div class="tape-window" aria-hidden="true">
-      <span class="tape-reel tape-reel--left"><span class="tape-reel-hub"></span></span>
-      <span class="tape-band"></span>
-      <span class="tape-reel tape-reel--right"><span class="tape-reel-hub"></span></span>
-    </div>
-    <div class="tape-label"${coverH ? ` style="background-image:url('${coverH}')"` : ''}>
-      <p class="tape-kind">📼 Mixtape</p>
+    <svg class="tape-svg" viewBox="0 0 314 200" role="img" aria-label="Cassette" preserveAspectRatio="xMidYMid meet">
+      <defs>
+        <clipPath id="tapewin-${domId}"><rect x="72" y="96" width="170" height="62" rx="6"/></clipPath>
+      </defs>
+      <!-- de behuizing -->
+      <rect class="tape-body" x="3" y="3" width="308" height="194" rx="9"/>
+      <rect class="tape-body-inner" x="12" y="12" width="290" height="176" rx="6"/>
+      <!-- het labelvlak: hier komt de HTML-tekst overheen te staan -->
+      <rect class="tape-labelplate" x="24" y="22" width="266" height="62" rx="3"/>
+      <!-- het venster waardoor je de band ziet lopen -->
+      <rect class="tape-glass" x="72" y="96" width="170" height="62" rx="6"/>
+      <g clip-path="url(#tapewin-${domId})">
+        <!-- de bandpakketten om de spoelen; links loopt vol terwijl rechts leegloopt -->
+        <circle class="tape-pack tape-pack--left" cx="112" cy="127" r="30"/>
+        <circle class="tape-pack tape-pack--right" cx="202" cy="127" r="22"/>
+        <rect class="tape-ribbon" x="112" y="150" width="90" height="3"/>
+      </g>
+      <!-- de spoelen zelf: deze twee draaien -->
+      <g class="tape-reel tape-reel--left" style="transform-origin:112px 127px">
+        <circle class="tape-hub" cx="112" cy="127" r="15"/>
+        ${[0, 60, 120, 180, 240, 300].map((a) => `<rect class="tape-tooth" x="109.5" y="112" width="5" height="9" rx="1" transform="rotate(${a} 112 127)"/>`).join('')}
+      </g>
+      <g class="tape-reel tape-reel--right" style="transform-origin:202px 127px">
+        <circle class="tape-hub" cx="202" cy="127" r="15"/>
+        ${[0, 60, 120, 180, 240, 300].map((a) => `<rect class="tape-tooth" x="199.5" y="112" width="5" height="9" rx="1" transform="rotate(${a} 202 127)"/>`).join('')}
+      </g>
+      <!-- de schroefjes in de hoeken, en de openingen voor de kop onderin -->
+      <circle class="tape-screw" cx="22" cy="22" r="3.5"/>
+      <circle class="tape-screw" cx="292" cy="22" r="3.5"/>
+      <circle class="tape-screw" cx="22" cy="178" r="3.5"/>
+      <circle class="tape-screw" cx="292" cy="178" r="3.5"/>
+      <rect class="tape-slot" x="128" y="168" width="26" height="14" rx="2"/>
+      <rect class="tape-slot" x="160" y="168" width="26" height="14" rx="2"/>
+      <rect class="tape-slot tape-slot--capstan" x="98" y="170" width="10" height="12" rx="2"/>
+      <rect class="tape-slot tape-slot--capstan" x="206" y="170" width="10" height="12" rx="2"/>
+    </svg>
+    <div class="tape-label">
+      <p class="tape-kind">Mixtape</p>
       <h3 class="tape-title">${titleH}</h3>
       ${artistH ? `<p class="tape-artist">${artistH}</p>` : ''}
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
+++ src/views/shell.ejs	(revision e679bce2a7d2484f5395220f40517ea38e74c0da)
@@ -222,5 +222,5 @@
 
 <!-- v9 stylesheet (full palette system) -->
-<link rel="stylesheet" href="/assets/css/style.css?v=106">
+<link rel="stylesheet" href="/assets/css/style.css?v=107">
 <script>
 /* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
@@ -406,5 +406,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=32"></script>
+<script src="/assets/js/audio-player.js?v=35"></script>
 <!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte
      player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
@@ -505,5 +505,5 @@
   // Eén nummer voor de hele map. Te vaak bumpen kost één download; te weinig
   // bumpen kost een bugfix die nooit aankomt.
-  var MOD_V = 52;
+  var MOD_V = 53;
 
   // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
Index: test/mixtape-ui.test.js
===================================================================
--- test/mixtape-ui.test.js	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
+++ test/mixtape-ui.test.js	(revision e679bce2a7d2484f5395220f40517ea38e74c0da)
@@ -86,5 +86,7 @@
   // bestaat en sloeg zichzelf dan over -- groen zonder iets te meten.
   const html = AudioEmbedService.embedPlaylistShortcodes('<p>[[playlist:tape]]</p>', (id) => (id === 'tape' ? pl : null));
-  assert.match(html, /📼 Mixtape/, 'een mixtape hoort zichzelf zo te noemen');
+  // Het teken zat eerst in de tekst; nu staat er een echte cassette getekend en
+  // is het woord genoeg. De eis blijft dezelfde: hij noemt zichzelf goed.
+  assert.match(html, /class="tape-kind">Mixtape</, 'een mixtape hoort zichzelf zo te noemen');
   assert.doesNotMatch(html, /💿 Album/, 'en niet het jasje van een album te dragen');
 });
@@ -129,2 +131,37 @@
   assert.doesNotMatch(html, /class="post-album"/);
 });
+
+test('de cassetteknop staat in de selectorlijst van de speler', () => {
+  // DIT IS DE TEST DIE ONTBRAK. De markup-tests hierboven waren groen terwijl
+  // op de knop drukken niets deed: audio-player.js kiest zijn knoppen met een
+  // lijst met KLASSENAMEN, niet met een regel over data-attributen. Alle juiste
+  // data-pcms-* dragen helpt dan niets.
+  const speler = fs.readFileSync('src/assets/js/audio-player.js', 'utf8');
+  const m = speler.match(/const PLAY_SELECTOR =\s*\n?\s*'([^']+)'/);
+  assert.ok(m, 'PLAY_SELECTOR moet te vinden zijn -- is hij hernoemd, dan dekt deze test niets meer');
+  assert.match(m[1], /\.tape-btn--play/,
+    'zonder deze klasse doet de afspeelknop van het bandje niets');
+});
+
+test('een bandje gaat als EEN object de speler in', () => {
+  const speler = fs.readFileSync('src/assets/js/audio-player.js', 'utf8');
+  // De stand moet vanaf het blok worden doorgegeven; zonder dit is een mixtape
+  // in de speler gewoon weer een rij nummers.
+  assert.match(speler, /asTape: album\.dataset\.pcmsAlbumKind === 'mixtape'/);
+  // En hij moet een paginawissel overleven, anders valt de speler halverwege
+  // terug op de nummertitel.
+  assert.match(speler, /queue, currentIndex, albumName, tapeMode,/, 'in de sessie opslaan');
+  assert.match(speler, /tapeMode = !!s\.tapeMode;/, 'en terugzetten');
+  // De opgeslagen tijd is de positie BINNEN het nummer, ook in bandmodus: bij
+  // herstellen begint de keten opnieuw. Slaat hij de bandteller op, dan springt
+  // een hersteld bandje naar een plek die in dat nummer niet bestaat.
+  assert.match(speler, /time: trackTijd\(\)\.cur \|\| 0,/);
+});
+
+test('spoelen gaat in seconden, niet per nummer', () => {
+  const mod = fs.readFileSync('src/assets/js/mod/tape.js', 'utf8');
+  assert.match(mod, /seekBy\(/, 'spoelen hoort over de tijdlijn te gaan');
+  // next()/prev() mag hier niet meer voorkomen als spoelgebaar: dat is een
+  // playlistknop en maakt van de cassette een lijst met een plaatje.
+  assert.doesNotMatch(mod, /p\.next\(\); else p\.prev\(\)/, 'de oude sprong per nummer');
+});
