fix: preload next track 30s early to survive Android Doze network gap

When the current song ends, Android's battery optimization briefly kills
background network access. By starting the next song's stream request while
the current track is still playing (and Chrome holds an active audio session),
the buffer is ready before the gap hits. The same URL is reused by the main
element so Chrome can serve from its in-memory buffer.

Also reset _trackChanging on play() failure to avoid permanently suppressing
pause events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-03 20:40:25 +02:00
1 parent 89e169b70a
commit 502427d02c
1 file changed
+41 -2
+41 -2
View File
@@ -5,10 +5,41 @@ import { api, streamUrl, coverUrl, downloadUrl } from './api.js';
import { loadRecommendations } from './views.js'; import { loadRecommendations } from './views.js';
import { updateLyricsHighlight, onNpSongChange } from './nowplaying.js'; import { updateLyricsHighlight, onNpSongChange } from './nowplaying.js';
// ─── PRELOAD (keeps network alive through Doze mode gap between tracks) ───
let _preAudio = null;
let _preUrl = '';
let _preId = '';
function _nextIdx() {
if(S.repeat === 'one') return S.qIdx;
if(S.shuffle) return -1;
if(S.qIdx + 1 < S.queue.length) return S.qIdx + 1;
if(S.repeat === 'all') return 0;
return -1;
}
function _preloadNext() {
const ni = _nextIdx();
if(ni < 0) return;
const next = S.queue[ni];
if(!next || _preId === next.id) return;
_preId = next.id;
_preUrl = streamUrl(next.id); // generate URL once — reused by main element
if(!_preAudio) { _preAudio = new Audio(); _preAudio.preload = 'auto'; }
_preAudio.volume = 0;
_preAudio.src = _preUrl; // Chrome starts buffering immediately
}
function _clearPreload() {
_preId = ''; _preUrl = '';
if(_preAudio) { _preAudio.src = ''; }
}
// ─── PLAYBACK ─── // ─── PLAYBACK ───
export function playQueue(songs, startIdx) { export function playQueue(songs, startIdx) {
S.queue = songs.map(s => ({...s})); S.queue = songs.map(s => ({...s}));
S.qIdx = startIdx; S.qIdx = startIdx;
_clearPreload();
playCurrent(); playCurrent();
renderQueue(); renderQueue();
if(S.radioMode) fillRadioQueue(); if(S.radioMode) fillRadioQueue();
@@ -21,8 +52,11 @@ export function playCurrent() {
updatePlayerUI(s); // update metadata/notification BEFORE src change updatePlayerUI(s); // update metadata/notification BEFORE src change
onNpSongChange(s.id); onNpSongChange(s.id);
renderQueue(); renderQueue();
S.audio.src = streamUrl(s.id); // Reuse preloaded URL so Chrome can serve from its in-memory buffer
S.audio.play().catch(() => {}); const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload();
S.audio.src = src;
S.audio.play().catch(() => { S._trackChanging = false; });
} }
export function updatePlayerUI(s) { export function updatePlayerUI(s) {
@@ -128,11 +162,13 @@ export function setPlayIcon(playing) {
} }
export function prevSong() { export function prevSong() {
_clearPreload();
if(S.audio.currentTime > 3) { S.audio.currentTime = 0; return; } if(S.audio.currentTime > 3) { S.audio.currentTime = 0; return; }
if(S.qIdx > 0) { S.qIdx--; playCurrent(); } if(S.qIdx > 0) { S.qIdx--; playCurrent(); }
} }
export function nextSong() { export function nextSong() {
_clearPreload();
if(S.shuffle) S.qIdx = Math.floor(Math.random() * S.queue.length); if(S.shuffle) S.qIdx = Math.floor(Math.random() * S.queue.length);
else if(S.qIdx < S.queue.length - 1) S.qIdx++; else if(S.qIdx < S.queue.length - 1) S.qIdx++;
else if(S.repeat) S.qIdx = 0; else if(S.repeat) S.qIdx = 0;
@@ -200,6 +236,9 @@ let _androidPosTick = 0;
export function updateProgress() { export function updateProgress() {
if(!S.audio.duration || S._scrubbing) return; if(!S.audio.duration || S._scrubbing) return;
syncMediaSessionPosition(); syncMediaSessionPosition();
// Preload next track at 30s remaining so network stays alive through Doze gap
const rem = S.audio.duration - S.audio.currentTime;
if(rem > 0 && rem < 30 && !S._trackChanging) _preloadNext();
// Update Android native position every ~5 seconds to keep lock screen scrubber in sync // Update Android native position every ~5 seconds to keep lock screen scrubber in sync
const now = Date.now(); const now = Date.now();
if(now - _androidPosTick > 5000) { if(now - _androidPosTick > 5000) {