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


Ignore:
Timestamp:
07/02/2026 01:21:34 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
d039264
Parents:
a369029
Message:

fix(audio): gapless MSE playback - background auto-advance survives on Android

Root cause of "next track plays 1 second, then pauses and the media
notification closes" on backgrounded mobile Chrome/PWA: every track
change did pause() + audio.src=<new blob> + load() + play(), which the
browser treats as a NEW playback session - and Chrome's background media
policy pauses new sessions started in the background. Only a CONTINUING
session may keep playing.

The player now has two engines:

  • MSE chain (Chrome/Firefox/Android): one MediaSource + one 'audio/mpeg' SourceBuffer (every track is uniform transcoder mp3). The next track's bytes are appended into the same buffer, so the whole queue is one continuous playback session; auto-advance is just the timeline flowing past a segment boundary - no pause/src/load/play at all. Track chrome, per-track time display, seek, lock-screen position state and session save/restore all work in track coordinates via a segment table. Played data is pruned (~2 tracks buffered), ID3 tags are stripped between appends, QuotaExceeded evicts and retries.
  • Blob fallback (iOS Safari - no MSE; or runtime MSE failure): the previous per-track objectURL behaviour, now fed from shared byte fetches. An audio error while the MSE chain is active permanently falls back to blobs for the session and retries the same track.

Manual actions (play/next/prev/queue click/restore) start a fresh chain -
those happen in the foreground where a new session is allowed.

Verified in-browser (desktop Chrome, two 8s test tones): auto-advance
crosses the boundary with ZERO pause/play events (old engine: one pair
per track), per-track time display correct, manual next resets the
chain, in-track seek works, queue wrap-around is gapless, no console
errors.

  • src/assets/js/audio-player.js - the two-engine pipeline (fetchTrackBytes, MSE chain: chainStart/appendSegment/ensureNextAppended/pruneBuffer/ maybeCrossBoundary/displayTimes; applyBlobBytes fallback; updateTrackChrome extraction; MediaSession positionState)
  • src/views/shell.ejs - cache-buster v31 -> v32
  • CHANGELOG(.nl/.de).md - user-facing entry under Unreleased (3 languages)

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

File:
1 edited

