chore(playback): remove Preload Next Track setting (#1007)

* chore(playback): remove Preload Next Track setting

The configurable next-track RAM preload duplicated hot cache and is
obsolete now that ranged streaming starts playback sooner. Gapless and
crossfade still use the internal audio_preload backup when hot cache is off.

* docs: add CHANGELOG entry for Preload Next Track removal (PR #1007)
This commit is contained in:
cucadmuh
2026-06-06 01:01:08 +03:00
committed by GitHub
parent a66d932afe
commit 40db6b08d2
22 changed files with 39 additions and 235 deletions
+7 -34
View File
@@ -207,46 +207,22 @@ export function handleAudioProgress(
markStoreProgressCommit(nowCommit);
}
// Pre-buffer / pre-chain next track based on preload mode and crossfade.
// Pre-buffer / pre-chain next track for gapless and crossfade.
const {
gaplessEnabled,
preloadMode,
preloadCustomSeconds,
hotCacheEnabled,
crossfadeEnabled,
crossfadeSecs,
} = useAuthStore.getState();
const remaining = dur - current_time;
// 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).
// Even with preload mode OFF, crossfade needs the next track bytes ready before
// we enter the fade window to avoid a hard gap after track boundary.
const shouldBytePreloadFromMode = preloadMode !== 'off' && (
preloadMode === 'early'
? current_time >= 5
: preloadMode === 'custom'
? remaining < preloadCustomSeconds && remaining > 0
: remaining < 30 && remaining > 0 // balanced (default)
);
// Crossfade needs the next track bytes ready before the fade window.
const crossfadeWindowSecs = Math.max(8, Math.min(30, crossfadeSecs + 6));
const shouldBytePreloadForCrossfade =
!gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0;
const shouldBytePreload = !hotCacheEnabled && (
shouldBytePreloadFromMode ||
shouldBytePreloadForCrossfade
);
// Hot/offline cache: seed enrichment from disk (playback also uses psysonic-local://).
const shouldPreloadLocalFileAnalysis = preloadMode !== 'off' && (
preloadMode === 'early'
? current_time >= 5
: preloadMode === 'custom'
? remaining < preloadCustomSeconds && remaining > 0
: remaining < 30 && remaining > 0
);
!hotCacheEnabled && !gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0;
if (shouldChainGapless || shouldBytePreload || shouldPreloadLocalFileAnalysis || gaplessEnabled) {
if (shouldChainGapless || shouldBytePreloadForCrossfade || gaplessEnabled) {
const { queueItems, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
// Next track for preload/chain. The resolver bridge keeps the window around
@@ -281,11 +257,10 @@ export function handleAudioProgress(
const serverId = getPlaybackCacheServerKey();
const analysisServerId = getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
const nextIsLocalFile = nextUrl.startsWith('psysonic-local://');
// Byte pre-download — runs early so bytes are cached by chain time.
// Byte pre-download — gapless backup or crossfade; runs early so bytes are ready by chain time.
if (
(shouldBytePreload || shouldBytePreloadForGaplessBackup || (shouldPreloadLocalFileAnalysis && nextIsLocalFile))
(shouldBytePreloadForCrossfade || shouldBytePreloadForGaplessBackup)
&& nextTrack.id !== getBytePreloadingId()
) {
setBytePreloadingId(nextTrack.id);
@@ -296,10 +271,8 @@ export function handleAudioProgress(
console.info('[psysonic][preload-request]', {
nextTrackId: nextTrack.id,
nextUrl,
shouldBytePreload,
shouldBytePreloadForCrossfade,
shouldBytePreloadForGaplessBackup,
shouldPreloadLocalFileAnalysis,
nextIsLocalFile,
remaining,
gaplessEnabled,
});
+2 -6
View File
@@ -6,23 +6,19 @@ type SetState = (
/**
* Persistent plumbing settings that don't fit a more specific domain:
* runtime logging level, Navidrome `getNowPlaying` toggle, preload
* mode + custom seconds, audiobook exclusion, genre blacklist.
* runtime logging level, Navidrome `getNowPlaying` toggle, audiobook
* exclusion, genre blacklist.
*/
export function createPlumbingSettingsActions(set: SetState): Pick<
AuthState,
| 'setLoggingMode'
| 'setNowPlayingEnabled'
| 'setPreloadMode'
| 'setPreloadCustomSeconds'
| 'setExcludeAudiobooks'
| 'setCustomGenreBlacklist'
> {
return {
setLoggingMode: (v) => set({ loggingMode: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setPreloadMode: (v) => set({ preloadMode: v }),
setPreloadCustomSeconds: (v) => set({ preloadCustomSeconds: v }),
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
};
+5 -20
View File
@@ -4,8 +4,7 @@
* Covers Zustand persist's hydration path (via `useAuthStore.persist.rehydrate()`):
* existing localStorage shapes load, missing fields default to the store's
* initial values, corrupt JSON does not crash bootstrap, the
* hotCacheEnabled / preloadMode≠'off' conflicting-legacy migration clears
* both, and a few smaller field-level migrations.
* legacy field stripping, and a few smaller field-level migrations.
*
* Also pins the **synchronous storage** invariant called out in `CLAUDE.md`
* ("never switch to async storage") — regression §2 of the pre-refactor
@@ -53,7 +52,6 @@ describe('hydration — loads existing localStorage shape', () => {
expect(s.gaplessEnabled).toBe(false);
expect(s.replayGainEnabled).toBe(false);
expect(s.normalizationEngine).toBe('off');
expect(s.preloadMode).toBe('balanced');
});
it('preserves saved fields verbatim when present', async () => {
@@ -94,34 +92,21 @@ describe('hydration — corrupt / unexpected input', () => {
});
describe('onRehydrate migrations', () => {
it('clears the conflicting hotCacheEnabled + preloadMode≠"off" legacy combo', async () => {
it('keeps hotCacheEnabled when legacy preloadMode fields are present', async () => {
writePersistedState({
servers: [],
activeServerId: null,
hotCacheEnabled: true,
preloadMode: 'balanced',
});
await useAuthStore.persist.rehydrate();
const s = useAuthStore.getState();
expect(s.hotCacheEnabled).toBe(false);
expect(s.preloadMode).toBe('off');
});
it('keeps hotCacheEnabled when preloadMode was already "off"', async () => {
writePersistedState({
servers: [],
activeServerId: null,
hotCacheEnabled: true,
preloadMode: 'off',
preloadCustomSeconds: 45,
});
await useAuthStore.persist.rehydrate();
const s = useAuthStore.getState();
expect(s.hotCacheEnabled).toBe(true);
expect(s.preloadMode).toBe('off');
expect((s as { preloadMode?: unknown }).preloadMode).toBeUndefined();
expect((s as { preloadCustomSeconds?: unknown }).preloadCustomSeconds).toBeUndefined();
});
it('migrates a legacy `waveform` seekbarStyle to `truewave`', async () => {
+1 -9
View File
@@ -82,7 +82,6 @@ describe('trivial pass-through setters', () => {
['setMaxCacheMb', 'maxCacheMb', 2048],
['setHotCacheMaxMb', 'hotCacheMaxMb', 1024],
['setHotCacheDebounceSec', 'hotCacheDebounceSec', 60],
['setPreloadCustomSeconds', 'preloadCustomSeconds', 45],
])('%s stores a numeric value', (setter, key, value) => {
(useAuthStore.getState() as unknown as Record<string, (v: unknown) => void>)[setter](value);
expect((useAuthStore.getState() as unknown as Record<string, unknown>)[key]).toBe(value);
@@ -207,14 +206,7 @@ describe('replay-gain related setters (write through to player store)', () => {
});
});
describe('preload mode + discord cover source setters', () => {
it('setPreloadMode accepts off / balanced / early / custom', () => {
for (const mode of ['off', 'balanced', 'early', 'custom'] as const) {
useAuthStore.getState().setPreloadMode(mode);
expect(useAuthStore.getState().preloadMode).toBe(mode);
}
});
describe('discord cover source setters', () => {
it('setDiscordCoverSource accepts none / apple / server', () => {
for (const src of ['none', 'apple', 'server'] as const) {
useAuthStore.getState().setDiscordCoverSource(src);
-2
View File
@@ -59,8 +59,6 @@ export const useAuthStore = create<AuthState>()(
trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS },
trackPreviewStartRatio: 0.33,
trackPreviewDurationSec: 30,
preloadMode: 'balanced',
preloadCustomSeconds: 30,
infiniteQueueEnabled: false,
preservePlayNextOrder: false,
showArtistImages: false,
+3 -6
View File
@@ -34,11 +34,9 @@ import type {
* and writes the one-shot Linux smooth-scroll migration sentinel.
*/
export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState> {
// If both hot cache and preload were enabled before mutual exclusion was enforced, reset both.
const conflictingLegacyState =
state.hotCacheEnabled && state.preloadMode !== 'off'
? { hotCacheEnabled: false, preloadMode: 'off' as const }
: {};
// Drop removed preload-next-track settings from legacy persist blobs.
delete (state as { preloadMode?: unknown }).preloadMode;
delete (state as { preloadCustomSeconds?: unknown }).preloadCustomSeconds;
// Migrate lyricsServerFirst + enableNeteaselyrics → lyricsSources (one-time).
// Only for an *existing* persisted state (upgrade from a build without
@@ -166,7 +164,6 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
loudnessTargetLufs: targetSan,
loudnessPreAnalysisAttenuationDb: preSan,
loudnessPreIsRefV1: true,
...conflictingLegacyState,
...lyricsSourcesMigrated,
...youLyPlusMigrated,
...wheelSmoothOneTime,
-4
View File
@@ -115,8 +115,6 @@ export interface AuthState {
trackPreviewStartRatio: number;
/** Preview window length in seconds. Default 30 s. */
trackPreviewDurationSec: number;
preloadMode: 'off' | 'balanced' | 'early' | 'custom';
preloadCustomSeconds: number;
infiniteQueueEnabled: boolean;
preservePlayNextOrder: boolean;
showArtistImages: boolean;
@@ -307,8 +305,6 @@ export interface AuthState {
setTrackPreviewLocation: (location: TrackPreviewLocation, enabled: boolean) => void;
setTrackPreviewStartRatio: (v: number) => void;
setTrackPreviewDurationSec: (v: number) => void;
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void;
setPreloadCustomSeconds: (v: number) => void;
setInfiniteQueueEnabled: (v: boolean) => void;
setPreservePlayNextOrder: (v: boolean) => void;
setShowArtistImages: (v: boolean) => void;
+1 -9
View File
@@ -46,12 +46,11 @@ export function collectLoudnessBackfillWindowTrackIds(
return Array.from(ids);
}
/** Next ~5 queue neighbours (+ immediate next when preload is on) for middle-tier analysis. */
/** Next ~5 queue neighbours for middle-tier analysis priority hints. */
export function collectPlaybackMiddlePriorityTrackIds(
queue: QueueItemRef[],
queueIndex: number,
currentTrack: Track | null,
preloadMode: 'off' | 'balanced' | 'early' | 'custom',
): string[] {
const ids = new Set<string>();
const start = Math.max(0, queueIndex + 1);
@@ -60,13 +59,6 @@ export function collectPlaybackMiddlePriorityTrackIds(
const tid = queue[i]?.trackId;
if (tid && tid !== currentTrack?.id) ids.add(tid);
}
if (preloadMode !== 'off') {
const nextIdx = queueIndex + 1;
if (nextIdx >= 0 && nextIdx < queue.length) {
const tid = queue[nextIdx]?.trackId;
if (tid && tid !== currentTrack?.id) ids.add(tid);
}
}
return Array.from(ids);
}