Changeset 421046c in Klonkt for src/assets/js/audio-player.js
- Timestamp:
- 07/02/2026 01:21:34 PM (2 months ago)
- Branches:
- main
- Children:
- d039264
- Parents:
- a369029
- File:
-
- 1 edited
-
src/assets/js/audio-player.js (modified) (16 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/js/audio-player.js
ra369029 r421046c 158 158 let isPlaying = false; 159 159 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. 163 174 let currentObjectUrl = null; 164 175 // 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. 166 177 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. 172 180 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 173 190 174 191 // Hide initially … … 190 207 } 191 208 192 // Fetch the track bytes and hand back a blob: object URL. The X-Audio-Player193 // 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. 194 211 // Retries a few times with backoff: a single transient network blip used to 195 212 // bump the error counter and SKIP the song (auto-advance past it). Now one 196 213 // hiccup just costs a retry, and we only give up after genuinely failing. 197 async function fetch AsObjectUrl(url, attempts) {214 async function fetchTrackBytes(url, attempts) { 198 215 attempts = attempts || 1; 199 216 let lastErr; … … 205 222 }); 206 223 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(); 209 225 } catch (e) { 210 226 lastErr = e; … … 217 233 } 218 234 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' })); 224 239 root.classList.remove('audio-loading'); 225 240 // Free the previously-playing track's blob — otherwise each track leaks a … … 248 263 } 249 264 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; } 255 267 256 268 // Prefetch the *next* track's bytes in the background. Idempotent: re-calling 257 269 // 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). 259 272 function preloadNext() { 260 273 if (queue.length < 2) return; … … 263 276 if (!t || !t.url) return; 264 277 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 }; 267 279 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; 272 283 }).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 }; 273 465 } 274 466 … … 310 502 } 311 503 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) { 326 507 titleEl.textContent = t.title || 'Untitled'; 327 508 artistEl.textContent = t.artist || ''; … … 336 517 markPlaying(t.id); 337 518 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); 338 536 339 537 if (metaOnly) return; … … 342 540 343 541 // 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)); 359 560 } 360 561 … … 429 630 albumName = ''; 430 631 loadSeq++; // cancel any in-flight load 431 dropPreload(); // free any prefetched next-track blob 632 dropPreload(); 633 teardownChain(); 432 634 if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} } 433 635 currentObjectUrl = null; … … 510 712 wire('previoustrack', () => prev()); 511 713 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 */ } 513 738 } 514 739 // Reset the error counter only on a REAL playback start (`playing`), not the … … 516 741 // there would prevent the 3-strikes stop from ever triggering on a broken 517 742 // 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 }); 519 748 audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'paused'; } catch (e) {} } }); 520 749 audio.addEventListener('ended', next); … … 522 751 const code = audio.error ? audio.error.code : '?'; 523 752 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 } 524 762 consecutiveErrors++; 525 763 // On network/decode error: skip to next track instead of stalling. … … 533 771 audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); }); 534 772 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; 537 779 seekFill.style.width = pct + '%'; 538 780 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); 543 785 }); 544 786 … … 551 793 function attachSeek(seekEl) { 552 794 seekEl.addEventListener('click', (e) => { 553 if (!audio.duration) return;554 795 const rect = seekEl.getBoundingClientRect(); 555 796 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; 556 807 audio.currentTime = ratio * audio.duration; 557 808 }); … … 863 1114 sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({ 864 1115 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, 866 1119 playing: !!audio.src && !audio.paused, 867 1120 }));
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)