Changeset 421046c in Klonkt
- Timestamp:
- 07/02/2026 01:21:34 PM (2 months ago)
- Branches:
- main
- Children:
- d039264
- Parents:
- a369029
- Files:
-
- 5 edited
-
CHANGELOG.de.md (modified) (1 diff)
-
CHANGELOG.md (modified) (1 diff)
-
CHANGELOG.nl.md (modified) (1 diff)
-
src/assets/js/audio-player.js (modified) (16 diffs)
-
src/views/shell.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
CHANGELOG.de.md
ra369029 r421046c 5 5 6 6 ## [Unreleased] 7 8 ### Behoben 9 - **Musik läuft auf Android im Hintergrund weiter.** Wenn ein Titel endete, 10 während das Handy gesperrt oder die App im Hintergrund war, startete der 11 nächste Titel und stoppte nach einer Sekunde wieder. Der Player führt die 12 Warteschlange jetzt als einen durchgehenden Stream zu, sodass das 13 Weiterspringen zum nächsten Titel nicht mehr als "neue" Wiedergabe zählt, 14 die der Browser pausieren darf. 7 15 8 16 ## [1.3.0] — 2026-07-02 -
CHANGELOG.md
ra369029 r421046c 5 5 6 6 ## [Unreleased] 7 8 ### Fixed 9 - **Music keeps playing in the background on Android.** When a track ended while 10 your phone was locked or the app was in the background, the next track would 11 start and stop again after a second. The player now feeds the whole queue as 12 one continuous stream, so auto-advancing to the next track no longer counts 13 as "new" playback that the browser is allowed to pause. 7 14 8 15 ## [1.3.0] — 2026-07-02 -
CHANGELOG.nl.md
ra369029 r421046c 5 5 6 6 ## [Unreleased] 7 8 ### Opgelost 9 - **Muziek blijft op Android doorspelen in de achtergrond.** Als een nummer 10 afliep terwijl je telefoon op slot zat of de app in de achtergrond stond, 11 startte het volgende nummer en stopte het na een seconde weer. De speler 12 voert de wachtrij nu als één doorlopende stream aan, waardoor het doorgaan 13 naar het volgende nummer niet meer telt als "nieuw" afspelen dat de browser 14 mag pauzeren. 7 15 8 16 ## [1.3.0] — 2026-07-02 -
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 })); -
src/views/shell.ejs
ra369029 r421046c 400 400 ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat 401 401 Cloudflare (max-age=1y) niet de oude versie blijft serveren. --> 402 <script src="/assets/js/audio-player.js?v=3 1"></script>402 <script src="/assets/js/audio-player.js?v=32"></script> 403 403 <!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte 404 404 player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
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)