Changeset b5bae24 in Klonkt for src/assets/js/audio-player.js


Ignore:
Timestamp:
06/14/2026 01:25:19 AM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
9b36f45
Parents:
c88783e
Message:

reconcile: commit uncommitted live /srv state (playback fix + prefetch, SF favicon, smooth scroll, scripts)

The live working tree /srv/prutfolio was ahead of the bare repo with direct
server edits that had never been committed back. The deploy hook does
checkout -f main, so the next deploy would have reverted the 3 modified
tracked files to f33db01 and lost this work:

  • audio-player.js: robust playback — retry+backoff on network hiccups (no longer skipping immediately) + next-track prefetch (downloads the next track while the current one plays -> ended->next swaps instantly, covering the autoplay lapse). Fix for the sometimes-next-doesn't-play bug.
  • server.js: favicon mark p -> SF (SoundFabrics rebrand). shell.ejs: audio-player.js cache buster ?v=5 -> ?v=6 + favicon ?v=sf.
  • Plus previously untracked project files committed: lenis.min.js + smooth-scroll.js (not yet wired), scripts/ (v9 import/migration), deploy/ docs (DEPLOY.md/backup.sh/nginx/verify.ps1), .well-known/assetlinks.json. audio-player.js.bak.20260614 deliberately NOT committed.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/assets/js/audio-player.js

    rc88783e rb5bae24  
    165165  // earlier fetch resolves. Only the latest load may set audio.src.
    166166  let loadSeq = 0;
     167  // Next-track prefetch. While the current track plays we download the *next*
     168  // track's bytes into a held blob, so `ended` → next() can swap src instantly
     169  // (no silent gap, and no long async window where the browser's autoplay
     170  // activation can lapse and reject play()). At most one track ahead is held.
     171  // Shape: { url, objUrl }  — objUrl is null while the fetch is still in flight.
     172  let preload = null;
    167173
    168174  // Hide initially
     
    186192  // Fetch the track bytes and hand back a blob: object URL. The X-Audio-Player
    187193  // header + same-origin credentials get us past the stream route's access gate.
    188   async function fetchAsObjectUrl(url) {
    189     const r = await fetch(url, {
    190       credentials: 'same-origin',
    191       headers: { 'X-Audio-Player': '1' },
    192     });
    193     if (!r.ok) throw new Error('HTTP ' + r.status);
    194     const blob = await r.blob();
    195     return URL.createObjectURL(blob);
     194  // Retries a few times with backoff: a single transient network blip used to
     195  // bump the error counter and SKIP the song (auto-advance past it). Now one
     196  // hiccup just costs a retry, and we only give up after genuinely failing.
     197  async function fetchAsObjectUrl(url, attempts) {
     198    attempts = attempts || 1;
     199    let lastErr;
     200    for (let i = 0; i < attempts; i++) {
     201      try {
     202        const r = await fetch(url, {
     203          credentials: 'same-origin',
     204          headers: { 'X-Audio-Player': '1' },
     205        });
     206        if (!r.ok) throw new Error('HTTP ' + r.status);
     207        const blob = await r.blob();
     208        return URL.createObjectURL(blob);
     209      } catch (e) {
     210        lastErr = e;
     211        if (i < attempts - 1) {
     212          await new Promise((res) => setTimeout(res, 350 * (i + 1)));
     213        }
     214      }
     215    }
     216    throw lastErr;
     217  }
     218
     219  // Apply a ready blob URL to the <audio> element. Single source of truth for
     220  // "swap the playing source": used by both the cached-preload path and the
     221  // fresh-fetch path so there's one place that touches audio.src.
     222  function applyBlob(objUrl, autoplay, mySeq) {
     223    if (mySeq !== loadSeq) { try { URL.revokeObjectURL(objUrl); } catch (e) {} return; }  // superseded
     224    root.classList.remove('audio-loading');
     225    // Free the previously-playing track's blob — otherwise each track leaks a
     226    // copy. Never the same handle as objUrl (createObjectURL is unique), so this
     227    // can't revoke the source we're about to play.
     228    if (currentObjectUrl && currentObjectUrl !== objUrl) {
     229      try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
     230    }
     231    currentObjectUrl = objUrl;
     232    // Schone overgang: pause + load forceert reset van internal state na
     233    // meerdere src-changes (voorkomt state-corruption van het audio-element).
     234    try { audio.pause(); } catch (e) {}
     235    audio.src = objUrl;
     236    try { audio.load(); } catch (e) {}
     237    if (autoplay) play();
     238  }
     239
     240  function onLoadError(err, mySeq) {
     241    if (mySeq !== loadSeq) return;  // superseded — ignore stale failure
     242    root.classList.remove('audio-loading');
     243    console.error('[pcms-audio] track load failed after retries', err);
     244    // Genuine failure (after retries): bump the counter and auto-skip, but stop
     245    // after 3 in a row so a fully-broken queue can't loop "next" forever.
     246    consecutiveErrors++;
     247    if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
     248  }
     249
     250  // Discard any held/in-flight preload and free its blob if resolved.
     251  function dropPreload() {
     252    if (preload && preload.objUrl) { try { URL.revokeObjectURL(preload.objUrl); } catch (e) {} }
     253    preload = null;
     254  }
     255
     256  // Prefetch the *next* track's bytes in the background. Idempotent: re-calling
     257  // while the same track is already cached / in flight is a no-op. Called from
     258  // the `playing` event so the network is otherwise idle.
     259  function preloadNext() {
     260    if (queue.length < 2) return;
     261    const ni = (currentIndex + 1) % queue.length;
     262    const t = queue[ni];
     263    if (!t || !t.url) return;
     264    if (preload && preload.url === t.url) return;  // already held or in flight
     265    dropPreload();                                  // different track queued before → free it
     266    const marker = { url: t.url, objUrl: null };
     267    preload = marker;
     268    fetchAsObjectUrl(t.url, 2).then((obj) => {
     269      // Only keep it if this is still the track we want next; otherwise free it.
     270      if (preload === marker) { marker.objUrl = obj; }
     271      else { try { URL.revokeObjectURL(obj); } catch (e) {} }
     272    }).catch(() => { if (preload === marker) preload = null; });
    196273  }
    197274
     
    227304
    228305    const mySeq = ++loadSeq;
     306
     307    // Fast path: the bytes for this exact track were already prefetched while
     308    // the previous track played → swap in instantly, no gap, no fetch window.
     309    if (preload && preload.url === t.url && preload.objUrl) {
     310      const obj = preload.objUrl;
     311      preload = null;  // ownership moves to applyBlob (becomes currentObjectUrl)
     312      applyBlob(obj, autoplay, mySeq);
     313      return;
     314    }
     315
     316    // Not preloaded (or preload still in flight) → drop any stale preload and
     317    // fetch fresh, retrying transient failures before giving up.
     318    dropPreload();
    229319    root.classList.add('audio-loading');
    230     fetchAsObjectUrl(t.url).then((objUrl) => {
    231       if (mySeq !== loadSeq) { URL.revokeObjectURL(objUrl); return; }  // superseded
    232       root.classList.remove('audio-loading');
    233       // Free the previous track's blob — otherwise every track leaks a copy.
    234       if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
    235       currentObjectUrl = objUrl;
    236       // Schone overgang: pause + load forceert reset van internal state na
    237       // meerdere src-changes (voorkomt state-corruption van het audio-element).
    238       try { audio.pause(); } catch (e) {}
    239       audio.src = objUrl;
    240       try { audio.load(); } catch (e) {}
    241       if (autoplay) play();
    242     }).catch((err) => {
    243       if (mySeq !== loadSeq) return;  // superseded — ignore stale failure
    244       root.classList.remove('audio-loading');
    245       console.error('[pcms-audio] track fetch failed', err);
    246       // Treat a failed download like a playback error: bump the counter and
    247       // auto-skip, but stop after 3 in a row so we never loop forever.
    248       consecutiveErrors++;
    249       if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
    250     });
     320    fetchAsObjectUrl(t.url, 3)
     321      .then((objUrl) => applyBlob(objUrl, autoplay, mySeq))
     322      .catch((err) => onLoadError(err, mySeq));
    251323  }
    252324
     
    298370    albumName = '';
    299371    loadSeq++;  // cancel any in-flight load
     372    dropPreload();  // free any prefetched next-track blob
    300373    if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
    301374    currentObjectUrl = null;
     
    345418  // een kapotte track → infinite "next"-loop. `playing` vuurt alleen als er
    346419  // daadwerkelijk audio speelt.
    347   audio.addEventListener('playing', () => { consecutiveErrors = 0; });
     420  audio.addEventListener('playing', () => { consecutiveErrors = 0; preloadNext(); });
    348421  audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
    349422  audio.addEventListener('ended', next);
Note: See TracChangeset for help on using the changeset viewer.