Changeset 16fd4fe in Klonkt


Ignore:
Timestamp:
08/22/2026 09:32:29 AM (3 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
e679bce
Parents:
8e1af9c
Message:

De cassettespeler: draaiende spoelen, vooruit en achteruit

Een album toont een genummerde lijst waar je in prikt. Een cassette is het
tegenovergestelde: je spoelt, en je hoort wat er komt in de volgorde waarin het
is opgenomen. Vandaar een eigen vorm en niet het albumjasje met een plaatje.

DE SPELER IS DE BESTAANDE SPELER. De afspeelknop draagt exact dezelfde
data-attributen als een albumhoes, dus audio-player.js pakt hem op; spoelen is
next()/prev(). Een tweede speler ernaast bouwen zou twee dingen tegelijk laten
klinken en de sessie weggooien die een paginawissel overleeft. Daarom spoelt
het per NUMMER en niet per seconde: die speler denkt in een wachtrij.

De nummers staan er als inhoud, niet als knoppen. Geen speel-url, geen knop,
geen hover -- op een cassette kun je niet naar nummer zeven springen. Wat er
speelt lees je op de statusregel, en dat is meteen de toegankelijke kant.

Verder:

  • De pauzeknop vangt zijn klik in de CAPTURE-fase, want audio-player.js luistert in de bubbelfase en zou de wachtrij anders opnieuw vanaf een zetten.
  • Spoelen doet niets als dit bandje niet loopt; anders verzet een spoelknop de wachtrij van een ander blok.
  • init() ruimt de tikker altijd eerst op, ook zonder bandje op de pagina. Zonder dat blijft hij lopen na een paginawissel -- dezelfde fout die in de leesweergave ooit de hele site heeft vastgezet.
  • prefers-reduced-motion zet het draaien uit; de statusregel blijft.

Opmaak in dezelfde familie als .post-album: zelfde rand, radius en papier, en
alleen bestaande variabelen, dus licht, donker en het accent volgen vanzelf.

MOD_V 52, style.css v106.

Co-Authored-By: Claude Opus 5 <noreply@…>

Files:
1 added
5 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/style.css

    r8e1af9c r16fd4fe  
    50765076  background: var(--accent, #e8b04b); color: #1a1205;
    50775077}
     5078
     5079/* ── Het bandje (mixtape) ──────────────────────────────────────────────
     5080   Zelfde familie als .post-album hierboven: dezelfde rand, dezelfde radius,
     5081   dezelfde papierkleur. Het verschil zit in wat je ziet draaien, niet in een
     5082   ander jasje -- een mixtape is een soort uitgave binnen Klonkt, geen ander
     5083   product.
     5084
     5085   Alles hieronder gebruikt de bestaande variabelen (--rule, --paper-2, --ink,
     5086   --accent), dus het volgt licht en donker en het accent van de site vanzelf. */
     5087.post-tape {
     5088    position: relative;
     5089    border: 1px solid var(--rule, rgba(0,0,0,.1));
     5090    border-radius: 14px;
     5091    overflow: hidden;
     5092    margin: 1.25rem 0;
     5093    background: var(--paper-2, color-mix(in srgb, var(--ink) 3%, transparent));
     5094}
     5095
     5096/* De cassette zelf. De verhouding is die van een echt bandje (ongeveer 10:6),
     5097   zodat hij ook op een telefoon als cassette leest en niet als balk. */
     5098.tape-shell {
     5099    display: grid;
     5100    grid-template-rows: auto auto;
     5101    gap: .5rem;
     5102    padding: .8rem .8rem .4rem;
     5103}
     5104.tape-window {
     5105    position: relative;
     5106    display: flex;
     5107    align-items: center;
     5108    justify-content: center;
     5109    gap: 12%;
     5110    padding: .9rem 1.2rem;
     5111    border-radius: 10px;
     5112    background: color-mix(in srgb, var(--ink) 8%, transparent);
     5113    border: 1px solid var(--rule, rgba(0,0,0,.12));
     5114}
     5115/* De band tussen de spoelen. Een dun lijntje is genoeg: het gaat om de twee
     5116   draaiende schijven, dat is wat een cassette een cassette maakt. */
     5117.tape-band {
     5118    position: absolute;
     5119    left: 22%; right: 22%;
     5120    height: 3px;
     5121    background: color-mix(in srgb, var(--ink) 35%, transparent);
     5122    border-radius: 2px;
     5123}
     5124.tape-reel {
     5125    position: relative;
     5126    width: clamp(48px, 16vw, 76px);
     5127    aspect-ratio: 1;
     5128    border-radius: 50%;
     5129    background:
     5130      repeating-conic-gradient(from 0deg,
     5131        color-mix(in srgb, var(--ink) 22%, transparent) 0deg 12deg,
     5132        transparent 12deg 24deg);
     5133    border: 2px solid color-mix(in srgb, var(--ink) 30%, transparent);
     5134    display: grid;
     5135    place-items: center;
     5136}
     5137.tape-reel-hub {
     5138    width: 34%;
     5139    aspect-ratio: 1;
     5140    border-radius: 50%;
     5141    background: var(--paper, #fff);
     5142    border: 2px solid color-mix(in srgb, var(--ink) 40%, transparent);
     5143}
     5144/* Draaien doet hij alleen als hij speelt. De animatie staat op de spoel en niet
     5145   op de hele cassette: het label hoort stil te blijven, anders wordt de titel
     5146   onleesbaar. */
     5147.post-tape.is-playing .tape-reel { animation: tape-spin 2.4s linear infinite; }
     5148.post-tape.is-playing .tape-reel--right { animation-duration: 3.2s; }
     5149@keyframes tape-spin { to { transform: rotate(360deg); } }
     5150/* Wie liever geen beweging ziet, krijgt geen beweging. De cassette blijft dan
     5151   gewoon staan; de statusregel eronder vertelt nog steeds wat er speelt. */
     5152@media (prefers-reduced-motion: reduce) {
     5153    .post-tape.is-playing .tape-reel { animation: none; }
     5154}
     5155
     5156.tape-label {
     5157    padding: .5rem .6rem;
     5158    border-radius: 8px;
     5159    background-size: cover;
     5160    background-position: center;
     5161    position: relative;
     5162}
     5163/* Staat er een hoes achter het label, dan moet de tekst leesbaar blijven. */
     5164.tape-label[style] { color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.7); }
     5165.tape-kind {
     5166    margin: 0;
     5167    font-size: .7rem;
     5168    letter-spacing: .06em;
     5169    text-transform: uppercase;
     5170    opacity: .75;
     5171}
     5172.tape-title { margin: .1rem 0 0; font-size: 1.05rem; line-height: 1.25; }
     5173.tape-artist { margin: .05rem 0 0; font-size: .85rem; opacity: .8; }
     5174.tape-meta { margin: .15rem 0 0; font-size: .75rem; opacity: .65; }
     5175
     5176/* De knoppen: terugspoelen, spelen, vooruitspoelen. Meer heeft een bandje niet,
     5177   en dat is de bedoeling -- er is geen zoekbalk omdat je op een cassette niet
     5178   kunt zoeken. */
     5179.tape-controls {
     5180    display: flex;
     5181    align-items: center;
     5182    justify-content: center;
     5183    gap: .6rem;
     5184    padding: .3rem 0 .7rem;
     5185}
     5186.tape-btn {
     5187    display: grid;
     5188    place-items: center;
     5189    width: 40px; height: 40px;
     5190    border-radius: 50%;
     5191    border: 1px solid var(--rule, rgba(0,0,0,.15));
     5192    background: var(--paper, #fff);
     5193    color: var(--ink);
     5194    cursor: pointer;
     5195    padding: 0;
     5196}
     5197.tape-btn svg { width: 18px; height: 18px; }
     5198.tape-btn:hover { border-color: var(--accent); color: var(--accent); }
     5199.tape-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
     5200.tape-btn--play {
     5201    width: 52px; height: 52px;
     5202    background: var(--accent);
     5203    border-color: var(--accent);
     5204    color: var(--paper, #fff);
     5205}
     5206.tape-btn--play svg { width: 22px; height: 22px; }
     5207/* Speelt hij, dan is dezelfde knop de pauzeknop. Twee knoppen zou een bandje
     5208   niet hebben. */
     5209.post-tape.is-playing .tape-btn--play svg path { d: path("M7 5h4v14H7zM13 5h4v14h-4z"); }
     5210
     5211.tape-now {
     5212    margin: 0;
     5213    padding: 0 .8rem .4rem;
     5214    text-align: center;
     5215    font-size: .8rem;
     5216    opacity: .8;
     5217    min-height: 1.2em;
     5218}
     5219
     5220/* De nummers staan er als INHOUD, niet als knoppen. Geen cursor, geen hover,
     5221   geen aanwijzing dat je erop kunt drukken: op een cassette kun je dat niet. */
     5222.tape-tracks {
     5223    margin: 0;
     5224    padding: 0 .8rem .8rem;
     5225    list-style: none;
     5226    counter-reset: tape;
     5227    display: grid;
     5228    gap: .1rem;
     5229}
     5230.tape-track {
     5231    counter-increment: tape;
     5232    display: flex;
     5233    gap: .5rem;
     5234    align-items: baseline;
     5235    font-size: .85rem;
     5236    padding: .18rem .3rem;
     5237    border-radius: 6px;
     5238    opacity: .75;
     5239}
     5240.tape-track::before {
     5241    content: counter(tape) ".";
     5242    opacity: .55;
     5243    min-width: 1.4em;
     5244    font-variant-numeric: tabular-nums;
     5245}
     5246.tape-track-title { flex: 1; }
     5247.tape-track-dur { opacity: .6; font-variant-numeric: tabular-nums; }
     5248.tape-track.is-current {
     5249    opacity: 1;
     5250    background: color-mix(in srgb, var(--accent) 12%, transparent);
     5251}
  • src/routes/posts.js

    r8e1af9c r16fd4fe  
    255255    bodyClass: 'on-home',
    256256    // mod/read.js: alleen nog de tik-op-een-bericht in de leesweergave.
    257     pageJs: 'read',
     257    pageJs: 'read tape',
    258258  });
    259259});
     
    16951695
    16961696  renderPage(req, res, 'pages/post', {
    1697     pageJs: 'post reply-editor',
     1697    pageJs: 'post reply-editor tape',
    16981698    post,
    16991699    poll: ActivityPubService.ownPollView(post),
  • src/services/AudioEmbedService.js

    r8e1af9c r16fd4fe  
    545545      // uitkomsten, en dan draagt een mixtape het jasje en het woord van een
    546546      // album -- dezelfde vorm die op vier andere plekken al misging.
    547       // DE SPELER IS ER NOG NIET. Een mixtape rendert voorlopig als de gewone
    548       // lijst; wat hier af is, is dat hij zichzelf goed noemt.
    549547      const kind = SOORTEN.includes(pl.kind) ? pl.kind : 'album';
    550548      const KIND_LABEL = { album: '💿 Album', playlist: '📃 Playlist', mixtape: '📼 Mixtape' };
     
    630628    </li>`;
    631629      }).join('\n');
     630
     631      // HET BANDJE HEEFT ZIJN EIGEN VORM. Een album toont een genummerde lijst
     632      // waar je in kunt prikken; een cassette is juist het tegenovergestelde --
     633      // je hoort wat er komt, in de volgorde waarin het is opgenomen. Alles
     634      // hierboven (de wachtrij, de metaregel, de duur) is gedeeld; alleen de
     635      // opmaak splitst hier.
     636      if (kind === 'mixtape') {
     637        return this.renderTape({
     638          domId: albumDomId, id, titleH, artistH, coverH, metaLine, firstUrl,
     639          albumJson, tracks: pl.tracks, isAdmin,
     640        });
     641      }
    632642
    633643      return `<div class="post-album" id="${albumDomId}"
     
    673683
    674684  /**
     685   * Het bandje (Robins idee, 21-8).
     686   *
     687   * WAT HET ANDERS MAAKT DAN EEN ALBUM, en dat is de hele reden dat dit een
     688   * eigen vorm heeft: bij een album prik je in een genummerde lijst en spring
     689   * je naar nummer zeven. Op een cassette kan dat niet. Je spoelt vooruit of
     690   * terug, en wat er komt hoor je in de volgorde waarin het is opgenomen. De
     691   * lijst staat er dus wel -- je mag zien wat erop staat -- maar hij is geen
     692   * knoppenrij.
     693   *
     694   * DE SPELER IS DE BESTAANDE SPELER. De knop hieronder draagt exact dezelfde
     695   * data-attributen als de albumhoes (data-pcms-track-url + data-pcms-album-id),
     696   * dus audio-player.js pakt hem op zonder dat hier iets nieuws bij komt. Vooruit
     697   * en terug lopen via window.pcmsAudioPlayer.next()/prev(), en dat is meteen de
     698   * reden dat spoelen per NUMMER gaat en niet per seconde: die speler denkt in
     699   * een wachtrij, en een tweede speler ernaast bouwen om een band na te doen zou
     700   * twee dingen tegelijk laten afspelen.
     701   */
     702  static renderTape({ domId, id, titleH, artistH, coverH, metaLine, firstUrl, albumJson, tracks, isAdmin }) {
     703    // De nummers als tekst, niet als knoppen. Bewust geen data-pcms-track-url:
     704    // een aanklikbaar nummer is precies wat een bandje niet heeft.
     705    const lijst = tracks.map((t, i) => {
     706      const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
     707      const dur = t.duration > 0
     708        ? `${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}`
     709        : '—:—';
     710      return `      <li class="tape-track" data-tape-index="${i}"><span class="tape-track-title">${tTitle}</span><span class="tape-track-dur">${dur}</span></li>`;
     711    }).join('\n');
     712
     713    const spoel = (richting, label, pad) => `    <button type="button" class="tape-btn tape-btn--${richting}" data-tape-go="${richting}" aria-label="${label}">
     714      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">${pad}</svg>
     715    </button>`;
     716
     717    return `<div class="post-tape" id="${domId}"
     718     data-pcms-album='${albumJson}'
     719     data-pcms-album-title="${titleH}"
     720     data-pcms-album-kind="mixtape"
     721     data-pcms-playlist-id="${this.escape(id)}">
     722${isAdmin ? `  <div class="post-album-actions" role="group" aria-label="Mixtape beheren">
     723    <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk mixtape" aria-label="Bewerk mixtape">
     724      <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>
     725    </a>
     726  </div>
     727` : ''}  <div class="tape-shell">
     728    <div class="tape-window" aria-hidden="true">
     729      <span class="tape-reel tape-reel--left"><span class="tape-reel-hub"></span></span>
     730      <span class="tape-band"></span>
     731      <span class="tape-reel tape-reel--right"><span class="tape-reel-hub"></span></span>
     732    </div>
     733    <div class="tape-label"${coverH ? ` style="background-image:url('${coverH}')"` : ''}>
     734      <p class="tape-kind">📼 Mixtape</p>
     735      <h3 class="tape-title">${titleH}</h3>
     736      ${artistH ? `<p class="tape-artist">${artistH}</p>` : ''}
     737      <p class="tape-meta">${metaLine}</p>
     738    </div>
     739  </div>
     740  <div class="tape-controls" role="group" aria-label="Bandje bedienen">
     741${spoel('back', 'Terugspoelen', '<path d="M11 12l9-7v14zM2 12l9-7v14z"/>')}
     742    <button type="button" class="tape-btn tape-btn--play"
     743            data-pcms-track-url="${firstUrl}"
     744            data-pcms-album-id="${domId}"
     745            data-tape-play
     746            aria-label="Afspelen">
     747      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
     748    </button>
     749${spoel('fwd', 'Vooruitspoelen', '<path d="M13 12L4 5v14zM22 12l-9-7v14z"/>')}
     750  </div>
     751  <p class="tape-now" data-tape-now aria-live="polite"></p>
     752  <ol class="tape-tracks">
     753${lijst}
     754  </ol>
     755</div>`;
     756  }
     757
     758  /**
    675759   * Human-readable label for a provider slug. Used by external-link buttons.
    676760   */
  • src/views/shell.ejs

    r8e1af9c r16fd4fe  
    222222
    223223<!-- v9 stylesheet (full palette system) -->
    224 <link rel="stylesheet" href="/assets/css/style.css?v=105">
     224<link rel="stylesheet" href="/assets/css/style.css?v=106">
    225225<script>
    226226/* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
     
    505505  // Eén nummer voor de hele map. Te vaak bumpen kost één download; te weinig
    506506  // bumpen kost een bugfix die nooit aankomt.
    507   var MOD_V = 51;
     507  var MOD_V = 52;
    508508
    509509  // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
  • test/mixtape-ui.test.js

    r8e1af9c r16fd4fe  
    9696  assert.match(bron, /mixtape: '📼 Mixtape'/);
    9797});
     98
     99test('het bandje is een cassette, geen albumlijst', async () => {
     100  const AudioEmbedService = (await import('../src/services/AudioEmbedService.js')).default;
     101  const pl = {
     102    id: 'tape', title: 'De Mixtape', artist: 'robo', kind: 'mixtape', cover: '',
     103    tracks: [
     104      { id: 't1', title: 'Kant A', artist: 'robo', url: '/a.mp3', duration: 100, cover: '' },
     105      { id: 't2', title: 'Kant B', artist: 'robo', url: '/b.mp3', duration: 44, cover: '' },
     106    ],
     107  };
     108  const html = AudioEmbedService.embedPlaylistShortcodes('<p>[[playlist:tape]]</p>', (id) => (id === 'tape' ? pl : null));
     109
     110  assert.match(html, /class="post-tape"/);
     111  assert.match(html, /tape-reel--left/, 'twee spoelen, anders is het geen cassette');
     112  assert.match(html, /tape-reel--right/);
     113  assert.match(html, /data-tape-go="back"/, 'terugspoelen');
     114  assert.match(html, /data-tape-go="fwd"/, 'vooruitspoelen');
     115
     116  // DE KERN VAN HET IDEE: de nummers staan er als inhoud, niet als knoppen.
     117  // Zodra een track een speel-url draagt kun je erop prikken, en dan is het
     118  // geen bandje meer maar een lijst met een cassetteplaatje erboven.
     119  const lijst = html.slice(html.indexOf('<ol class="tape-tracks"'));
     120  assert.doesNotMatch(lijst, /data-pcms-track-url/, 'een aanklikbaar nummer hoort hier niet');
     121  assert.doesNotMatch(lijst, /<button/, 'en een knop ook niet');
     122
     123  // De afspeelknop leunt wel op de bestaande speler, anders bouwen we een
     124  // tweede speler naast de site-speler.
     125  assert.match(html, /data-pcms-album-id="album-tape"/);
     126  assert.match(html, /data-pcms-track-url="\/a\.mp3"/);
     127
     128  // En geen albumopmaak: dat was de hele reden voor een eigen vorm.
     129  assert.doesNotMatch(html, /class="post-album"/);
     130});
Note: See TracChangeset for help on using the changeset viewer.