Index: src/assets/css/style.css
===================================================================
--- src/assets/css/style.css	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
+++ src/assets/css/style.css	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
@@ -5076,2 +5076,176 @@
   background: var(--accent, #e8b04b); color: #1a1205;
 }
+
+/* ── 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. */
+.post-tape {
+    position: relative;
+    border: 1px solid var(--rule, rgba(0,0,0,.1));
+    border-radius: 14px;
+    overflow: hidden;
+    margin: 1.25rem 0;
+    background: var(--paper-2, color-mix(in srgb, var(--ink) 3%, transparent));
+}
+
+/* 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 {
+    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); }
+.tape-kind {
+    margin: 0;
+    font-size: .7rem;
+    letter-spacing: .06em;
+    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. */
+.tape-controls {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: .6rem;
+    padding: .3rem 0 .7rem;
+}
+.tape-btn {
+    display: grid;
+    place-items: center;
+    width: 40px; height: 40px;
+    border-radius: 50%;
+    border: 1px solid var(--rule, rgba(0,0,0,.15));
+    background: var(--paper, #fff);
+    color: var(--ink);
+    cursor: pointer;
+    padding: 0;
+}
+.tape-btn svg { width: 18px; height: 18px; }
+.tape-btn:hover { border-color: var(--accent); color: var(--accent); }
+.tape-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.tape-btn--play {
+    width: 52px; height: 52px;
+    background: var(--accent);
+    border-color: var(--accent);
+    color: var(--paper, #fff);
+}
+.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"); }
+
+.tape-now {
+    margin: 0;
+    padding: 0 .8rem .4rem;
+    text-align: center;
+    font-size: .8rem;
+    opacity: .8;
+    min-height: 1.2em;
+}
+
+/* De nummers staan er als INHOUD, niet als knoppen. Geen cursor, geen hover,
+   geen aanwijzing dat je erop kunt drukken: op een cassette kun je dat niet. */
+.tape-tracks {
+    margin: 0;
+    padding: 0 .8rem .8rem;
+    list-style: none;
+    counter-reset: tape;
+    display: grid;
+    gap: .1rem;
+}
+.tape-track {
+    counter-increment: tape;
+    display: flex;
+    gap: .5rem;
+    align-items: baseline;
+    font-size: .85rem;
+    padding: .18rem .3rem;
+    border-radius: 6px;
+    opacity: .75;
+}
+.tape-track::before {
+    content: counter(tape) ".";
+    opacity: .55;
+    min-width: 1.4em;
+    font-variant-numeric: tabular-nums;
+}
+.tape-track-title { flex: 1; }
+.tape-track-dur { opacity: .6; font-variant-numeric: tabular-nums; }
+.tape-track.is-current {
+    opacity: 1;
+    background: color-mix(in srgb, var(--accent) 12%, transparent);
+}
Index: src/assets/js/mod/tape.js
===================================================================
--- src/assets/js/mod/tape.js	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
+++ src/assets/js/mod/tape.js	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
@@ -0,0 +1,130 @@
+/**
+ * Het bandje: een cassette die draait, en alleen vooruit en achteruit speelt.
+ *
+ * WAT DEZE MODULE WEL EN NIET DOET. Hij speelt niets af. De site heeft al een
+ * speler (audio-player.js) met een wachtrij, een mini-speler onderin en een
+ * sessie die een paginawissel overleeft. Een tweede speler ernaast bouwen om
+ * 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().
+ *
+ * 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.
+ *
+ * WAAROM ER GEPOLLD WORDT. audio-player.js zendt geen gebeurtenissen uit -- het
+ * is een gesloten module met een klein oppervlak (setQueue, play, pause, next,
+ * prev, isPlaying, currentTrack). Liever hier vijf keer per seconde kijken dan
+ * daar een gebeurtenissenlaag inbouwen die de rest van de site niet vraagt. Het
+ * polsen stopt zodra er geen bandje op de pagina staat.
+ */
+
+const TIK_MS = 200;
+let tikker = null;
+let gebonden = null;   // de container waarop de klikafhandelaar hangt
+
+/** De speler, of niets als hij (nog) niet geladen is. */
+const speler = () => window.pcmsAudioPlayer || null;
+
+/** De url's die op dit bandje staan, in volgorde. */
+function bandUrls(tape) {
+  try {
+    return JSON.parse(tape.dataset.pcmsAlbum || '[]').map((t) => t.url);
+  } catch { return []; }
+}
+
+/**
+ * Speelt DIT bandje op dit moment? Vergelijken op url en niet op index: de
+ * wachtrij kan ondertussen van een ander blok komen, en dan hoort deze cassette
+ * gewoon stil te staan.
+ */
+function huidigeIndex(tape) {
+  const p = speler();
+  if (!p) return -1;
+  const nu = p.currentTrack();
+  if (!nu || !nu.url) return -1;
+  return bandUrls(tape).indexOf(nu.url);
+}
+
+function tekenBand(tape) {
+  const i = huidigeIndex(tape);
+  const p = speler();
+  const draait = i >= 0 && !!p && p.isPlaying();
+
+  tape.classList.toggle('is-playing', draait);
+  tape.classList.toggle('is-loaded', i >= 0);
+
+  const knop = tape.querySelector('[data-tape-play]');
+  if (knop) {
+    knop.classList.toggle('is-pauze', draait);
+    knop.setAttribute('aria-label', draait ? 'Pauzeren' : 'Afspelen');
+  }
+
+  // Welk nummer loopt er. Bij een bandje is dat de enige plek waar je het leest,
+  // want de lijst is met opzet geen knoppenrij.
+  const items = [...tape.querySelectorAll('.tape-track')];
+  items.forEach((li, n) => li.classList.toggle('is-current', n === i));
+  const nu = tape.querySelector('[data-tape-now]');
+  if (nu) {
+    const titel = i >= 0 && items[i] ? items[i].querySelector('.tape-track-title').textContent : '';
+    nu.textContent = i >= 0 ? `${i + 1}. ${titel}` : '';
+  }
+}
+
+function tekenAlles() {
+  for (const tape of document.querySelectorAll('.post-tape')) tekenBand(tape);
+}
+
+function opKlik(e) {
+  const tape = e.target.closest && e.target.closest('.post-tape');
+  if (!tape) return;
+  const p = speler();
+
+  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);
+    return;
+  }
+
+  const play = e.target.closest('[data-tape-play]');
+  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
+    // starten, want die kent alleen "speel deze wachtrij".
+    const i = huidigeIndex(tape);
+    if (i >= 0) {
+      e.preventDefault();
+      e.stopPropagation();
+      if (p.isPlaying()) p.pause(); else p.play();
+      setTimeout(tekenAlles, 60);
+    }
+    // Staat het bandje stil, dan laten we de klik doorlopen: de speler pakt de
+    // data-attributen op en zet de wachtrij op.
+  }
+}
+
+export function init() {
+  const tapes = document.querySelectorAll('.post-tape');
+
+  // Altijd eerst opruimen, ook als er niets staat. Zonder dat blijft de tikker
+  // lopen na een paginawissel naar een pagina zonder bandje -- dezelfde fout
+  // 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; }
+  if (!tapes.length) return;
+
+  // Vangen in de CAPTURE-fase, want de speler luistert zelf op document in de
+  // bubbelfase. Alleen zo kan de pauzeknop zijn klik tegenhouden voordat de
+  // wachtrij opnieuw wordt gezet.
+  gebonden = document;
+  gebonden.addEventListener('click', opKlik, true);
+
+  tikker = setInterval(tekenAlles, TIK_MS);
+  tekenAlles();
+}
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
+++ src/routes/posts.js	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
@@ -255,5 +255,5 @@
     bodyClass: 'on-home',
     // mod/read.js: alleen nog de tik-op-een-bericht in de leesweergave.
