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


Ignore:
Timestamp:
05/20/2026 10:14:01 PM (4 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
353c39c
Parents:
46f23fd
git-author:
Robin Genis <roboburr@…> (05/20/2026 10:13:26 PM)
git-committer:
Robin Genis <roboburr@…> (05/20/2026 10:14:01 PM)
Message:

audio: Spotify-style blob playback + same-origin gate (fix playback loop)

Root cause of the "next-loops-but-never-plays after 4-5 songs" bug: every
track URL was HMAC-signed once at page-render time with a 10-min TTL. A whole
queue shared that single deadline, so tracks further down expired mid-session
-> /audio/stream returned 403 -> audio 'error' -> auto-skip -> next track also
expired -> infinite loop. The 3-strike guard never fired because the eager
'play' event reset the counter before each 403 landed.

Removed the expiring-token system entirely and replaced it with two
non-expiring layers:

  • Client fetch()es track bytes and plays from a blob: object URL (no shareable URL, no "save audio as"); blobs revoked to avoid leaks; loadSeq guards fast prev/next; pre-seed is metadata-only (no auto-download).
  • Server gates /audio/stream to same-origin browser fetches (X-Audio-Player header or Sec-Fetch-Site): blocks address-bar paste, hotlinks, curl.

Also: reset error counter on real 'playing' event (not eager 'play') so the
3-strike auto-skip-stop actually works; fix admin play-state detection to
compare logical currentTrack().url instead of the now-blob: audio.src; bump
audio-player.js cache-buster v5.

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

File:
1 edited

Legend:

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

    r46f23fd r21522ae  
    158158  let isPlaying = false;
    159159  let albumName = '';
     160  // Blob playback state. We fetch each track's bytes and play from a blob:
     161  // object URL — no plain media URL is ever exposed to the page. currentObjectUrl
     162  // is revoked when we move on, so we don't leak one Blob per track in memory.
     163  let currentObjectUrl = null;
     164  // Monotonic load token: a fast prev/next can fire several loads before an
     165  // earlier fetch resolves. Only the latest load may set audio.src.
     166  let loadSeq = 0;
    160167
    161168  // Hide initially
     
    177184  }
    178185
    179   function loadTrack(index) {
     186  // Fetch the track bytes and hand back a blob: object URL. The X-Audio-Player
     187  // 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);
     196  }
     197
     198  // metaOnly: show the track in the UI but DON'T download its bytes yet.
     199  // Used by the site pre-seed so opening a page doesn't auto-download audio;
     200  // the blob is fetched lazily on the first play().
     201  function loadTrack(index, autoplay, metaOnly) {
    180202    if (!queue[index]) {
    181203      console.warn('[pcms-audio] loadTrack: no track at index', index);
     
    188210      return;
    189211    }
    190     console.log('[pcms-audio] loading', t.title, t.url);
    191     // Schone overgang: pause + reset voorkomt state-corruption van het
    192     // audio-element na meerdere src-changes (bug die continuous playback
    193     // brak na 3-4 tracks). audio.load() forceert reset van internal state.
    194     try { audio.pause(); } catch (e) {}
    195     audio.src = t.url;
    196     try { audio.load(); } catch (e) {}
     212    console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : '');
     213    // Metadata + chrome update synchronously so the UI reacts instantly while
     214    // the bytes download.
    197215    titleEl.textContent  = t.title  || 'Untitled';
    198216    artistEl.textContent = t.artist || '';
     
    205223    document.body.classList.add('has-audio-player');
    206224    renderQueue();
     225
     226    if (metaOnly) return;
     227
     228    const mySeq = ++loadSeq;
     229    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    });
    207251  }
    208252
     
    211255    albumName = (opts && opts.albumName) || '';
    212256    if (!queue.length) return;
    213     loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0);
    214     play();
     257    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
    215258  }
    216259
    217260  function play() {
    218     if (!audio.src) return;
     261    if (!audio.src) {
     262      // Nothing fetched yet (pre-seed showed metadata only, or a load is still
     263      // in flight). Kick off the blob load for the current track and autoplay.
     264      if (queue[currentIndex]) loadTrack(currentIndex, true);
     265      return;
     266    }
    219267    const p = audio.play();
    220268    if (p && typeof p.catch === 'function') {
     
    236284  function next() {
    237285    if (!queue.length) return;
    238     loadTrack((currentIndex + 1) % queue.length);
    239     play();
     286    loadTrack((currentIndex + 1) % queue.length, true);
    240287  }
    241288  function prev() {
    242289    if (!queue.length) return;
    243     loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1);
    244     play();
     290    loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
    245291  }
    246292  function close() {
     
    251297    queue = [];
    252298    albumName = '';
     299    loadSeq++;  // cancel any in-flight load
     300    if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
     301    currentObjectUrl = null;
     302    try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
    253303  }
    254304
     
    268318        const idx = parseInt(li.dataset.idx, 10);
    269319        if (!isNaN(idx) && idx !== currentIndex) {
    270           loadTrack(idx);
    271           play();
     320          loadTrack(idx, true);
    272321        }
    273322      });
     
    288337  audio.addEventListener('play',  () => {
    289338    isPlaying = true;
    290     consecutiveErrors = 0;  // reset bij succesvolle play
    291339    root.classList.add('is-playing');
    292340    root.classList.remove('audio-needs-tap');  // verstop tap-hint
    293341  });
     342  // Reset de error-teller pas bij ECHTE playback-start (`playing`), niet bij
     343  // het eager `play`-event. `play` vuurt vóór een eventuele netwerk-/decode-
     344  // fout, dus resetten daar zou de 3-strikes-stop nooit laten triggeren bij
     345  // een kapotte track → infinite "next"-loop. `playing` vuurt alleen als er
     346  // daadwerkelijk audio speelt.
     347  audio.addEventListener('playing', () => { consecutiveErrors = 0; });
    294348  audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
    295349  audio.addEventListener('ended', next);
     
    539593      cover:  t.cover_url || t.cover || '',
    540594    }));
    541     if (queue.length) loadTrack(0);
     595    if (queue.length) loadTrack(0, false, true);  // metadata only — fetch on first play
    542596  }
    543597})();
Note: See TracChangeset for help on using the changeset viewer.