Legend:

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

    ra369029 r421046c  
    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.
     160  // Playback pipeline. We fetch each track's bytes ourselves (X-Audio-Player
     161  // gate; no plain media URL is ever exposed to the page) and feed them to the
     162  // <audio> element through one of two engines:
     163  //
     164  //  1. MSE chain (Chrome/Firefox/Android): ONE MediaSource + SourceBuffer
     165  //     ('audio/mpeg', sequence mode — every track is uniform transcoder mp3).
     166  //     The next track's bytes are APPENDED into the same buffer, so the whole
     167  //     queue is one continuous playback session. That is what keeps a
     168  //     backgrounded tab/PWA playing across track changes: Chrome's background
     169  //     media policy pauses NEW playback sessions started in the background
     170  //     (the old per-track src-swap + load() + play()), but never interrupts a
     171  //     continuing one. A track change becomes a timeline position, not a swap.
     172  //  2. Blob fallback (iOS Safari — no MSE; or MSE failed at runtime): one
     173  //     objectURL per track, the previous behaviour.
    163174  let currentObjectUrl = null;
    164175  // 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.
     176  // earlier fetch resolves. Only the latest load may touch the audio pipeline.
    166177  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.
     178  // Next-track prefetch (blob engine; the MSE engine appends ahead instead).
     179  // Shape: { url, bytes } — bytes is null while the fetch is still in flight.
    172180  let preload = null;
     181  // ── MSE chain state ──
     182  const MSE_SUPPORTED = !!(window.MediaSource && MediaSource.isTypeSupported && MediaSource.isTypeSupported('audio/mpeg'));
     183  let mseFailed = false;               // runtime bail → blob engine for the rest of this session
     184  const useMse = () => MSE_SUPPORTED && !mseFailed;
     185  let ms = null;                       // MediaSource
     186  let sb = null;                       // SourceBuffer
     187  let chain = [];                      // appended segments: { qIndex, start, end } (timeline seconds)
     188  let chainFetching = false;           // a fetch+append for the NEXT track is in flight
     189  let sbOps = Promise.resolve();       // serializes SourceBuffer operations
    173190
    174191  // Hide initially
     
    190207  }
    191208
    192   // Fetch the track bytes and hand back a blob: object URL. The X-Audio-Player
    193   // header + same-origin credentials get us past the stream route's access gate.
     209  // Fetch the track bytes (ArrayBuffer). The X-Audio-Player header +
     210  // same-origin credentials get us past the stream route's access gate.
    194211  // Retries a few times with backoff: a single transient network blip used to
    195212  // bump the error counter and SKIP the song (auto-advance past it). Now one
    196213  // hiccup just costs a retry, and we only give up after genuinely failing.
    197   async function fetchAsObjectUrl(url, attempts) {
     214  async function fetchTrackBytes(url, attempts) {
    198215    attempts = attempts || 1;
    199216    let lastErr;
     
    205222        });
    206223        if (!r.ok) throw new Error('HTTP ' + r.status);
    207         const blob = await r.blob();
    208         return URL.createObjectURL(blob);
     224        return await r.arrayBuffer();
    209225      } catch (e) {
    210226        lastErr = e;
     
    217233  }
    218234
    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
     235  // Blob fallback engine: wrap the bytes in an objectURL and swap audio.src.
     236  function applyBlobBytes(bytes, autoplay, mySeq) {
     237    if (mySeq !== loadSeq) return;  // superseded
     238    const objUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' }));
    224239    root.classList.remove('audio-loading');
    225240    // Free the previously-playing track's blob — otherwise each track leaks a
     
    248263  }
    249264
    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   }
     265  // Discard any held/in-flight preload (plain bytes now — GC handles them).
     266  function dropPreload() { preload = null; }
    255267
    256268  // Prefetch the *next* track's bytes in the background. Idempotent: re-calling
    257269  // 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.
     270  // the `playing` event so the network is otherwise idle. Blob engine only —
     271  // the MSE engine "preloads" by appending ahead (ensureNextAppended).
    259272  function preloadNext() {
    260273    if (queue.length < 2) return;
     
    263276    if (!t || !t.url) return;
    264277    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 };
     278    const marker = { url: t.url, bytes: null };
    267279    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) {} }
     280    fetchTrackBytes(t.url, 2).then((bytes) => {
     281      // Only keep it if this is still the track we want next.
     282      if (preload === marker) marker.bytes = bytes;
    272283    }).catch(() => { if (preload === marker) preload = null; });
     284  }
     285
     286  // ============================================================
     287  // 4a. MSE chain engine — one continuous playback session
     288  // ============================================================
     289  // All tracks are uniform transcoder mp3 (192kbps), so raw frames can be
     290  // appended back-to-back into a single 'audio/mpeg' SourceBuffer (its
     291  // byte-stream format generates continuous timestamps — sequence mode).
     292  // Auto-advance = playback simply flowing into the next track's region.
     293
     294  // Strip ID3v2 (leading) / ID3v1 (trailing) tags: tag bytes between two
     295  // appended tracks would glitch the MPEG frame parser.
     296  function stripId3(buf) {
     297    const u8 = new Uint8Array(buf);
     298    let start = 0, end = u8.length;
     299    if (end > 10 && u8[0] === 0x49 && u8[1] === 0x44 && u8[2] === 0x33) {  // "ID3"
     300      const size = ((u8[6] & 0x7f) << 21) | ((u8[7] & 0x7f) << 14) | ((u8[8] & 0x7f) << 7) | (u8[9] & 0x7f);
     301      const skip = 10 + size + ((u8[5] & 0x10) ? 10 : 0);  // +10 when a footer is flagged
     302      if (skip < end) start = skip;
     303    }
     304    if (end - start > 128 && u8[end - 128] === 0x54 && u8[end - 127] === 0x41 && u8[end - 126] === 0x47) end -= 128;  // "TAG"
     305    return (start === 0 && end === u8.length) ? buf : buf.slice(start, end);
     306  }
     307
     308  function teardownChain() {
     309    chain = [];
     310    chainFetching = false;
     311    sbOps = Promise.resolve();
     312    sb = null;
     313    ms = null;
     314  }
     315
     316  // Serialize a SourceBuffer operation (append/remove): they throw if issued
     317  // while the buffer is still updating, so everything funnels through a queue.
     318  function sbRun(fn) {
     319    const run = () => new Promise((resolve, reject) => {
     320      if (!sb || !ms || ms.readyState !== 'open') return resolve();
     321      const ok  = () => { cleanup(); resolve(); };
     322      const err = (e) => { cleanup(); reject(e); };
     323      function cleanup() { sb.removeEventListener('updateend', ok); sb.removeEventListener('error', err); }
     324      sb.addEventListener('updateend', ok);
     325      sb.addEventListener('error', err);
     326      try { fn(); } catch (e) { cleanup(); reject(e); }
     327    });
     328    const p = sbOps.then(run, run);
     329    sbOps = p.catch(() => {});
     330    return p;
     331  }
     332
     333  // Append one track's bytes as the next segment of the chain.
     334  async function appendSegment(qIndex, bytes, mySeq) {
     335    const clean = stripId3(bytes);
     336    try {
     337      await sbRun(() => sb.appendBuffer(clean));
     338    } catch (e) {
     339      if (e && e.name === 'QuotaExceededError' && chain.length > 1) {
     340        // Evict already-played data and retry once.
     341        const seg = currentSegment();
     342        if (seg && seg.start > 1) {
     343          await sbRun(() => sb.remove(0, seg.start - 0.5));
     344          chain = chain.filter((s) => s.end > seg.start - 0.5);
     345        }
     346        await sbRun(() => sb.appendBuffer(clean));
     347      } else {
     348        throw e;
     349      }
     350    }
     351    if (mySeq !== loadSeq || !sb) return;
     352    const buffered = sb.buffered;
     353    const chainEnd = buffered.length ? buffered.end(buffered.length - 1) : 0;
     354    const start = chain.length ? chain[chain.length - 1].end : (buffered.length ? buffered.start(0) : 0);
     355    chain.push({ qIndex, start, end: chainEnd });
     356    if (queue.length === 1 && ms && ms.readyState === 'open') {
     357      // Single-track queue: close the stream so `ended` fires (which replays
     358      // it, matching the old engine's behaviour).
     359      try { ms.endOfStream(); } catch (e) {}
     360    }
     361  }
     362
     363  // Keep exactly one full track appended ahead of the one playing.
     364  function ensureNextAppended() {
     365    if (!useMse() || !sb || !ms || ms.readyState !== 'open' || chainFetching) return;
     366    if (queue.length < 2 || !chain.length) return;
     367    const seg = currentSegment();
     368    if (!seg || chain.length - 1 - chain.indexOf(seg) >= 1) return;  // already one ahead
     369    const nextIdx = (chain[chain.length - 1].qIndex + 1) % queue.length;
     370    const t = queue[nextIdx];
     371    if (!t || !t.url) return;
     372    chainFetching = true;
     373    const mySeq = loadSeq;
     374    const bytesP = (preload && preload.url === t.url && preload.bytes)
     375      ? Promise.resolve(preload.bytes)
     376      : fetchTrackBytes(t.url, 2);
     377    bytesP.then((bytes) => {
     378      if (mySeq !== loadSeq) return;
     379      if (preload && preload.url === t.url) preload = null;
     380      return appendSegment(nextIdx, bytes, mySeq);
     381    }).catch((e) => {
     382      console.warn('[pcms-audio] next-track append failed', e);
     383    }).finally(() => { chainFetching = false; });
     384  }
     385
     386  // Drop played-out data so the buffer holds ~2 tracks at most.
     387  function pruneBuffer(curSeg) {
     388    if (!useMse() || !sb || !ms || ms.readyState !== 'open') return;
     389    const cut = curSeg.start - 0.5;
     390    if (cut <= 1) return;
     391    sbRun(() => sb.remove(0, cut)).catch(() => {});
     392    chain = chain.filter((s) => s.end > cut);
     393  }
     394
     395  function currentSegment() {
     396    const t = audio.currentTime || 0;
     397    for (let i = 0; i < chain.length; i++) if (t < chain[i].end - 0.05) return chain[i];
     398    return chain[chain.length - 1] || null;
     399  }
     400
     401  // Playback flowed across a track boundary (the gapless auto-advance):
     402  // update chrome/metadata, top the buffer up, evict what's been played.
     403  function maybeCrossBoundary() {
     404    const seg = currentSegment();
     405    if (!seg || seg.qIndex === currentIndex) return;
     406    currentIndex = seg.qIndex;
     407    const t = queue[currentIndex];
     408    if (t) {
     409      console.log('[pcms-audio] gapless auto-advance →', t.title);
     410      updateTrackChrome(t);
     411    }
     412    ensureNextAppended();
     413    pruneBuffer(seg);
     414    updatePositionState();
     415    savePlayerState();
     416  }
     417
     418  // Start a fresh chain at queue[index]. Manual actions only (start/jump/
     419  // prev/next/restore) — those happen in the foreground, where starting a
     420  // new playback session is allowed.
     421  function chainStart(index, autoplay, mySeq, bytes) {
     422    teardownChain();
     423    ms = new MediaSource();
     424    const msUrl = URL.createObjectURL(ms);
     425    if (currentObjectUrl && currentObjectUrl !== msUrl) {
     426      try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
     427    }
     428    currentObjectUrl = msUrl;
     429    try { audio.pause(); } catch (e) {}
     430    audio.src = msUrl;
     431    try { audio.load(); } catch (e) {}
     432    const bailToBlob = (e) => {
     433      console.warn('[pcms-audio] MSE unavailable, using blob playback', e);
     434      mseFailed = true;
     435      teardownChain();
     436      if (mySeq === loadSeq) applyBlobBytes(bytes, autoplay, mySeq);
     437    };
     438    ms.addEventListener('sourceopen', () => {
     439      if (mySeq !== loadSeq || !ms) return;
     440      try {
     441        sb = ms.addSourceBuffer('audio/mpeg');
     442      } catch (e) { return bailToBlob(e); }
     443      appendSegment(index, bytes, mySeq).then(() => {
     444        if (mySeq !== loadSeq) return;
     445        // Session-restore: land at the saved in-track position.
     446        if (pendingSeek > 0 && chain.length) {
     447          const seg = chain[0];
     448          try { audio.currentTime = Math.min(pendingSeek, (seg.end - seg.start) - 0.25); } catch (e) {}
     449          pendingSeek = 0;
     450        }
     451        ensureNextAppended();
     452      }).catch(bailToBlob);
     453    }, { once: true });
     454    if (autoplay) play();
     455  }
     456
     457  // Current position/duration in TRACK coordinates (the MSE timeline is the
     458  // whole chain; the UI always shows the single playing track).
     459  function displayTimes() {
     460    if (useMse() && chain.length) {
     461      const seg = currentSegment();
     462      if (seg) return { cur: Math.max(0, (audio.currentTime || 0) - seg.start), dur: seg.end - seg.start };
     463    }
     464    return { cur: audio.currentTime || 0, dur: audio.duration };
    273465  }
    274466
     
    310502  }
    311503
    312   function loadTrack(index, autoplay, metaOnly) {
    313     if (!queue[index]) {
    314       console.warn('[pcms-audio] loadTrack: no track at index', index);
    315       return;
    316     }
    317     currentIndex = index;
    318     const t = queue[index];
    319     if (!t.url) {
    320       console.error('[pcms-audio] track has no url', t);
    321       return;
    322     }
    323     console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : '');
    324     // Metadata + chrome update synchronously so the UI reacts instantly while
    325     // the bytes download.
     504  // All the visible per-track chrome: titles, covers, queue highlight, media
     505  // session metadata. Called from loadTrack AND from the gapless boundary-cross.
     506  function updateTrackChrome(t) {
    326507    titleEl.textContent  = t.title  || 'Untitled';
    327508    artistEl.textContent = t.artist || '';
     
    336517    markPlaying(t.id);
    337518    updateMediaMetadata(t);
     519  }
     520
     521  function loadTrack(index, autoplay, metaOnly) {
     522    if (!queue[index]) {
     523      console.warn('[pcms-audio] loadTrack: no track at index', index);
     524      return;
     525    }
     526    currentIndex = index;
     527    const t = queue[index];
     528    if (!t.url) {
     529      console.error('[pcms-audio] track has no url', t);
     530      return;
     531    }
     532    console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : '');
     533    // Metadata + chrome update synchronously so the UI reacts instantly while
     534    // the bytes download.
     535    updateTrackChrome(t);
    338536
    339537    if (metaOnly) return;
     
    342540
    343541    // Fast path: the bytes for this exact track were already prefetched while
    344     // the previous track played → swap in instantly, no gap, no fetch window.
    345     if (preload && preload.url === t.url && preload.objUrl) {
    346       const obj = preload.objUrl;
    347       preload = null;  // ownership moves to applyBlob (becomes currentObjectUrl)
    348       applyBlob(obj, autoplay, mySeq);
    349       return;
    350     }
    351 
    352     // Not preloaded (or preload still in flight) → drop any stale preload and
    353     // fetch fresh, retrying transient failures before giving up.
    354     dropPreload();
    355     root.classList.add('audio-loading');
    356     fetchAsObjectUrl(t.url, 3)
    357       .then((objUrl) => applyBlob(objUrl, autoplay, mySeq))
    358       .catch((err) => onLoadError(err, mySeq));
     542    // the previous track played → no gap, no fetch window.
     543    let bytesP;
     544    if (preload && preload.url === t.url && preload.bytes) {
     545      bytesP = Promise.resolve(preload.bytes);
     546      preload = null;
     547    } else {
     548      // Not preloaded (or still in flight) → drop any stale preload and fetch
     549      // fresh, retrying transient failures before giving up.
     550      dropPreload();
     551      root.classList.add('audio-loading');
     552      bytesP = fetchTrackBytes(t.url, 3);
     553    }
     554    bytesP.then((bytes) => {
     555      if (mySeq !== loadSeq) return;
     556      root.classList.remove('audio-loading');
     557      if (useMse()) chainStart(index, autoplay, mySeq, bytes);
     558      else applyBlobBytes(bytes, autoplay, mySeq);
     559    }).catch((err) => onLoadError(err, mySeq));
    359560  }
    360561
     
    429630    albumName = '';
    430631    loadSeq++;  // cancel any in-flight load
    431     dropPreload();  // free any prefetched next-track blob
     632    dropPreload();
     633    teardownChain();
    432634    if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
    433635    currentObjectUrl = null;
     
    510712    wire('previoustrack', () => prev());
    511713    wire('nexttrack', () => next());
    512     wire('seekto', (e) => { if (e && e.seekTime != null && audio.duration) { try { audio.currentTime = e.seekTime; } catch (er) {} } });
     714    wire('seekto', (e) => {
     715      if (!e || e.seekTime == null) return;
     716      // Lock-screen scrubber works in TRACK coordinates (positionState below).
     717      if (useMse() && chain.length) {
     718        const seg = currentSegment();
     719        if (seg) { try { audio.currentTime = seg.start + Math.min(e.seekTime, seg.end - seg.start - 0.1); } catch (er) {} }
     720        return;
     721      }
     722      if (audio.duration) { try { audio.currentTime = e.seekTime; } catch (er) {} }
     723    });
     724  }
     725  // Lock-screen / notification scrubber: report per-track position, not the
     726  // whole-chain timeline.
     727  function updatePositionState() {
     728    if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
     729    try {
     730      const dt = displayTimes();
     731      if (!isFinite(dt.dur) || !dt.dur) return;
     732      navigator.mediaSession.setPositionState({
     733        duration: dt.dur,
     734        playbackRate: audio.playbackRate || 1,
     735        position: Math.min(dt.cur, dt.dur),
     736      });
     737    } catch (e) { /* non-fatal */ }
    513738  }
    514739  // Reset the error counter only on a REAL playback start (`playing`), not the
     
    516741  // there would prevent the 3-strikes stop from ever triggering on a broken
    517742  // track → infinite "next" loop. `playing` only fires when audio is actually playing.
    518   audio.addEventListener('playing', () => { consecutiveErrors = 0; preloadNext(); });
     743  audio.addEventListener('playing', () => {
     744    consecutiveErrors = 0;
     745    if (useMse()) ensureNextAppended(); else preloadNext();
     746    updatePositionState();
     747  });
    519748  audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'paused'; } catch (e) {} } });
    520749  audio.addEventListener('ended', next);
     
    522751    const code = audio.error ? audio.error.code : '?';
    523752    console.error('[pcms-audio] playback error', code, audio.src, e);
     753    if (useMse() && ms) {
     754      // The MSE pipeline failed (decode/append) → permanently fall back to the
     755      // blob engine for this session and retry the SAME track.
     756      console.warn('[pcms-audio] MSE failed, falling back to blob playback');
     757      mseFailed = true;
     758      teardownChain();
     759      if (queue[currentIndex]) loadTrack(currentIndex, true);
     760      return;
     761    }
    524762    consecutiveErrors++;
    525763    // On network/decode error: skip to next track instead of stalling.
     
    533771  audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
    534772  audio.addEventListener('timeupdate', () => {
    535     if (!audio.duration || isNaN(audio.duration)) return;
    536     const pct = (audio.currentTime / audio.duration) * 100;
     773    // Gapless boundary: in MSE mode a track change is just the timeline
     774    // flowing past a segment edge — detect it here and update the chrome.
     775    if (useMse() && chain.length) maybeCrossBoundary();
     776    const dt = displayTimes();
     777    if (!dt.dur || isNaN(dt.dur) || !isFinite(dt.dur)) return;
     778    const pct = (dt.cur / dt.dur) * 100;
    537779    seekFill.style.width = pct + '%';
    538780    sheetSeekFill.style.width = pct + '%';
    539     currentEl.textContent  = formatTime(audio.currentTime);
    540     totalEl.textContent    = formatTime(audio.duration);
    541     sheetCurrent.textContent = formatTime(audio.currentTime);
    542     sheetTotal.textContent   = formatTime(audio.duration);
     781    currentEl.textContent  = formatTime(dt.cur);
     782    totalEl.textContent    = formatTime(dt.dur);
     783    sheetCurrent.textContent = formatTime(dt.cur);
     784    sheetTotal.textContent   = formatTime(dt.dur);
    543785  });
    544786
     
    551793  function attachSeek(seekEl) {
    552794    seekEl.addEventListener('click', (e) => {
    553       if (!audio.duration) return;
    554795      const rect = seekEl.getBoundingClientRect();
    555796      const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
     797      if (useMse() && chain.length) {
     798        // Seek within the CURRENT track's segment of the chain timeline.
     799        const seg = currentSegment();
     800        if (seg) {
     801          try { audio.currentTime = seg.start + ratio * (seg.end - seg.start); } catch (er) {}
     802          updatePositionState();
     803        }
     804        return;
     805      }
     806      if (!audio.duration) return;
    556807      audio.currentTime = ratio * audio.duration;
    557808    });
     
    8631114      sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
    8641115        queue, currentIndex, albumName,
    865         time: audio.currentTime || 0,
     1116        // In-TRACK position (the MSE timeline spans the whole chain; a restore
     1117        // starts a fresh chain where this track begins at 0).
     1118        time: displayTimes().cur || 0,
    8661119        playing: !!audio.src && !audio.paused,
    8671120      }));
Note: See TracChangeset for help on using the changeset viewer.