-    pageJs: 'read',
+    pageJs: 'read tape',
   });
 });
@@ -1695,5 +1695,5 @@
 
   renderPage(req, res, 'pages/post', {
-    pageJs: 'post reply-editor',
+    pageJs: 'post reply-editor tape',
     post,
     poll: ActivityPubService.ownPollView(post),
Index: src/services/AudioEmbedService.js
===================================================================
--- src/services/AudioEmbedService.js	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
+++ src/services/AudioEmbedService.js	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
@@ -545,6 +545,4 @@
       // uitkomsten, en dan draagt een mixtape het jasje en het woord van een
       // album -- dezelfde vorm die op vier andere plekken al misging.
-      // DE SPELER IS ER NOG NIET. Een mixtape rendert voorlopig als de gewone
-      // lijst; wat hier af is, is dat hij zichzelf goed noemt.
       const kind = SOORTEN.includes(pl.kind) ? pl.kind : 'album';
       const KIND_LABEL = { album: '💿 Album', playlist: '📃 Playlist', mixtape: '📼 Mixtape' };
@@ -630,4 +628,16 @@
     </li>`;
       }).join('\n');
+
+      // HET BANDJE HEEFT ZIJN EIGEN VORM. Een album toont een genummerde lijst
+      // waar je in kunt prikken; een cassette is juist het tegenovergestelde --
+      // je hoort wat er komt, in de volgorde waarin het is opgenomen. Alles
+      // hierboven (de wachtrij, de metaregel, de duur) is gedeeld; alleen de
+      // opmaak splitst hier.
+      if (kind === 'mixtape') {
+        return this.renderTape({
+          domId: albumDomId, id, titleH, artistH, coverH, metaLine, firstUrl,
+          albumJson, tracks: pl.tracks, isAdmin,
+        });
+      }
 
       return `<div class="post-album" id="${albumDomId}"
@@ -673,4 +683,78 @@
 
   /**
+   * Het bandje (Robins idee, 21-8).
+   *
+   * WAT HET ANDERS MAAKT DAN EEN ALBUM, en dat is de hele reden dat dit een
+   * eigen vorm heeft: bij een album prik je in een genummerde lijst en spring
+   * je naar nummer zeven. Op een cassette kan dat niet. Je spoelt vooruit of
+   * terug, en wat er komt hoor je in de volgorde waarin het is opgenomen. De
+   * lijst staat er dus wel -- je mag zien wat erop staat -- maar hij is geen
+   * knoppenrij.
+   *
+   * DE SPELER IS DE BESTAANDE SPELER. De knop hieronder draagt exact dezelfde
+   * data-attributen als de albumhoes (data-pcms-track-url + data-pcms-album-id),
+   * dus audio-player.js pakt hem op zonder dat hier iets nieuws bij komt. Vooruit
+   * en terug lopen via window.pcmsAudioPlayer.next()/prev(), en dat is meteen de
+   * reden dat spoelen per NUMMER gaat en niet per seconde: die speler denkt in
+   * een wachtrij, en een tweede speler ernaast bouwen om een band na te doen zou
+   * twee dingen tegelijk laten afspelen.
+   */
+  static renderTape({ domId, id, titleH, artistH, coverH, metaLine, firstUrl, albumJson, tracks, isAdmin }) {
+    // De nummers als tekst, niet als knoppen. Bewust geen data-pcms-track-url:
+    // een aanklikbaar nummer is precies wat een bandje niet heeft.
+    const lijst = tracks.map((t, i) => {
+      const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
+      const dur = t.duration > 0
+        ? `${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}`
+        : '—:—';
+      return `      <li class="tape-track" data-tape-index="${i}"><span class="tape-track-title">${tTitle}</span><span class="tape-track-dur">${dur}</span></li>`;
+    }).join('\n');
+
+    const spoel = (richting, label, pad) => `    <button type="button" class="tape-btn tape-btn--${richting}" data-tape-go="${richting}" aria-label="${label}">
+      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">${pad}</svg>
+    </button>`;
+
+    return `<div class="post-tape" id="${domId}"
+     data-pcms-album='${albumJson}'
+     data-pcms-album-title="${titleH}"
+     data-pcms-album-kind="mixtape"
+     data-pcms-playlist-id="${this.escape(id)}">
+${isAdmin ? `  <div class="post-album-actions" role="group" aria-label="Mixtape beheren">
+    <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk mixtape" aria-label="Bewerk mixtape">
+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
+    </a>
+  </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>
+      <h3 class="tape-title">${titleH}</h3>
+      ${artistH ? `<p class="tape-artist">${artistH}</p>` : ''}
+      <p class="tape-meta">${metaLine}</p>
+    </div>
+  </div>
+  <div class="tape-controls" role="group" aria-label="Bandje bedienen">
+${spoel('back', 'Terugspoelen', '<path d="M11 12l9-7v14zM2 12l9-7v14z"/>')}
+    <button type="button" class="tape-btn tape-btn--play"
+            data-pcms-track-url="${firstUrl}"
+            data-pcms-album-id="${domId}"
+            data-tape-play
+            aria-label="Afspelen">
+      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
+    </button>
+${spoel('fwd', 'Vooruitspoelen', '<path d="M13 12L4 5v14zM22 12l-9-7v14z"/>')}
+  </div>
+  <p class="tape-now" data-tape-now aria-live="polite"></p>
+  <ol class="tape-tracks">
+${lijst}
+  </ol>
+</div>`;
+  }
+
+  /**
    * Human-readable label for a provider slug. Used by external-link buttons.
    */
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
+++ src/views/shell.ejs	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
@@ -222,5 +222,5 @@
 
 <!-- v9 stylesheet (full palette system) -->
-<link rel="stylesheet" href="/assets/css/style.css?v=105">
+<link rel="stylesheet" href="/assets/css/style.css?v=106">
 <script>
 /* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
@@ -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 = 51;
+  var MOD_V = 52;
 
   // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
