Changeset c0e463b in Klonkt


Ignore:
Timestamp:
08/22/2026 11:05:19 AM (2 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
5e72413
Parents:
4fbe2c1
Message:

Speler-UI in bandmodus: spoelknoppen, eerlijke teller, lijst zonder keuze (shaer-tmyn)

Speelt er een bandje, dan hoort de speler onderin zich als een cassettedeck te
gedragen en niet als een playlist-speler. Drie van de vier stappen uit de bead;
de vierde (seek over de hele tijdlijn) staat er los van, want die raakt
loadTrack en de keten.

  • De tracklijst blijft staan maar is geen keuzelijst: renderQueue hangt in bandmodus geen klik meer op de items, en een CSS-klasse haalt de belofte weg dat het kan. Op een cassette spring je niet naar nummer zeven.
  • Vorige/volgende worden terugspoelen/vooruitspoelen: ander teken, ander woord, en vasthouden spoelt door. Een korte klik spoelt vijf seconden, voor toetsenbord en schermlezer.
  • De spoellus is VERHUISD naar de speler. Hij stond in mod/tape.js, en met spoelknoppen op twee plekken zouden twee kopieen van dezelfde versnelling uit elkaar lopen zodra iemand aan een getal draait. De cassette in de post bedient nu dezelfde lus en tekent alleen nog het jasje.

En de teller is eerlijk geworden. Die kwam uit chain[last].end, dus uit de
lengte van wat toevallig gebufferd was: 2:05 met een nummer geladen, 4:07 met
drie, terwijl de band 6:13 duurt. Erger nog, sprong je naar nummer drie dan
begon de keten daar opnieuw op nul en stond de teller weer vooraan. Nu telt hij
bandOffset(nummer) + positie in dat nummer, uit de trackduren -- die daarvoor
in de wachtrij-JSON moesten. Ontbreekt er een duur, dan valt hij terug: een som
met gaten is een verzonnen getal.

Nagemeten op dev: totaal 6:13 meteen bij de eerste seconde (klopt met de
PT373S op het AP-object), vier knoppen op Terug-/Vooruitspoelen, drie items in
de lijst met pointer-events none en het lopende nummer gemarkeerd, en spoelen
via de spelerknop loopt door de band heen.

audio-player v38, MOD_V 55, style.css v108.

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

Files:
6 edited

Legend:

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

    r4fbe2c1 rc0e463b  
    52355235    background: color-mix(in srgb, var(--accent) 12%, transparent);
    52365236}
     5237
     5238
     5239/* ── De speler in bandmodus ────────────────────────────────────────────
     5240   Zelfde speler, ander gedrag: op een cassette kies je geen nummer. De lijst
     5241   blijft staan zodat je ziet wat erop staat, maar zonder de opmaak die belooft
     5242   dat je erin kunt prikken. */
     5243.audio-sheet-queue-list.is-tape .audio-sheet-queue-item {
     5244    cursor: default;
     5245    pointer-events: none;
     5246}
     5247.audio-sheet-queue-list.is-tape .audio-sheet-queue-item:hover {
     5248    background: none;
     5249}
     5250/* Het lopende nummer blijft wel gemarkeerd -- dat is de enige plek waar je op
     5251   een bandje leest waar je bent. */
     5252.audio-sheet-queue-list.is-tape .audio-sheet-queue-item.is-current {
     5253    opacity: 1;
     5254}
     5255/* Spoelknoppen mogen niet als "volgende" voelen: vasthouden hoort geen tekst te
     5256   selecteren of de pagina te laten zoomen. */
     5257.audio-btn.is-wind {
     5258    touch-action: manipulation;
     5259    user-select: none;
     5260}
  • src/assets/js/audio-player.js

    r4fbe2c1 rc0e463b  
    2727    vol:   '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zM16 8a5 5 0 010 8M19 5a9 9 0 010 14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
    2828    mute:  '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zM17 9l5 5M22 9l-5 5" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
     29    // Dubbele driehoeken: het teken voor spoelen, niet voor overslaan.
     30    rew:   '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 12l9-7v14zM2 12l9-7v14z" fill="currentColor"/></svg>',
     31    ff:    '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 12L4 5v14zM22 12l-9-7v14z" fill="currentColor"/></svg>',
    2932    musicNote: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 17V5l12-2v12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="17" r="3" fill="currentColor"/><circle cx="18" cy="15" r="3" fill="currentColor"/></svg>',
    3033  };
     
    128131  const prevBtn = $('audio-prev');
    129132  const nextBtn = $('audio-next');
     133  // De vier knoppen die in bandmodus van betekenis veranderen: vorige/volgende
     134  // worden terugspoelen/vooruitspoelen. Ze staan hier bij elkaar zodat het
     135  // omzetten op EEN plek gebeurt.
     136  const SPOELKNOPPEN = ['audio-prev', 'audio-next', 'audio-sheet-prev', 'audio-sheet-next'];
    130137  const muteBtn = $('audio-mute');
    131138  const volumeSlider = $('audio-volume');
     
    491498  }
    492499
     500  // De lengte van de hele band, uit de bekende trackduren. Null zodra er van
     501  // een nummer geen duur bekend is: een som met gaten is een verzonnen getal,
     502  // en dan valt de teller liever terug op wat hij wel zeker weet.
     503  function bandDuur() {
     504    if (!queue.length) return null;
     505    let som = 0;
     506    for (const t of queue) {
     507      const d = Number(t && t.duration) || 0;
     508      if (d <= 0) return null;
     509      som += d;
     510    }
     511    return som;
     512  }
     513
     514  /** Hoeveel band ligt er voor nummer `i`. */
     515  function bandOffset(i) {
     516    let som = 0;
     517    for (let n = 0; n < i && n < queue.length; n++) som += Number(queue[n].duration) || 0;
     518    return som;
     519  }
     520
    493521  function displayTimes() {
    494     // Een bandje heeft een teller, geen nummerpositie: hij telt door over de
    495     // kant heen. Dat is letterlijk de ketentijdlijn, dus hier juist NIET
    496     // terugrekenen naar trackcoordinaten.
    497     if (tapeMode && useMse() && chain.length) {
    498       return { cur: audio.currentTime || 0, dur: chain[chain.length - 1].end };
     522    // EEN BANDJE HEEFT EEN TELLER, geen nummerpositie. Hij telt door over de
     523    // kant heen.
     524    //
     525    // Uit de trackduren en niet uit de keten, en dat verschil is zichtbaar: de
     526    // keten bevat alleen wat gebufferd is, dus het totaal groeide mee tijdens
     527    // het luisteren (2:05 met een nummer geladen, 4:07 met drie). En sprong je
     528    // naar nummer drie, dan begon de keten daar opnieuw op nul en stond de
     529    // teller weer aan het begin van de band.
     530    if (tapeMode) {
     531      const totaal = bandDuur();
     532      if (totaal) return { cur: bandOffset(currentIndex) + (trackTijd().cur || 0), dur: totaal };
     533      if (useMse() && chain.length) return { cur: audio.currentTime || 0, dur: chain[chain.length - 1].end };
    499534    }
    500535    return trackTijd();
     
    621656    albumName = (opts && opts.albumName) || '';
    622657    tapeMode = !!(opts && opts.asTape);
     658    zetKnopStanden();
    623659    if (!queue.length) return;
    624660    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
     
    681717    albumName = '';
    682718    tapeMode = false;
     719    zetKnopStanden();
    683720    loadSeq++;  // cancel any in-flight load
    684721    dropPreload();
     
    700737      sheetQueueList.appendChild(li);
    701738    });
     739    // OP EEN BANDJE KIES JE NIET. De lijst blijft staan -- je mag zien wat
     740    // erop staat -- maar zonder klik en zonder de opmaak die belooft dat het
     741    // kan. Dat is hetzelfde onderscheid als bij de cassette in de post.
     742    sheetQueueList.classList.toggle('is-tape', tapeMode);
     743    if (tapeMode) return;
    702744    sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
    703745      li.addEventListener('click', () => {
     
    883925  // ============================================================
    884926  playBtn.addEventListener('click', togglePlay);
    885   prevBtn.addEventListener('click', prev);
    886   nextBtn.addEventListener('click', next);
     927  /**
     928   * De vier knoppen in de juiste stand zetten. In bandmodus zijn het
     929   * spoelknoppen: ander teken, ander woord, en vasthouden spoelt door.
     930   */
     931  function zetKnopStanden() {
     932    for (const id of SPOELKNOPPEN) {
     933      const b = $(id);
     934      if (!b) continue;
     935      const vooruit = id.endsWith('next');
     936      b.innerHTML = tapeMode ? (vooruit ? SVG.ff : SVG.rew) : (vooruit ? SVG.next : SVG.prev);
     937      const label = tapeMode ? (vooruit ? 'Vooruitspoelen' : 'Terugspoelen') : (vooruit ? 'Volgende' : 'Vorige');
     938      b.setAttribute('aria-label', label);
     939      b.setAttribute('title', label);
     940      b.classList.toggle('is-wind', tapeMode);
     941    }
     942  }
     943
     944  // Een KLIK: een nummer verder, of in bandmodus een stukje spoelen. Dat laatste
     945  // is er voor toetsenbord en schermlezer, want vasthouden is met een
     946  // spatiebalk geen gebaar.
     947  const TIK_SPOEL_S = 5;
     948  prevBtn.addEventListener('click', () => (tapeMode ? seekBy(-TIK_SPOEL_S) : prev()));
     949  nextBtn.addEventListener('click', () => (tapeMode ? seekBy(TIK_SPOEL_S) : next()));
    887950  sheetPlay.addEventListener('click', togglePlay);
    888   sheetPrev.addEventListener('click', prev);
    889   sheetNext.addEventListener('click', next);
     951  sheetPrev.addEventListener('click', () => (tapeMode ? seekBy(-TIK_SPOEL_S) : prev()));
     952  sheetNext.addEventListener('click', () => (tapeMode ? seekBy(TIK_SPOEL_S) : next()));
     953
     954  // VASTHOUDEN spoelt door. Op de knop zelf indrukken, maar loslaten op
     955  // document: glijdt je vinger van de knop af, dan hoort de band te stoppen en
     956  // niet door te blijven spoelen.
     957  for (const id of SPOELKNOPPEN) {
     958    const b = $(id);
     959    if (!b) continue;
     960    b.addEventListener('pointerdown', () => { if (tapeMode) startWind(id.endsWith('next') ? 1 : -1); });
     961  }
     962  document.addEventListener('pointerup', stopWind);
     963  document.addEventListener('pointercancel', stopWind);
    890964
    891965  // ============================================================
     
    11991273  }
    12001274
     1275  /**
     1276   * SPOELEN, met de knop ingedrukt.
     1277   *
     1278   * Stond eerst in assets/js/mod/tape.js, want daar zat de cassette. Nu de
     1279   * speler zelf ook spoelknoppen krijgt zou dezelfde lus op twee plekken staan,
     1280   * en dan lopen ze uit elkaar zodra iemand aan de versnelling draait. Hier is
     1281   * de enige plek; de cassette op de pagina roept deze aan.
     1282   */
     1283  const WIND_START = 4;        // maal de normale snelheid bij het indrukken
     1284  const WIND_MAX = 16;
     1285  const WIND_VERSNELLING = 1.35;
     1286  const WIND_STAP_MS = 120;
     1287  let winder = null;
     1288
     1289  function stopWind() {
     1290    if (winder) { clearInterval(winder.timer); winder = null; }
     1291  }
     1292
     1293  function startWind(richting) {
     1294    stopWind();
     1295    const r = richting < 0 ? -1 : 1;
     1296    let snelheid = WIND_START;
     1297    let vorige = null;
     1298    let stil = 0;
     1299    const timer = setInterval(() => {
     1300      snelheid = Math.min(WIND_MAX, snelheid * WIND_VERSNELLING);
     1301      seekBy(r * snelheid * (WIND_STAP_MS / 1000));
     1302      // AAN DE KOP EN DE STAART STOPPEN. De positie wordt afgeklemd, dus daar
     1303      // gebeurt niets meer -- maar zonder deze controle blijft de knop malen en
     1304      // draaien de spoelen door alsof er nog band is.
     1305      const nu = displayTimes().cur;
     1306      if (vorige !== null && Math.abs(nu - vorige) < 0.05) {
     1307        if (++stil >= 2) { stopWind(); return; }
     1308      } else stil = 0;
     1309      vorige = nu;
     1310    }, WIND_STAP_MS);
     1311    winder = { timer, richting: r };
     1312  }
     1313
    12011314  /** Waar staat de teller, over het hele bandje. */
    12021315  function tapeTijden() { return displayTimes(); }
     
    12041317  window.pcmsAudioPlayer = {
    12051318    setQueue, play, pause, next, prev, close, openSheet, closeSheet, seekBy, tapeTijden,
     1319    startWind, stopWind,
    12061320    isTape: () => tapeMode,
     1321    isWinding: () => !!winder,
    12071322    isPlaying: () => isPlaying,
    12081323    currentTrack: () => queue[currentIndex] || null,
     
    12521367    albumName = s.albumName || '';
    12531368    tapeMode = !!s.tapeMode;
     1369    zetKnopStanden();
    12541370    pendingSeek = s.time || 0;
    12551371    const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
  • src/assets/js/mod/tape.js

    r4fbe2c1 rc0e463b  
    1414 * die tijdlijn. Vandaar seekBy() en niet next()/prev() -- springen per nummer
    1515 * is een playlistgebaar, en dan is dit een lijst met een cassetteplaatje.
     16 *
     17 * DE SPOELLUS STAAT IN DE SPELER, niet hier: sinds de speler onderin dezelfde
     18 * spoelknoppen heeft zou dezelfde versnelling op twee plekken staan. Deze
     19 * module bedient hem en tekent het jasje.
    1620 *
    1721 * WAAROM ER GEPOLLD WORDT. audio-player.js zendt geen gebeurtenissen uit -- het
     
    5660  tape.classList.toggle('is-playing', draait);
    5761  tape.classList.toggle('is-loaded', i >= 0);
     62  // Spoelt de SPELER (bijvoorbeeld via zijn eigen knoppen onderin), dan hoort
     63  // deze cassette dat ook te laten zien.
     64  const windt = i >= 0 && !!p && p.isWinding && p.isWinding();
     65  if (!windt) tape.classList.remove('is-winding', 'is-winding-back');
     66  else tape.classList.add('is-winding');
    5867
    5968  const knop = tape.querySelector('[data-tape-play]');
     
    91100 * schermlezer, want vasthouden is met een spatiebalk geen gebaar.
    92101 */
    93 const START_SNELHEID = 4;    // maal de normale snelheid bij het indrukken
    94 const MAX_SNELHEID = 16;
    95 const VERSNELLING = 1.35;    // per stap
    96 const STAP_MS = 120;
    97102const TIK_STAP_S = 5;        // een losse klik
    98103
    99 let winder = null;
    100 
    101104function stopWinden(tape) {
    102   if (winder) { clearInterval(winder.timer); winder = null; }
     105  const p = speler();
     106  if (p && p.stopWind) p.stopWind();
    103107  if (tape) tape.classList.remove('is-winding', 'is-winding-back');
    104108}
     
    106110function startWinden(tape, richting) {
    107111  const p = speler();
    108   if (!p || huidigeIndex(tape) < 0) return;
    109   stopWinden(tape);
    110   let snelheid = START_SNELHEID;
    111   let vorigePositie = null;
    112   let stil = 0;
     112  if (!p || !p.startWind || huidigeIndex(tape) < 0) return;
     113  // DE LUS STAAT IN DE SPELER. Hij stond hier, en toen kreeg de speler zelf ook
     114  // spoelknoppen -- twee kopieen van dezelfde versnelling die uit elkaar lopen
     115  // zodra iemand aan een getal draait. Deze module zet alleen nog het jasje:
     116  // welke spoelen draaien en hoe hard.
     117  p.startWind(richting);
    113118  tape.classList.add('is-winding');
    114119  if (richting < 0) tape.classList.add('is-winding-back');
    115   const timer = setInterval(() => {
    116     snelheid = Math.min(MAX_SNELHEID, snelheid * VERSNELLING);
    117     p.seekBy(richting * snelheid * (STAP_MS / 1000));
    118     tekenBand(tape);
    119     // AAN HET EIND VAN DE BAND STOPPEN MET SPOELEN. De speler klemt de positie
    120     // af, dus daar gebeurt niets meer -- maar de spoelen bleven wel doordraaien
    121     // en de knop bleef oplichten, alsof er nog band was. Beweegt de teller twee
    122     // rondes niet, dan zijn we bij de kop of de staart.
    123     const nu = p.tapeTijden ? p.tapeTijden().cur : null;
    124     if (nu !== null && vorigePositie !== null && Math.abs(nu - vorigePositie) < 0.05) {
    125       if (++stil >= 2) { stopWinden(tape); return; }
    126     } else stil = 0;
    127     vorigePositie = nu;
    128   }, STAP_MS);
    129   winder = { timer, tape };
    130120}
    131121
  • src/services/AudioEmbedService.js

    r4fbe2c1 rc0e463b  
    562562        artist: t.artist || pl.artist || '',
    563563        cover:  t.cover  || pl.cover  || '',
     564        // De duur gaat mee omdat een BANDJE een lengte heeft. Zonder dit kan de
     565        // speler alleen de gebufferde keten optellen, en dan las de teller
     566        // 2:05 met een nummer geladen en 4:07 met drie -- een totaal dat
     567        // meegroeit terwijl je luistert.
     568        duration: Number(t.duration) || 0,
    564569      }));
    565570      const albumJson = JSON.stringify(tracksData)
  • src/views/shell.ejs

    r4fbe2c1 rc0e463b  
    222222
    223223<!-- v9 stylesheet (full palette system) -->
    224 <link rel="stylesheet" href="/assets/css/style.css?v=107">
     224<link rel="stylesheet" href="/assets/css/style.css?v=108">
    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=37"></script>
     408<script src="/assets/js/audio-player.js?v=38"></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 = 54;
     507  var MOD_V = 55;
    508508
    509509  // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
  • test/mixtape-ui.test.js

    r4fbe2c1 rc0e463b  
    210210  assert.match(speler, /if \(\(queue\.length === 1 \|\| laatsteVanDeBand\) && ms && ms\.readyState === 'open'\)/);
    211211});
     212
     213test('de speler-UI schakelt mee in bandmodus (shaer-tmyn)', () => {
     214  const speler = fs.readFileSync('src/assets/js/audio-player.js', 'utf8');
     215
     216  // 1. De tracklijst blijft staan maar is geen keuzelijst meer.
     217  assert.match(speler, /sheetQueueList\.classList\.toggle\('is-tape', tapeMode\);\s*\n\s*if \(tapeMode\) return;/,
     218    'zonder deze return kun je op een cassette alsnog naar nummer zeven springen');
     219
     220  // 2. Vorige/volgende worden terugspoelen/vooruitspoelen, op EEN plek geregeld.
     221  assert.match(speler, /function zetKnopStanden\(\)/);
     222  assert.match(speler, /'Vooruitspoelen' : 'Terugspoelen'/);
     223  // en vasthouden spoelt door
     224  assert.match(speler, /addEventListener\('pointerdown', \(\) => \{ if \(tapeMode\) startWind/);
     225
     226  // 3. De spoellus staat in de speler, niet meer in de module -- anders lopen
     227  //    twee kopieen van dezelfde versnelling uit elkaar.
     228  assert.match(speler, /function startWind\(richting\)/);
     229  const mod = fs.readFileSync('src/assets/js/mod/tape.js', 'utf8');
     230  assert.doesNotMatch(mod, /VERSNELLING/, 'de versnelling hoort niet meer in de module te staan');
     231  assert.match(mod, /p\.startWind\(richting\)/, 'de cassette bedient de gedeelde lus');
     232});
     233
     234test('de bandteller komt uit de trackduren, niet uit de buffer', () => {
     235  const speler = fs.readFileSync('src/assets/js/audio-player.js', 'utf8');
     236  assert.match(speler, /function bandDuur\(\)/);
     237  assert.match(speler, /function bandOffset\(i\)/);
     238  // Een som met gaten is een verzonnen getal: ontbreekt er een duur, dan valt
     239  // hij terug in plaats van te gokken.
     240  assert.match(speler, /if \(d <= 0\) return null;/);
     241
     242  // En de duur moet mee in de wachtrij, anders kan de speler het niet weten.
     243  const embed = fs.readFileSync('src/services/AudioEmbedService.js', 'utf8');
     244  assert.match(embed, /duration: Number\(t\.duration\) \|\| 0,/,
     245    'zonder dit las de teller de lengte van wat toevallig gebufferd was');
     246});
Note: See TracChangeset for help on using the changeset viewer.