mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
+27
-20
@@ -272,10 +272,10 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
|
|||||||
useHotCacheStore.getState().touchPlayed(trackId, serverId);
|
useHotCacheStore.getState().touchPlayed(trackId, serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track ID that has already been sent to audio_chain_preload / audio_preload.
|
// Track ID that has already been sent to audio_chain_preload (gapless chain).
|
||||||
// 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.
|
|
||||||
let gaplessPreloadingId: string | null = null;
|
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 ─────────────────────────────────────────────────────────
|
// ─── Server queue sync ─────────────────────────────────────────────────────────
|
||||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -329,29 +329,40 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pre-buffer / pre-chain next track based on preload mode.
|
// 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 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'
|
preloadMode === 'early'
|
||||||
? current_time >= 5
|
? current_time >= 5
|
||||||
: preloadMode === 'custom'
|
: preloadMode === 'custom'
|
||||||
? remaining < preloadCustomSeconds && remaining > 0
|
? remaining < preloadCustomSeconds && remaining > 0
|
||||||
: remaining < 30 && remaining > 0 // balanced (default)
|
: remaining < 30 && remaining > 0 // balanced (default)
|
||||||
);
|
);
|
||||||
if (shouldPreload) {
|
|
||||||
|
if (shouldChainGapless || shouldBytePreload) {
|
||||||
const { queue, queueIndex, repeatMode } = store;
|
const { queue, queueIndex, repeatMode } = store;
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
const nextTrack = repeatMode === 'one'
|
const nextTrack = repeatMode === 'one'
|
||||||
? track
|
? track
|
||||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
: (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;
|
||||||
gaplessPreloadingId = nextTrack.id;
|
|
||||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||||
if (gaplessEnabled) {
|
|
||||||
// Gapless ON: decode + chain directly into the Sink now, 30 s in
|
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||||
// advance. By the time the track boundary arrives, the next source is
|
if (shouldBytePreload && nextTrack.id !== bytePreloadingId) {
|
||||||
// already live — no IPC round-trip at the gap point.
|
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 authState = useAuthStore.getState();
|
const authState = useAuthStore.getState();
|
||||||
const replayGainDb = authState.replayGainEnabled
|
const replayGainDb = authState.replayGainEnabled
|
||||||
? (authState.replayGainMode === 'album'
|
? (authState.replayGainMode === 'album'
|
||||||
@@ -367,12 +378,8 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
durationHint: nextTrack.duration,
|
durationHint: nextTrack.duration,
|
||||||
replayGainDb,
|
replayGainDb,
|
||||||
replayGainPeak,
|
replayGainPeak,
|
||||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
hiResEnabled: authState.enableHiRes,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
} else {
|
|
||||||
// Gapless OFF: just pre-download bytes so audio_play finds them cached.
|
|
||||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,7 +417,7 @@ function handleAudioEnded() {
|
|||||||
*/
|
*/
|
||||||
function handleAudioTrackSwitched(duration: number) {
|
function handleAudioTrackSwitched(duration: number) {
|
||||||
lastGaplessSwitchTime = Date.now();
|
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;
|
isAudioPaused = false;
|
||||||
|
|
||||||
const store = usePlayerStore.getState();
|
const store = usePlayerStore.getState();
|
||||||
@@ -763,7 +770,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
clearRadioReconnectTimer();
|
clearRadioReconnectTimer();
|
||||||
radioReconnectCount = 0;
|
radioReconnectCount = 0;
|
||||||
gaplessPreloadingId = null;
|
gaplessPreloadingId = null; bytePreloadingId = null;
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||||
// Stop Rust engine in case a regular track was playing.
|
// Stop Rust engine in case a regular track was playing.
|
||||||
invoke('audio_stop').catch(() => {});
|
invoke('audio_stop').catch(() => {});
|
||||||
@@ -798,7 +805,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
|
|
||||||
const gen = ++playGeneration;
|
const gen = ++playGeneration;
|
||||||
isAudioPaused = false;
|
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 (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||||
|
|
||||||
// If a radio stream is active, stop it before the new track starts so
|
// If a radio stream is active, stop it before the new track starts so
|
||||||
|
|||||||
@@ -1088,6 +1088,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--bg-card);
|
||||||
}
|
}
|
||||||
.player-album-art-wrap::after {
|
.player-album-art-wrap::after {
|
||||||
content: '';
|
content: '';
|
||||||
|
|||||||
Reference in New Issue
Block a user