Changeset e679bce in Klonkt


Ignore:
Timestamp:
08/22/2026 09:54:51 AM (3 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
8bb3b09
Parents:
16fd4fe
Message:

Het bandje speelt als bandje: een cassette, een object, spoelen in seconden

Robins correctie (21-8): vooruit en achteruit deden hier next()/prev(), en dat
is een playlistgebaar. Dan is het een lijst met een cassetteplaatje erboven.
Een cassette kent geen "volgend nummer" -- je houdt de knop ingedrukt, de band
loopt door, en je laat los waar je bent.

Dat kan omdat audio-player.js een wachtrij al in EEN doorlopende
MediaSource-tijdlijn giet: "een trackwissel is een positie, geen omschakeling".
Een bandje IS die tijdlijn. Dus:

  • seekBy(seconden) op de speler, over de nummergrenzen heen. Op de blob-motor (iOS, geen MSE) bestaat die tijdlijn niet; daar loopt spoelen tot de rand van het nummer en stapt dan naar de buur. Grover, maar eerlijker dan doen alsof.
  • Bandmodus: de speler toont het BANDJE als titel met het lopende nummer eronder, en de teller loopt over de hele band in plaats van per nummer. Dat is wat "een object, geen losse tracks" betekent op het scherm.
  • Vasthouden versnelt (4x tot 16x), loslaten stopt -- ook als je buiten de knop loslaat. Een korte tik spoelt vijf seconden, want vasthouden is met een spatiebalk geen gebaar.

Een echte cassette, als SVG. Verhouding 100,4 x 63,8 mm, de maat van een
compact cassette, dus viewBox 314x200: behuizing, labelvlak met de titel erop,
venster, twee spoelen met tandjes die meedraaien, de bandpakketten, vier
schroefjes en de openingen voor de kop en de capstans. De vorige versie waren
twee CSS-schijfjes.

Twee dingen die alleen door het echt te draaien boven kwamen:

  • audio-player.js kiest zijn knoppen met een lijst KLASSENAMEN (PLAY_SELECTOR), niet met een regel over data-attributen. De cassetteknop droeg alle juiste data-pcms-* en deed niets. Er staat nu een test op.
  • De sessie sloeg displayTimes().cur op als trackpositie. In bandmodus is dat de bandteller, dus een hersteld bandje sprong naar een plek die in dat nummer niet bestaat. Daarvoor is trackTijd() afgesplitst.

MOD_V 53, style.css v107, audio-player v35.

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

Files:
6 edited

Legend:

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

    r16fd4fe re679bce  
    50785078
    50795079/* ── 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. */
     5080   Een cassette, getekend als SVG op de echte maat: 100,4 x 63,8 mm, dus een
     5081   viewBox van 314x200. De vorige versie tekende twee schijfjes met CSS; dit is
     5082   een replica -- behuizing, labelvlak, venster, twee spoelen met tandjes, de
     5083   schroefjes in de hoeken en de openingen voor de kop en de capstans onderin.
     5084
     5085   Alle kleuren komen uit de bestaande variabelen, dus licht, donker en het
     5086   accent van de site volgen vanzelf. */
    50875087.post-tape {
    50885088    position: relative;
     
    50945094}
    50955095
    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 {
     5096.tape-shell { position: relative; padding: .8rem .8rem .3rem; }
     5097.tape-svg { display: block; width: 100%; height: auto; }
     5098
     5099/* De behuizing. Twee lagen zodat er een randje omheen zit, zoals bij een echt
     5100   doosje waar de bovenschaal iets uitsteekt. */
     5101.tape-body { fill: color-mix(in srgb, var(--ink) 82%, var(--paper, #fff)); }
     5102.tape-body-inner { fill: color-mix(in srgb, var(--ink) 70%, var(--paper, #fff)); }
     5103.tape-labelplate { fill: color-mix(in srgb, var(--paper, #fff) 92%, var(--accent)); }
     5104.tape-glass {
     5105    fill: color-mix(in srgb, var(--ink) 88%, transparent);
     5106    stroke: color-mix(in srgb, var(--paper, #fff) 25%, transparent);
     5107    stroke-width: 1.5;
     5108}
     5109.tape-pack { fill: color-mix(in srgb, var(--ink) 55%, var(--paper, #fff)); }
     5110.tape-ribbon { fill: color-mix(in srgb, var(--ink) 45%, var(--paper, #fff)); }
     5111.tape-hub { fill: var(--paper, #fff); }
     5112.tape-tooth { fill: color-mix(in srgb, var(--ink) 60%, var(--paper, #fff)); }
     5113.tape-screw { fill: color-mix(in srgb, var(--ink) 55%, var(--paper, #fff)); }
     5114.tape-slot { fill: color-mix(in srgb, var(--ink) 92%, transparent); }
     5115.tape-slot--capstan { fill: color-mix(in srgb, var(--ink) 96%, transparent); }
     5116
     5117/* Draaien. De tandjes zitten in dezelfde groep als de naaf, dus de hele spoel
     5118   draait als een geheel -- daar gaat het om, want aan die tandjes zie je pas
     5119   DAT hij draait. Het label blijft stil, anders wordt de titel onleesbaar. */
     5120.post-tape.is-playing .tape-reel { animation: tape-spin 3s linear infinite; }
     5121.post-tape.is-playing .tape-reel--right { animation-duration: 2.2s; }
     5122/* Spoelen: veel sneller, en achteruit ook echt achteruit. */
     5123.post-tape.is-winding .tape-reel { animation-duration: .45s; }
     5124.post-tape.is-winding-back .tape-reel { animation-direction: reverse; }
     5125@keyframes tape-spin { to { transform: rotate(360deg); } }
     5126@media (prefers-reduced-motion: reduce) {
     5127    .post-tape.is-playing .tape-reel,
     5128    .post-tape.is-winding .tape-reel { animation: none; }
     5129}
     5130
     5131/* De tekst staat OP het labelvlak van de cassette. Percentages volgen de
     5132   viewBox, dus het blijft kloppen op elke breedte. */
     5133.tape-label {
    51185134    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); }
     5135    left: 8.4%; right: 8.4%;
     5136    top: calc(.8rem + 11%);
     5137    height: 31%;
     5138    padding: .1rem .4rem;
     5139    color: color-mix(in srgb, var(--ink) 92%, transparent);
     5140    overflow: hidden;
     5141}
    51655142.tape-kind {
    51665143    margin: 0;
    5167     font-size: .7rem;
    5168     letter-spacing: .06em;
     5144    font-size: clamp(.55rem, 1.6vw, .68rem);
     5145    letter-spacing: .14em;
    51695146    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. */
     5147    opacity: .7;
     5148}
     5149.tape-title {
     5150    margin: 0;
     5151    font-size: clamp(.85rem, 2.6vw, 1.1rem);
     5152    line-height: 1.15;
     5153    white-space: nowrap;
     5154    overflow: hidden;
     5155    text-overflow: ellipsis;
     5156}
     5157.tape-artist { margin: 0; font-size: clamp(.65rem, 1.9vw, .8rem); opacity: .8; }
     5158.tape-meta { margin: 0; font-size: clamp(.6rem, 1.7vw, .72rem); opacity: .6; }
     5159
     5160/* De knoppen: terugspoelen, spelen, vooruitspoelen. Meer heeft een cassette
     5161   niet, en dat is de bedoeling -- er is geen zoekbalk omdat je op een bandje
     5162   niet kunt zoeken, alleen spoelen. */
    51795163.tape-controls {
    51805164    display: flex;
     
    51825166    justify-content: center;
    51835167    gap: .6rem;
    5184     padding: .3rem 0 .7rem;
     5168    padding: .5rem 0 .6rem;
    51855169}
    51865170.tape-btn {
     
    51945178    cursor: pointer;
    51955179    padding: 0;
     5180    touch-action: manipulation;   /* vasthouden mag geen zoom of scroll worden */
     5181    user-select: none;
    51965182}
    51975183.tape-btn svg { width: 18px; height: 18px; }
     
    52055191}
    52065192.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"); }
     5193.post-tape.is-winding .tape-btn--back,
     5194.post-tape.is-winding .tape-btn--fwd { border-color: var(--accent); color: var(--accent); }
    52105195
    52115196.tape-now {
  • src/assets/js/audio-player.js

    r16fd4fe re679bce  
    158158  let isPlaying = false;
    159159  let albumName = '';
     160  // BANDMODUS (Robins eis, 21-8): een mixtape is EEN object, geen wachtrij met
     161  // nummers. De MSE-keten hieronder maakt van een wachtrij toch al een
     162  // doorlopende tijdlijn -- "een trackwissel is een positie, geen omschakeling"
     163  // -- dus een bandje is precies die tijdlijn, alleen anders getoond en anders
     164  // bediend: je ziet de titel van het bandje, de teller loopt over het geheel,
     165  // en spoelen gaat in seconden in plaats van per nummer.
     166  let tapeMode = false;
    160167  // Playback pipeline. We fetch each track's bytes ourselves (X-Audio-Player
    161168  // gate; no plain media URL is ever exposed to the page) and feed them to the
     
    457464  // Current position/duration in TRACK coordinates (the MSE timeline is the
    458465  // whole chain; the UI always shows the single playing track).
    459   function displayTimes() {
     466  // De positie BINNEN het huidige nummer. Apart van displayTimes(), want die
     467  // geeft in bandmodus de teller over de hele band -- en het opslaan van de
     468  // sessie heeft juist de trackpositie nodig: bij het herstellen begint de
     469  // keten opnieuw en start dit nummer weer op nul.
     470  function trackTijd() {
    460471    if (useMse() && chain.length) {
    461472      const seg = currentSegment();
     
    463474    }
    464475    return { cur: audio.currentTime || 0, dur: audio.duration };
     476  }
     477
     478  function displayTimes() {
     479    // Een bandje heeft een teller, geen nummerpositie: hij telt door over de
     480    // kant heen. Dat is letterlijk de ketentijdlijn, dus hier juist NIET
     481    // terugrekenen naar trackcoordinaten.
     482    if (tapeMode && useMse() && chain.length) {
     483      return { cur: audio.currentTime || 0, dur: chain[chain.length - 1].end };
     484    }
     485    return trackTijd();
    465486  }
    466487
     
    505526  // session metadata. Called from loadTrack AND from the gapless boundary-cross.
    506527  function updateTrackChrome(t) {
    507     titleEl.textContent  = t.title  || 'Untitled';
    508     artistEl.textContent = t.artist || '';
    509     sheetTitle.textContent  = t.title  || 'Untitled';
    510     sheetArtist.textContent = t.artist || '';
     528    // In bandmodus staat het BANDJE op de speler. Wat er op dit moment klinkt
     529    // staat eronder, zoals een cassette een titel op het label heeft en de
     530    // nummers op het doosje.
     531    const hoofd = tapeMode ? (albumName || 'Mixtape') : (t.title || 'Untitled');
     532    const onder = tapeMode ? (t.title || '') : (t.artist || '');
     533    titleEl.textContent  = hoofd;
     534    artistEl.textContent = onder;
     535    sheetTitle.textContent  = hoofd;
     536    sheetArtist.textContent = onder;
    511537    sheetAlbum.textContent  = albumName || '';
    512538    setCoverImage(cover,      t.cover);
     
    579605    queue = Array.isArray(tracks) ? tracks.slice() : [];
    580606    albumName = (opts && opts.albumName) || '';
     607    tapeMode = !!(opts && opts.asTape);
    581608    if (!queue.length) return;
    582609    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
     
    629656    queue = [];
    630657    albumName = '';
     658    tapeMode = false;
    631659    loadSeq++;  // cancel any in-flight load
    632660    dropPreload();
     
    10101038  // would leave dead buttons (flag baked into the snapshot, listener gone). One
    10111039  // delegated listener works regardless of how many times the DOM is (re)swapped.
     1040  // WELKE KNOPPEN DEZE SPELER BEDIENT. Let op: dit is een LIJST MET NAMEN, geen
     1041  // regel over data-attributen. Een nieuwe knop die keurig data-pcms-track-url
     1042  // en data-pcms-album-id draagt doet dus niets zolang hij hier niet bij staat.
     1043  // Precies daar liep de cassetteknop op vast (21-8): de opmaak klopte, de
     1044  // gegevens klopten, en er gebeurde niets.
    10121045  const PLAY_SELECTOR =
    1013     '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall';
     1046    '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall, .tape-btn--play';
    10141047  document.body.addEventListener('click', (e) => {
    10151048    const btn = e.target.closest(PLAY_SELECTOR);
     
    10381071        // (cover-btn and playall both want to start from the beginning).
    10391072        const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
    1040         setQueue(tracks, startIdx >= 0 ? startIdx : 0, { albumName: album.dataset.pcmsAlbumTitle || '' });
     1073        // Een mixtape gaat als EEN object de speler in: de titel van het bandje
     1074        // op de speler, de teller over de hele band, en spoelen in seconden.
     1075        // De soort staat al op het blok (data-pcms-album-kind), dus hier is het
     1076        // een doorgeefje en geen tweede plek die iets afleidt.
     1077        setQueue(tracks, startIdx >= 0 ? startIdx : 0, {
     1078          albumName: album.dataset.pcmsAlbumTitle || '',
     1079          asTape: album.dataset.pcmsAlbumKind === 'mixtape',
     1080        });
    10411081      } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
    10421082    } else if (trackData) {
     
    10921132  // 9. Public API
    10931133  // ============================================================
     1134  /**
     1135   * SPOELEN, in seconden over de hele band.
     1136   *
     1137   * Op de MSE-motor is dit precies wat een cassette doet: `audio.currentTime`
     1138   * IS de tijdlijn van de hele keten, dus over een nummergrens heen spoelen is
     1139   * gewoon doortellen. Geen trackwissel, geen nieuwe afspeelsessie.
     1140   *
     1141   * Op de blob-motor (iOS Safari, geen MSE) bestaat die doorlopende tijdlijn
     1142   * niet: daar is elk nummer een eigen bron. Spoelen loopt daar dus tot de rand
     1143   * van het huidige nummer en stapt dan naar de buur. Grover, maar het is
     1144   * eerlijker dan doen alsof de band doorloopt terwijl hij dat niet doet.
     1145   */
     1146  function seekBy(seconden) {
     1147    const d = Number(seconden) || 0;
     1148    if (!d || !audio) return;
     1149    if (useMse() && chain.length) {
     1150      const eind = chain[chain.length - 1].end;
     1151      const doel = Math.max(0, Math.min((audio.currentTime || 0) + d, Math.max(0, eind - 0.25)));
     1152      try { audio.currentTime = doel; } catch (e) { /* buffer nog niet zover */ }
     1153      updatePositionState();
     1154      return;
     1155    }
     1156    // Blob-motor: binnen het nummer blijven, en anders naar de buur.
     1157    const duur = audio.duration || 0;
     1158    const nu = audio.currentTime || 0;
     1159    if (duur && nu + d >= duur) { next(); return; }
     1160    if (nu + d < 0) { prev(); return; }
     1161    try { audio.currentTime = Math.max(0, nu + d); } catch (e) {}
     1162  }
     1163
     1164  /** Waar staat de teller, over het hele bandje. */
     1165  function tapeTijden() { return displayTimes(); }
     1166
    10941167  window.pcmsAudioPlayer = {
    1095     setQueue, play, pause, next, prev, close, openSheet, closeSheet,
     1168    setQueue, play, pause, next, prev, close, openSheet, closeSheet, seekBy, tapeTijden,
     1169    isTape: () => tapeMode,
    10961170    isPlaying: () => isPlaying,
    10971171    currentTrack: () => queue[currentIndex] || null,
     
    11131187      if (!queue.length) { sessionStorage.removeItem(PLAYER_STATE_KEY); return; }
    11141188      sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
    1115         queue, currentIndex, albumName,
     1189        queue, currentIndex, albumName, tapeMode,
    11161190        // In-TRACK position (the MSE timeline spans the whole chain; a restore
    11171191        // starts a fresh chain where this track begins at 0).
    1118         time: displayTimes().cur || 0,
     1192        time: trackTijd().cur || 0,
    11191193        playing: !!audio.src && !audio.paused,
    11201194      }));
     
    11401214    queue = s.queue;
    11411215    albumName = s.albumName || '';
     1216    tapeMode = !!s.tapeMode;
    11421217    pendingSeek = s.time || 0;
    11431218    const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
  • src/assets/js/mod/tape.js

    r16fd4fe re679bce  
    77 * een bandgevoel na te doen zou twee dingen tegelijk laten klinken en die hele
    88 * sessie weggooien. Dus: de afspeelknop draagt dezelfde data-attributen als een
    9  * albumhoes en wordt door die speler opgepakt, en spoelen is next()/prev().
     9 * albumhoes en wordt door die speler opgepakt.
    1010 *
    11  * DAAROM SPOELT HET PER NUMMER en niet per seconde. Die speler denkt in een
    12  * wachtrij. Dat is een eerlijke benadering van een cassette: je komt vooruit en
    13  * achteruit, en je springt niet naar nummer zeven.
     11 * SPOELEN GAAT IN SECONDEN, over de nummergrenzen heen. Dat kan omdat die
     12 * speler een wachtrij in EEN doorlopende MediaSource-tijdlijn giet: "een
     13 * trackwissel is een positie, geen omschakeling". Een bandje is dus letterlijk
     14 * die tijdlijn. Vandaar seekBy() en niet next()/prev() -- springen per nummer
     15 * is een playlistgebaar, en dan is dit een lijst met een cassetteplaatje.
    1416 *
    1517 * WAAROM ER GEPOLLD WORDT. audio-player.js zendt geen gebeurtenissen uit -- het
     
    7678}
    7779
     80/**
     81 * SPOELEN IS VASTHOUDEN, en dat is het verschil met een afspeellijst.
     82 *
     83 * Eerst deed vooruit en achteruit hier next()/prev(): een nummer verder. Dat is
     84 * een playlistgebaar. Op een cassette bestaat "volgend nummer" niet -- je houdt
     85 * de knop ingedrukt, de band loopt door, en je laat los waar je bent. Robin
     86 * wees daarop (21-8) en hij heeft gelijk: met knoppen die per nummer springen
     87 * is het een lijst met een cassetteplaatje erboven.
     88 *
     89 * Het versnelt terwijl je hem vasthoudt, zoals een echt bandje op gang komt.
     90 * Een korte TIK spoelt een klein stukje -- dat is er voor toetsenbord en
     91 * schermlezer, want vasthouden is met een spatiebalk geen gebaar.
     92 */
     93const START_SNELHEID = 4;    // maal de normale snelheid bij het indrukken
     94const MAX_SNELHEID = 16;
     95const VERSNELLING = 1.35;    // per stap
     96const STAP_MS = 120;
     97const TIK_STAP_S = 5;        // een losse klik
     98
     99let winder = null;
     100
     101function stopWinden(tape) {
     102  if (winder) { clearInterval(winder.timer); winder = null; }
     103  if (tape) tape.classList.remove('is-winding', 'is-winding-back');
     104}
     105
     106function startWinden(tape, richting) {
     107  const p = speler();
     108  if (!p || huidigeIndex(tape) < 0) return;
     109  stopWinden(tape);
     110  let snelheid = START_SNELHEID;
     111  tape.classList.add('is-winding');
     112  if (richting < 0) tape.classList.add('is-winding-back');
     113  const timer = setInterval(() => {
     114    snelheid = Math.min(MAX_SNELHEID, snelheid * VERSNELLING);
     115    p.seekBy(richting * snelheid * (STAP_MS / 1000));
     116    tekenBand(tape);
     117  }, STAP_MS);
     118  winder = { timer, tape };
     119}
     120
     121function opPointerDown(e) {
     122  const knop = e.target.closest && e.target.closest('[data-tape-go]');
     123  if (!knop) return;
     124  const tape = knop.closest('.post-tape');
     125  if (!tape) return;
     126  startWinden(tape, knop.dataset.tapeGo === 'fwd' ? 1 : -1);
     127}
     128
     129function opPointerUp() {
     130  if (winder) stopWinden(winder.tape);
     131}
     132
    78133function opKlik(e) {
    79134  const tape = e.target.closest && e.target.closest('.post-tape');
     
    83138  const spoel = e.target.closest('[data-tape-go]');
    84139  if (spoel) {
    85     if (!p) return;
    86     // Spoelen kan alleen als DIT bandje loopt. Anders zou de knop de wachtrij
    87     // van een ander blok verzetten, en dat is niet wat een spoelknop op deze
    88     // cassette hoort te doen.
    89     if (huidigeIndex(tape) < 0) return;
    90     if (spoel.dataset.tapeGo === 'fwd') p.next(); else p.prev();
    91     setTimeout(tekenAlles, 60);
     140    // Een tik spoelt een stukje. Vasthouden gaat via de pointer-afhandelaars
     141    // hierboven; die hebben dan al gespoeld en deze klik doet er nog een klein
     142    // beetje bovenop, wat niet stoort.
     143    if (!p || huidigeIndex(tape) < 0) return;
     144    p.seekBy(spoel.dataset.tapeGo === 'fwd' ? TIK_STAP_S : -TIK_STAP_S);
     145    setTimeout(() => tekenBand(tape), 60);
    92146    return;
    93147  }
     
    96150  if (play && p) {
    97151    // Loopt dit bandje al, dan is deze knop een pauzeknop. De globale
    98     // afhandelaar van audio-player.js zou hem anders opnieuw vanaf nummer een
     152    // afhandelaar van audio-player.js zou hem anders opnieuw vanaf het begin
    99153    // starten, want die kent alleen "speel deze wachtrij".
    100154    const i = huidigeIndex(tape);
     
    106160    }
    107161    // Staat het bandje stil, dan laten we de klik doorlopen: de speler pakt de
    108     // data-attributen op en zet de wachtrij op.
     162    // data-attributen op en zet de band op.
    109163  }
    110164}
     
    117171  // die in de leesweergave een keer de hele site heeft vastgezet.
    118172  if (tikker) { clearInterval(tikker); tikker = null; }
    119   if (gebonden) { gebonden.removeEventListener('click', opKlik, true); gebonden = null; }
     173  stopWinden(null);
     174  if (gebonden) {
     175    gebonden.removeEventListener('click', opKlik, true);
     176    gebonden.removeEventListener('pointerdown', opPointerDown, true);
     177    gebonden.removeEventListener('pointerup', opPointerUp, true);
     178    gebonden.removeEventListener('pointercancel', opPointerUp, true);
     179    gebonden = null;
     180  }
    120181  if (!tapes.length) return;
    121182
     
    125186  gebonden = document;
    126187  gebonden.addEventListener('click', opKlik, true);
     188  gebonden.addEventListener('pointerdown', opPointerDown, true);
     189  // Loslaten telt ook als je BUITEN de knop loslaat, anders blijft de band
     190  // doorspoelen als je met je vinger wegglijdt.
     191  gebonden.addEventListener('pointerup', opPointerUp, true);
     192  gebonden.addEventListener('pointercancel', opPointerUp, true);
    127193
    128194  tikker = setInterval(tekenAlles, TIK_MS);
  • src/services/AudioEmbedService.js

    r16fd4fe re679bce  
    726726  </div>
    727727` : ''}  <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>
     728    <svg class="tape-svg" viewBox="0 0 314 200" role="img" aria-label="Cassette" preserveAspectRatio="xMidYMid meet">
     729      <defs>
     730        <clipPath id="tapewin-${domId}"><rect x="72" y="96" width="170" height="62" rx="6"/></clipPath>
     731      </defs>
     732      <!-- de behuizing -->
     733      <rect class="tape-body" x="3" y="3" width="308" height="194" rx="9"/>
     734      <rect class="tape-body-inner" x="12" y="12" width="290" height="176" rx="6"/>
     735      <!-- het labelvlak: hier komt de HTML-tekst overheen te staan -->
     736      <rect class="tape-labelplate" x="24" y="22" width="266" height="62" rx="3"/>
     737      <!-- het venster waardoor je de band ziet lopen -->
     738      <rect class="tape-glass" x="72" y="96" width="170" height="62" rx="6"/>
     739      <g clip-path="url(#tapewin-${domId})">
     740        <!-- de bandpakketten om de spoelen; links loopt vol terwijl rechts leegloopt -->
     741        <circle class="tape-pack tape-pack--left" cx="112" cy="127" r="30"/>
     742        <circle class="tape-pack tape-pack--right" cx="202" cy="127" r="22"/>
     743        <rect class="tape-ribbon" x="112" y="150" width="90" height="3"/>
     744      </g>
     745      <!-- de spoelen zelf: deze twee draaien -->
     746      <g class="tape-reel tape-reel--left" style="transform-origin:112px 127px">
     747        <circle class="tape-hub" cx="112" cy="127" r="15"/>
     748        ${[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('')}
     749      </g>
     750      <g class="tape-reel tape-reel--right" style="transform-origin:202px 127px">
     751        <circle class="tape-hub" cx="202" cy="127" r="15"/>
     752        ${[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('')}
     753      </g>
     754      <!-- de schroefjes in de hoeken, en de openingen voor de kop onderin -->
     755      <circle class="tape-screw" cx="22" cy="22" r="3.5"/>
     756      <circle class="tape-screw" cx="292" cy="22" r="3.5"/>
     757      <circle class="tape-screw" cx="22" cy="178" r="3.5"/>
     758      <circle class="tape-screw" cx="292" cy="178" r="3.5"/>
     759      <rect class="tape-slot" x="128" y="168" width="26" height="14" rx="2"/>
     760      <rect class="tape-slot" x="160" y="168" width="26" height="14" rx="2"/>
     761      <rect class="tape-slot tape-slot--capstan" x="98" y="170" width="10" height="12" rx="2"/>
     762      <rect class="tape-slot tape-slot--capstan" x="206" y="170" width="10" height="12" rx="2"/>
     763    </svg>
     764    <div class="tape-label">
     765      <p class="tape-kind">Mixtape</p>
    735766      <h3 class="tape-title">${titleH}</h3>
    736767      ${artistH ? `<p class="tape-artist">${artistH}</p>` : ''}
  • src/views/shell.ejs

    r16fd4fe re679bce  
    222222
    223223<!-- v9 stylesheet (full palette system) -->
    224 <link rel="stylesheet" href="/assets/css/style.css?v=106">
     224<link rel="stylesheet" href="/assets/css/style.css?v=107">
    225225<script>
    226226/* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
     
    406406     ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
    407407     Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
    408 <script src="/assets/js/audio-player.js?v=32"></script>
     408<script src="/assets/js/audio-player.js?v=35"></script>
    409409<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte
    410410     player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
     
    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 = 52;
     507  var MOD_V = 53;
    508508
    509509  // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
  • test/mixtape-ui.test.js

    r16fd4fe re679bce  
    8686  // bestaat en sloeg zichzelf dan over -- groen zonder iets te meten.
    8787  const html = AudioEmbedService.embedPlaylistShortcodes('<p>[[playlist:tape]]</p>', (id) => (id === 'tape' ? pl : null));
    88   assert.match(html, /📼 Mixtape/, 'een mixtape hoort zichzelf zo te noemen');
     88  // Het teken zat eerst in de tekst; nu staat er een echte cassette getekend en
     89  // is het woord genoeg. De eis blijft dezelfde: hij noemt zichzelf goed.
     90  assert.match(html, /class="tape-kind">Mixtape</, 'een mixtape hoort zichzelf zo te noemen');
    8991  assert.doesNotMatch(html, /💿 Album/, 'en niet het jasje van een album te dragen');
    9092});
     
    129131  assert.doesNotMatch(html, /class="post-album"/);
    130132});
     133
     134test('de cassetteknop staat in de selectorlijst van de speler', () => {
     135  // DIT IS DE TEST DIE ONTBRAK. De markup-tests hierboven waren groen terwijl
     136  // op de knop drukken niets deed: audio-player.js kiest zijn knoppen met een
     137  // lijst met KLASSENAMEN, niet met een regel over data-attributen. Alle juiste
     138  // data-pcms-* dragen helpt dan niets.
     139  const speler = fs.readFileSync('src/assets/js/audio-player.js', 'utf8');
     140  const m = speler.match(/const PLAY_SELECTOR =\s*\n?\s*'([^']+)'/);
     141  assert.ok(m, 'PLAY_SELECTOR moet te vinden zijn -- is hij hernoemd, dan dekt deze test niets meer');
     142  assert.match(m[1], /\.tape-btn--play/,
     143    'zonder deze klasse doet de afspeelknop van het bandje niets');
     144});
     145
     146test('een bandje gaat als EEN object de speler in', () => {
     147  const speler = fs.readFileSync('src/assets/js/audio-player.js', 'utf8');
     148  // De stand moet vanaf het blok worden doorgegeven; zonder dit is een mixtape
     149  // in de speler gewoon weer een rij nummers.
     150  assert.match(speler, /asTape: album\.dataset\.pcmsAlbumKind === 'mixtape'/);
     151  // En hij moet een paginawissel overleven, anders valt de speler halverwege
     152  // terug op de nummertitel.
     153  assert.match(speler, /queue, currentIndex, albumName, tapeMode,/, 'in de sessie opslaan');
     154  assert.match(speler, /tapeMode = !!s\.tapeMode;/, 'en terugzetten');
     155  // De opgeslagen tijd is de positie BINNEN het nummer, ook in bandmodus: bij
     156  // herstellen begint de keten opnieuw. Slaat hij de bandteller op, dan springt
     157  // een hersteld bandje naar een plek die in dat nummer niet bestaat.
     158  assert.match(speler, /time: trackTijd\(\)\.cur \|\| 0,/);
     159});
     160
     161test('spoelen gaat in seconden, niet per nummer', () => {
     162  const mod = fs.readFileSync('src/assets/js/mod/tape.js', 'utf8');
     163  assert.match(mod, /seekBy\(/, 'spoelen hoort over de tijdlijn te gaan');
     164  // next()/prev() mag hier niet meer voorkomen als spoelgebaar: dat is een
     165  // playlistknop en maakt van de cassette een lijst met een plaatje.
     166  assert.doesNotMatch(mod, /p\.next\(\); else p\.prev\(\)/, 'de oude sprong per nummer');
     167});
Note: See TracChangeset for help on using the changeset viewer.