Revert "feat(web): gapless audio handoff to fix desktop media-tile flicker"

This reverts commit 98d7c84269.
This commit is contained in:
Honey committed 2026-06-26 17:45:40 +02:00
1 parent 98d7c84269
commit 420c84d191
4 files changed
+48 -92

No files matched your search

+1 -1
View File
@@ -74,7 +74,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
- **Android** — Offline detail screen flashed open then immediately returned: its initial empty state (before Room emitted) was mistaken for "deleted". Now shows a brief spinner until loaded, and only leaves when the last song is actually removed - **Android** — Offline detail screen flashed open then immediately returned: its initial empty state (before Room emitted) was mistaken for "deleted". Now shows a brief spinner until loaded, and only leaves when the last song is actually removed
- **Android** — Lyrics autoscroll: rewrote centering to use the active line's *measured* position in a `LazyColumn` instead of a fixed line-height estimate. Eliminates the stutter and the downward drift on wrapped (multi-line) lyrics — the active line now tracks smoothly and sits at a natural ~40 %-from-top reading anchor - **Android** — Lyrics autoscroll: rewrote centering to use the active line's *measured* position in a `LazyColumn` instead of a fixed line-height estimate. Eliminates the stutter and the downward drift on wrapped (multi-line) lyrics — the active line now tracks smoothly and sits at a natural ~40 %-from-top reading anchor
- **Web** — Home now refreshes on an explicit Home click (matching Android): the album rows re-fetch and the random row re-randomizes instead of serving the 5-minute cache. Back/forward navigation still keeps the cached state - **Web** — Home now refreshes on an explicit Home click (matching Android): the album rows re-fetch and the random row re-randomizes instead of serving the 5-minute cache. Back/forward navigation still keeps the cached state
- **Web (PWA)** — OS media tile no longer disappears/reappears when skipping tracks on desktop. Two fixes: (1) the Media Session metadata is mutated in place instead of replaced with a new `MediaMetadata` each track, and (2) gapless handoff — playback is handed to a second, pre-buffered `<audio>` element on skip so the active element never empties (the source-swap gap is what made Windows hide the tile). Falls back to the classic source swap when the next track isn't preloaded - **Web (PWA)** — OS media tile no longer disappears/reappears when skipping tracks on desktop: the Media Session metadata is now mutated in place instead of replaced with a new `MediaMetadata` each track (which made Windows rebuild the tile)
### Internal ### Internal
- **Android** — Offline system: `Room` DB (`novasonic_offline.db`) with `downloaded_tracks`, `download_groups`, `group_track_cross_refs`; `OfflineRepository` holds hot `StateFlow` maps for O(1) local-path lookups; `DownloadWorker` streams audio + cover art via `HttpURLConnection` to `context.filesDir/offline/{audio|covers}/`. `MusicRepository.streamUrl()` checks local files first, so playback uses the `file://` URI on every entry point - **Android** — Offline system: `Room` DB (`novasonic_offline.db`) with `downloaded_tracks`, `download_groups`, `group_track_cross_refs`; `OfflineRepository` holds hot `StateFlow` maps for O(1) local-path lookups; `DownloadWorker` streams audio + cover art via `HttpURLConnection` to `context.filesDir/offline/{audio|covers}/`. `MusicRepository.streamUrl()` checks local files first, so playback uses the `file://` URI on every entry point
-1
View File
@@ -742,7 +742,6 @@
<div class="toast-container" id="toast-container"></div> <div class="toast-container" id="toast-container"></div>
<audio id="audio-element" preload="auto" playsinline crossorigin="anonymous"></audio> <audio id="audio-element" preload="auto" playsinline crossorigin="anonymous"></audio>
<audio id="audio-element-b" preload="auto" playsinline crossorigin="anonymous"></audio>
<audio id="preview-audio"></audio> <audio id="preview-audio"></audio>
<audio id="abs-audio"></audio> <audio id="abs-audio"></audio>
+35 -44
View File
@@ -122,9 +122,6 @@ window.addEventListener('load', () => {
// Wire up audio elements from DOM // Wire up audio elements from DOM
S.audio = document.getElementById('audio-element'); S.audio = document.getElementById('audio-element');
const _audioB = document.getElementById('audio-element-b');
// Two fixed audio elements for gapless handoff — S.audio always points at the active one.
S._audioEls = _audioB ? [S.audio, _audioB] : [S.audio];
S.previewAudio = document.getElementById('preview-audio'); S.previewAudio = document.getElementById('preview-audio');
const saved = localStorage.getItem('ns_cfg'); const saved = localStorage.getItem('ns_cfg');
@@ -137,50 +134,44 @@ window.addEventListener('load', () => {
if(window.NS_SERVER_URL?.trim()) { const el=document.getElementById('srv-url'); if(el&&!el.value) el.value=window.NS_SERVER_URL.trim(); } if(window.NS_SERVER_URL?.trim()) { const el=document.getElementById('srv-url'); if(el&&!el.value) el.value=window.NS_SERVER_URL.trim(); }
document.getElementById('srv-pass').addEventListener('keydown', e => { if(e.key==='Enter') doConnect(); }); document.getElementById('srv-pass').addEventListener('keydown', e => { if(e.key==='Enter') doConnect(); });
// Listeners are bound to BOTH audio elements so a gapless handoff needs no rebinding. S.audio.addEventListener('timeupdate', updateProgress);
// Handlers that only make sense for the playing element guard with `el !== S.audio`. S.audio.addEventListener('ended', onEnded);
let _errRetries = 0, _errTimer = null; S.audio.addEventListener('play', () => {
function bindAudioEl(el) { S._trackChanging = false;
el.addEventListener('timeupdate', updateProgress); absInterrupt();
el.addEventListener('ended', onEnded); if (S.previewCurrent) stopPreview(false);
el.addEventListener('play', () => { setPlayIcon(true);
S._trackChanging = false; document.getElementById('player-art').classList.add('spinning');
absInterrupt(); if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
if (S.previewCurrent) stopPreview(false); });
setPlayIcon(true); S.audio.addEventListener('pause', () => {
document.getElementById('player-art').classList.add('spinning'); setPlayIcon(false);
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing'; document.getElementById('player-art').classList.remove('spinning');
}); // Suppress during track change — src swap fires pause before the new track starts
el.addEventListener('pause', () => { if(!S._trackChanging && navigator.mediaSession) navigator.mediaSession.playbackState = 'paused';
if (el !== S.audio) return; // ignore the idle standby element during a handoff });
setPlayIcon(false);
document.getElementById('player-art').classList.remove('spinning');
// Suppress during track change — src swap fires pause before the new track starts
if(!S._trackChanging && navigator.mediaSession) navigator.mediaSession.playbackState = 'paused';
});
// stalled = browser buffer ran out briefly; let the browser retry naturally.
el.addEventListener('play', () => { _errRetries = 0; clearTimeout(_errTimer); }, { capture: true });
el.addEventListener('error', () => {
if (el !== S.audio) return; // standby/preload errors must not disrupt active playback
// Skip if playCurrent() is already handling recovery (_trackChanging = true means
// we're mid-transition or waiting for canplay — don't conflict with that)
if(!S.queue[S.qIdx] || S._trackChanging) return;
_errRetries++;
const delay = Math.min(1000 * Math.pow(2, _errRetries - 1), 8000); // 1s, 2s, 4s, 8s
clearTimeout(_errTimer);
_errTimer = setTimeout(() => {
if(!S.queue[S.qIdx] || S._trackChanging) return;
S._trackChanging = true;
S.audio.load();
S.audio.play().catch(() => { S._trackChanging = false; });
}, delay);
});
}
S._audioEls.forEach(bindAudioEl);
S.previewAudio.addEventListener('timeupdate', updatePreviewProgress); S.previewAudio.addEventListener('timeupdate', updatePreviewProgress);
S.previewAudio.addEventListener('ended', stopPreview); S.previewAudio.addEventListener('ended', stopPreview);
// stalled = browser buffer ran out briefly; let the browser retry naturally.
// The watchdog below catches genuine long freezes.
let _errRetries = 0, _errTimer = null;
S.audio.addEventListener('play', () => { _errRetries = 0; clearTimeout(_errTimer); }, { capture: true });
S.audio.addEventListener('error', () => {
// Skip if playCurrent() is already handling recovery (_trackChanging = true means
// we're mid-transition or waiting for canplay — don't conflict with that)
if(!S.queue[S.qIdx] || S._trackChanging) return;
_errRetries++;
const delay = Math.min(1000 * Math.pow(2, _errRetries - 1), 8000); // 1s, 2s, 4s, 8s
clearTimeout(_errTimer);
_errTimer = setTimeout(() => {
if(!S.queue[S.qIdx] || S._trackChanging) return;
S._trackChanging = true;
S.audio.load();
S.audio.play().catch(() => { S._trackChanging = false; });
}, delay);
});
const _savedVol = parseInt(localStorage.getItem('ns_volume') ?? '', 10); const _savedVol = parseInt(localStorage.getItem('ns_volume') ?? '', 10);
setVolume(isNaN(_savedVol) ? 55 : _savedVol); setVolume(isNaN(_savedVol) ? 55 : _savedVol);
+12 -46
View File
@@ -276,19 +276,10 @@ function _setAudioSession() {
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {} try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
} }
// ─── PRELOAD — the standby audio element buffers the next track ────────────── // ─── PRELOAD — shadow Audio element actually buffers the next track ──────────
// Buffering into the *standby* element (not a throwaway) lets playCurrent() hand
// playback over to it gaplessly: the active element never empties, so the OS media
// tile doesn't flicker on skip. Falls back to a source-swap when not preloaded.
let _preUrl = ''; let _preUrl = '';
let _preId = ''; let _preId = '';
let _preAudio = null;
/** The audio element that is NOT currently active (or null if there's only one). */
function _standbyEl() {
const els = S._audioEls;
if (!els || els.length < 2) return null;
return els.find(e => e !== S.audio) || null;
}
function _nextIdx() { function _nextIdx() {
if(S.repeat === 'one') return S.qIdx; if(S.repeat === 'one') return S.qIdx;
@@ -303,12 +294,13 @@ function _preloadNext() {
if(ni < 0) return; if(ni < 0) return;
const next = S.queue[ni]; const next = S.queue[ni];
if(!next || _preId === next.id) return; if(!next || _preId === next.id) return;
const standby = _standbyEl();
if(!standby) return;
_preId = next.id; _preId = next.id;
_preUrl = streamUrl(next.id); _preUrl = streamUrl(next.id);
standby.src = _preUrl; // preload="auto" → the element buffers it for a gapless handoff // Real Audio preload — actually buffers the stream so canplay fires instantly
try { standby.load(); } catch {} if(_preAudio) { _preAudio.src = ''; _preAudio = null; }
_preAudio = new Audio();
_preAudio.preload = 'auto';
_preAudio.src = _preUrl;
if(next.coverArt) { if(next.coverArt) {
[256, 512, 1024].forEach(size => { [256, 512, 1024].forEach(size => {
const img = new Image(); const img = new Image();
@@ -319,8 +311,7 @@ function _preloadNext() {
function _clearPreload() { function _clearPreload() {
_preId = ''; _preUrl = ''; _preId = ''; _preUrl = '';
const standby = _standbyEl(); if(_preAudio) { _preAudio.src = ''; _preAudio = null; }
if(standby) { try { standby.pause(); standby.removeAttribute('src'); standby.load(); } catch {} }
} }
// ─── PLAY HISTORY (for prev-in-shuffle) ────────────────────────────────────── // ─── PLAY HISTORY (for prev-in-shuffle) ──────────────────────────────────────
@@ -406,6 +397,10 @@ export function playCurrent() {
renderQueue(); renderQueue();
_scheduleScrobble(s.id, s.duration); _scheduleScrobble(s.id, s.duration);
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload();
S.audio.src = src;
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; }; const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
const _applySession = () => { const _applySession = () => {
@@ -414,35 +409,6 @@ export function playCurrent() {
setTimeout(_preloadNext, 2000); setTimeout(_preloadNext, 2000);
}; };
// ── Gapless handoff: if the standby element already buffered THIS track, switch
// playback to it instead of reloading the source. The active element never empties,
// so the OS media tile stays put (and the skip is instant). ──
const standby = _standbyEl();
if (standby && _preId === s.id && standby.readyState >= 2) {
const old = S.audio;
standby.currentTime = 0;
standby.volume = old.volume; standby.muted = old.muted; standby.playbackRate = old.playbackRate || 1;
S.audio = standby; // the active element is now the preloaded one
_preId = ''; _preUrl = '';
try { old.pause(); } catch {} // 'pause' is ignored for the now-inactive element
_setAudioSession();
S.audio.play()
.then(() => { S._trackChanging = false; _applySession(); })
.catch(() => {
// Handoff playback failed — recover with a fresh source load on this element.
if (_playGen !== gen) return;
S.audio.src = streamUrl(s.id);
try { S.audio.load(); } catch {}
S.audio.play().then(() => { S._trackChanging = false; _applySession(); }).catch(_resetFlag);
});
return;
}
// ── Fallback: classic source swap on the current element (unchanged) ──
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload();
S.audio.src = src;
let _guardTimer = null; let _guardTimer = null;
let _canplayFn = null; let _canplayFn = null;