From f34bd7c0f1cb08e2e6819150dc30a72136cd86b0 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Fri, 10 Apr 2026 14:04:30 +0200 Subject: [PATCH] fix(audio): separate gapless chain from preload gate and reduce black flash - Gapless now always chains at 30s regardless of preloadMode ('off' no longer breaks gapless playback) - Byte pre-download (audio_preload) skips when Hot Cache is active to avoid duplicate downloads - When both Gapless and Preload are active, bytes are pre-fetched early and the chain reuses the cached data (separate bytePreloadingId guard) - player-album-art-wrap gets background: var(--bg-card) so the brief opacity-0 loading state shows a themed colour instead of black Co-Authored-By: Claude Sonnet 4.6 --- src/store/playerStore.ts | 81 ++++++++++++++++++++++------------------ src/styles/layout.css | 1 + 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 6ea036ea..2e51a1da 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -272,10 +272,10 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) { useHotCacheStore.getState().touchPlayed(trackId, serverId); } -// Track ID that has already been sent to audio_chain_preload / audio_preload. -// Prevents the 100ms progress ticker from firing 300 identical IPC calls over -// the last 30 seconds of a track, each spawning its own HTTP download. +// Track ID that has already been sent to audio_chain_preload (gapless chain). let gaplessPreloadingId: string | null = null; +// Track ID that has already been sent to audio_preload (byte pre-download). +let bytePreloadingId: string | null = null; // ─── Server queue sync ───────────────────────────────────────────────────────── let syncTimeout: ReturnType | null = null; @@ -329,50 +329,57 @@ function handleAudioProgress(current_time: number, duration: number) { } // Pre-buffer / pre-chain next track based on preload mode. - const { gaplessEnabled, preloadMode, preloadCustomSeconds } = useAuthStore.getState(); + const { gaplessEnabled, preloadMode, preloadCustomSeconds, hotCacheEnabled } = useAuthStore.getState(); const remaining = dur - current_time; - const shouldPreload = preloadMode !== 'off' && ( + + // Gapless chain: always triggers at 30s regardless of preloadMode. + const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0; + // Byte pre-download: skip when Hot Cache is active (it already handles buffering). + const shouldBytePreload = !hotCacheEnabled && preloadMode !== 'off' && ( preloadMode === 'early' ? current_time >= 5 : preloadMode === 'custom' ? remaining < preloadCustomSeconds && remaining > 0 : remaining < 30 && remaining > 0 // balanced (default) ); - if (shouldPreload) { + + if (shouldChainGapless || shouldBytePreload) { const { queue, queueIndex, repeatMode } = store; const nextIdx = queueIndex + 1; const nextTrack = repeatMode === 'one' ? track : (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null)); - if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) { + if (!nextTrack || nextTrack.id === track.id) return; + + const serverId = useAuthStore.getState().activeServerId ?? ''; + const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId); + + // Byte pre-download — runs early so bytes are cached by chain time. + if (shouldBytePreload && nextTrack.id !== bytePreloadingId) { + bytePreloadingId = nextTrack.id; + invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {}); + } + + // Gapless chain — decode + chain into Sink 30s before track boundary. + if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) { gaplessPreloadingId = nextTrack.id; - const serverId = useAuthStore.getState().activeServerId ?? ''; - const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId); - if (gaplessEnabled) { - // Gapless ON: decode + chain directly into the Sink now, 30 s in - // advance. By the time the track boundary arrives, the next source is - // already live — no IPC round-trip at the gap point. - const authState = useAuthStore.getState(); - const replayGainDb = authState.replayGainEnabled - ? (authState.replayGainMode === 'album' - ? (nextTrack.replayGainAlbumDb ?? nextTrack.replayGainTrackDb) - : nextTrack.replayGainTrackDb) ?? null - : null; - const replayGainPeak = authState.replayGainEnabled - ? (nextTrack.replayGainPeak ?? null) - : null; - invoke('audio_chain_preload', { - url: nextUrl, - volume: store.volume, - durationHint: nextTrack.duration, - replayGainDb, - replayGainPeak, - hiResEnabled: useAuthStore.getState().enableHiRes, - }).catch(() => {}); - } else { - // Gapless OFF: just pre-download bytes so audio_play finds them cached. - invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {}); - } + const authState = useAuthStore.getState(); + const replayGainDb = authState.replayGainEnabled + ? (authState.replayGainMode === 'album' + ? (nextTrack.replayGainAlbumDb ?? nextTrack.replayGainTrackDb) + : nextTrack.replayGainTrackDb) ?? null + : null; + const replayGainPeak = authState.replayGainEnabled + ? (nextTrack.replayGainPeak ?? null) + : null; + invoke('audio_chain_preload', { + url: nextUrl, + volume: store.volume, + durationHint: nextTrack.duration, + replayGainDb, + replayGainPeak, + hiResEnabled: authState.enableHiRes, + }).catch(() => {}); } } } @@ -410,7 +417,7 @@ function handleAudioEnded() { */ function handleAudioTrackSwitched(duration: number) { lastGaplessSwitchTime = Date.now(); - gaplessPreloadingId = null; // allow preloading for the track after this one + gaplessPreloadingId = null; bytePreloadingId = null; // allow preloading for the track after this one isAudioPaused = false; const store = usePlayerStore.getState(); @@ -763,7 +770,7 @@ export const usePlayerStore = create()( isAudioPaused = false; clearRadioReconnectTimer(); radioReconnectCount = 0; - gaplessPreloadingId = null; + gaplessPreloadingId = null; bytePreloadingId = null; if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null; // Stop Rust engine in case a regular track was playing. invoke('audio_stop').catch(() => {}); @@ -798,7 +805,7 @@ export const usePlayerStore = create()( const gen = ++playGeneration; isAudioPaused = false; - gaplessPreloadingId = null; // new track — allow fresh preload for next + gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null; // If a radio stream is active, stop it before the new track starts so diff --git a/src/styles/layout.css b/src/styles/layout.css index 321579e7..365456ba 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -1088,6 +1088,7 @@ flex-shrink: 0; border-radius: var(--radius-sm); overflow: hidden; + background: var(--bg-card); } .player-album-art-wrap::after { content: '';