fix(player): persist player prefs outside quota-bound queue blob (#958)

* fix(player): persist volume/repeat in dedicated localStorage key

Player prefs were bundled with the full queue blob in psysonic-player; after
thin-state #872 a large queue can exceed the quota and safeStorage silently
drops every write, so volume and repeat mode stopped surviving restarts.
Move them to psysonic_player_prefs, gate main-blob writes until rehydrate,
and sync volume to the Rust engine on startup.

* fix(player): split queue visibility and Last.fm cache from main persist blob

Move isQueueVisible and lastfmLovedCache to dedicated localStorage keys so
they keep saving when psysonic-player hits the quota on large queues. Include
the new keys in settings backup export.

* docs: note player prefs persist fix in CHANGELOG and credits (PR #958)
This commit is contained in:
cucadmuh
2026-06-03 21:55:20 +03:00
committed by GitHub
parent 40932d28e2
commit 75e2c7f9c6
10 changed files with 334 additions and 10 deletions
+9
View File
@@ -406,6 +406,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Fixed
### Player — prefs survive restart when queue persist hits quota
**By [@cucadmuh](https://github.com/cucadmuh), reported by norp on the Psysonic Discord, PR [#958](https://github.com/Psychotoxical/psysonic/pull/958)**
* Volume, repeat mode, queue panel visibility, and the Last.fm loved-track cache no longer depend on the quota-bound `psysonic-player` blob (full `queueItems` since thin-state #872). Each pref now has its own small localStorage key with legacy migration from the old blob.
* Startup no longer overwrites saved prefs before Zustand rehydration finishes; persisted volume is pushed to the Rust engine on boot.
### In-page browse — virtual scroll and cover-art priority
**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)**
+1
View File
@@ -151,6 +151,7 @@ const CONTRIBUTOR_ENTRIES = [
'Performance Probe: on-demand (ui) cover throughput alongside backfill (lib) cpm (PR #947)',
'Performance Probe: throughput (analysis tpm, cover cpm) measured over a trailing 5s window so the rate reacts promptly instead of coasting on minute-long inertia (PR #948)',
'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)',
'Player: persist volume/repeat/queue visibility/Last.fm cache outside quota-bound queue blob (report: norp on Psysonic Discord) (PR #958)',
],
},
{
@@ -17,6 +17,8 @@ export function runInitialAudioSync(): void {
// Initial sync of audio settings to Rust engine on startup.
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
const { volume } = usePlayerStore.getState();
invoke('audio_set_volume', { volume }).catch(() => {});
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
const normCfg = useAuthStore.getState();
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
persistLastfmLovedCache,
readInitialLastfmLovedCache,
} from './lastfmLovedCacheStorage';
const CACHE_KEY = 'psysonic_lastfm_loved_cache';
const LEGACY_KEY = 'psysonic-player';
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
describe('readInitialLastfmLovedCache', () => {
it('defaults to an empty object when nothing is stored', () => {
expect(readInitialLastfmLovedCache()).toEqual({});
});
it('reads the dedicated cache key and drops non-boolean entries', () => {
window.localStorage.setItem(
CACHE_KEY,
JSON.stringify({ 'Hello::Adele': true, 'Bad::': false, '': true, n: 1 }),
);
expect(readInitialLastfmLovedCache()).toEqual({ 'Hello::Adele': true, 'Bad::': false });
});
it('falls back to the legacy psysonic-player blob', () => {
window.localStorage.setItem(
LEGACY_KEY,
JSON.stringify({
state: {
lastfmLovedCache: { 'T::A': true },
queueItems: [],
},
}),
);
expect(readInitialLastfmLovedCache()).toEqual({ 'T::A': true });
});
it('prefers the dedicated key over the legacy blob', () => {
window.localStorage.setItem(CACHE_KEY, JSON.stringify({ 'A::B': false }));
window.localStorage.setItem(
LEGACY_KEY,
JSON.stringify({ state: { lastfmLovedCache: { 'A::B': true } } }),
);
expect(readInitialLastfmLovedCache()).toEqual({ 'A::B': false });
});
});
describe('persistLastfmLovedCache', () => {
it('round-trips through readInitialLastfmLovedCache', () => {
persistLastfmLovedCache({ 'Song::Artist': true });
expect(readInitialLastfmLovedCache()).toEqual({ 'Song::Artist': true });
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Last.fm loved-track cache keyed by `${title}::${artist}`. Kept out of the
* main `psysonic-player` blob so a large queue cannot block writes (thin-state
* #872 quota issue). Same split-storage pattern as `playerPrefsStorage.ts`.
*/
const CACHE_STORAGE_KEY = 'psysonic_lastfm_loved_cache';
const LEGACY_PLAYER_STORAGE_KEY = 'psysonic-player';
function sanitizeCache(raw: unknown): Record<string, boolean> {
if (!raw || typeof raw !== 'object') return {};
const out: Record<string, boolean> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (typeof key === 'string' && key.length > 0 && typeof value === 'boolean') {
out[key] = value;
}
}
return out;
}
function readLegacyCacheFromPlayerBlob(): Record<string, boolean> | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(LEGACY_PLAYER_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: { lastfmLovedCache?: unknown } };
const cache = parsed.state?.lastfmLovedCache;
if (!cache) return null;
const sanitized = sanitizeCache(cache);
return Object.keys(sanitized).length > 0 ? sanitized : null;
} catch {
return null;
}
}
export function readInitialLastfmLovedCache(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(CACHE_STORAGE_KEY);
if (raw) return sanitizeCache(JSON.parse(raw));
} catch {
// fall through to legacy blob / empty
}
return readLegacyCacheFromPlayerBlob() ?? {};
}
export function persistLastfmLovedCache(cache: Record<string, boolean>): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(sanitizeCache(cache)));
} catch {
// best-effort
}
}
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
persistPlayerPrefs,
readInitialPlayerPrefs,
} from './playerPrefsStorage';
const PREFS_KEY = 'psysonic_player_prefs';
const LEGACY_KEY = 'psysonic-player';
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
describe('readInitialPlayerPrefs', () => {
it('defaults when nothing is stored', () => {
expect(readInitialPlayerPrefs()).toEqual({ volume: 0.8, repeatMode: 'off' });
});
it('reads the dedicated prefs key', () => {
window.localStorage.setItem(PREFS_KEY, JSON.stringify({ volume: 0.42, repeatMode: 'all' }));
expect(readInitialPlayerPrefs()).toEqual({ volume: 0.42, repeatMode: 'all' });
});
it('clamps an out-of-range volume', () => {
window.localStorage.setItem(PREFS_KEY, JSON.stringify({ volume: 1.5, repeatMode: 'one' }));
expect(readInitialPlayerPrefs()).toEqual({ volume: 1, repeatMode: 'one' });
});
it('falls back to the legacy psysonic-player blob when the dedicated key is absent', () => {
window.localStorage.setItem(
LEGACY_KEY,
JSON.stringify({ state: { volume: 0.25, repeatMode: 'one', queueItems: [] } }),
);
expect(readInitialPlayerPrefs()).toEqual({ volume: 0.25, repeatMode: 'one' });
});
it('prefers the dedicated key over the legacy blob', () => {
window.localStorage.setItem(PREFS_KEY, JSON.stringify({ volume: 0.6, repeatMode: 'off' }));
window.localStorage.setItem(
LEGACY_KEY,
JSON.stringify({ state: { volume: 0.1, repeatMode: 'all' } }),
);
expect(readInitialPlayerPrefs()).toEqual({ volume: 0.6, repeatMode: 'off' });
});
});
describe('persistPlayerPrefs', () => {
it('round-trips through readInitialPlayerPrefs', () => {
persistPlayerPrefs({ volume: 0.33, repeatMode: 'all' });
expect(readInitialPlayerPrefs()).toEqual({ volume: 0.33, repeatMode: 'all' });
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Small player preferences that must survive even when the main
* `psysonic-player` Zustand blob exceeds the localStorage quota (full
* queueItems persist since thin-state #872). Same pattern as
* `queueVisibilityStorage.ts`.
*/
export type PlayerRepeatMode = 'off' | 'all' | 'one';
export type PlayerPrefs = {
volume: number;
repeatMode: PlayerRepeatMode;
};
const PREFS_STORAGE_KEY = 'psysonic_player_prefs';
const LEGACY_PLAYER_STORAGE_KEY = 'psysonic-player';
const DEFAULT_VOLUME = 0.8;
const DEFAULT_REPEAT_MODE: PlayerRepeatMode = 'off';
const REPEAT_MODES: readonly PlayerRepeatMode[] = ['off', 'all', 'one'];
function clampVolume(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_VOLUME;
return Math.max(0, Math.min(1, value));
}
function parseRepeatMode(value: unknown): PlayerRepeatMode {
if (typeof value === 'string' && (REPEAT_MODES as readonly string[]).includes(value)) {
return value as PlayerRepeatMode;
}
return DEFAULT_REPEAT_MODE;
}
function readLegacyPrefsFromPlayerBlob(): Partial<PlayerPrefs> | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(LEGACY_PLAYER_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: Partial<PlayerPrefs> };
return parsed.state ?? null;
} catch {
return null;
}
}
export function readInitialPlayerPrefs(): PlayerPrefs {
if (typeof window === 'undefined') {
return { volume: DEFAULT_VOLUME, repeatMode: DEFAULT_REPEAT_MODE };
}
try {
const raw = window.localStorage.getItem(PREFS_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<PlayerPrefs>;
return {
volume: clampVolume(typeof parsed.volume === 'number' ? parsed.volume : DEFAULT_VOLUME),
repeatMode: parseRepeatMode(parsed.repeatMode),
};
}
} catch {
// fall through to legacy blob / defaults
}
const legacy = readLegacyPrefsFromPlayerBlob();
if (legacy) {
return {
volume: clampVolume(typeof legacy.volume === 'number' ? legacy.volume : DEFAULT_VOLUME),
repeatMode: parseRepeatMode(legacy.repeatMode),
};
}
return { volume: DEFAULT_VOLUME, repeatMode: DEFAULT_REPEAT_MODE };
}
export function persistPlayerPrefs(prefs: PlayerPrefs): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(
PREFS_STORAGE_KEY,
JSON.stringify({
volume: clampVolume(prefs.volume),
repeatMode: parseRepeatMode(prefs.repeatMode),
}),
);
} catch {
// best-effort — in-memory state still works this session
}
}
+39 -9
View File
@@ -1,6 +1,8 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeJSONStorage } from './safeStorage';
import { readInitialLastfmLovedCache, persistLastfmLovedCache } from './lastfmLovedCacheStorage';
import { readInitialPlayerPrefs, persistPlayerPrefs } from './playerPrefsStorage';
import { createHydrationGatedStorage, createSafeJSONStorage } from './safeStorage';
import { emitPlaybackProgress } from './playbackProgress';
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
@@ -19,6 +21,10 @@ import { createTransportLightActions } from './transportLightActions';
import { createUiStateActions } from './uiStateActions';
import { createUndoRedoActions } from './undoRedoActions';
const initialPlayerPrefs = readInitialPlayerPrefs();
const initialLastfmLovedCache = readInitialLastfmLovedCache();
let playerPersistWritesEnabled = false;
export const usePlayerStore = create<PlayerState>()(
persist(
(set, get) => {
@@ -48,10 +54,10 @@ export const usePlayerStore = create<PlayerState>()(
progress: 0,
buffered: 0,
currentTime: 0,
volume: 0.8,
volume: initialPlayerPrefs.volume,
scrobbled: false,
lastfmLoved: false,
lastfmLovedCache: {},
lastfmLovedCache: initialLastfmLovedCache,
starredOverrides: {},
userRatingOverrides: {},
isQueueVisible: readInitialQueueVisibility(),
@@ -60,7 +66,7 @@ export const usePlayerStore = create<PlayerState>()(
scheduledPauseStartMs: null,
scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
repeatMode: 'off',
repeatMode: initialPlayerPrefs.repeatMode,
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
songInfoModal: { isOpen: false, songId: null },
@@ -85,10 +91,14 @@ export const usePlayerStore = create<PlayerState>()(
// Quota-safe: a failed persist write (huge queue > localStorage quota)
// must never throw, or it aborts the `set()` it fires from — that is what
// killed `playTrack` before `audio_play`. See safeStorage.ts.
storage: createSafeJSONStorage(),
storage: createHydrationGatedStorage(
createSafeJSONStorage(),
() => playerPersistWritesEnabled,
),
partialize: (state) => ({
volume: state.volume,
repeatMode: state.repeatMode,
// volume/repeatMode → psysonic_player_prefs; isQueueVisible →
// psysonic_queue_visible; lastfmLovedCache → psysonic_lastfm_loved_cache.
// Kept out of this blob so a huge queue cannot block their writes.
currentTrack: state.currentTrack,
queueServerId: state.queueServerId,
// Thin-state: persist the whole ordered ref list (tiny) — no windowed
@@ -97,12 +107,10 @@ export const usePlayerStore = create<PlayerState>()(
// `hydrateQueueFromIndex` the refs still need a full resolve.
queueItems: state.queueItems,
queueItemsIndex: state.queueIndex,
isQueueVisible: state.isQueueVisible,
// currentTime is intentionally NOT persisted here.
// handleAudioProgress fires every 100ms and each setState with a
// persisted field triggers a full JSON serialisation to localStorage.
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
lastfmLovedCache: state.lastfmLovedCache,
}),
// Rebuild `queueItems` from ANY older persisted blob shape so an upgrade
// restores the queue. Order of preference: an existing `queueItems` ref
@@ -145,6 +153,12 @@ export const usePlayerStore = create<PlayerState>()(
// Drop the obsolete windowed fat-array key — `queueItems` is canonical.
delete blob.queue;
// volume/repeatMode are owned by `psysonic_player_prefs`; strip any legacy
// fields so an old blob cannot clobber the dedicated prefs on rehydrate.
delete blob.volume;
delete blob.repeatMode;
delete blob.isQueueVisible;
delete blob.lastfmLovedCache;
// Persist the canonical form back onto the merged blob so subsequent
// reads of state.queueServerId always see the index key.
if (canonicalSid !== null) {
@@ -162,6 +176,22 @@ export const usePlayerStore = create<PlayerState>()(
)
);
usePlayerStore.persist.onHydrate(() => {
playerPersistWritesEnabled = false;
});
usePlayerStore.persist.onFinishHydration(() => {
playerPersistWritesEnabled = true;
});
usePlayerStore.subscribe((state, prev) => {
if (state.volume !== prev.volume || state.repeatMode !== prev.repeatMode) {
persistPlayerPrefs({ volume: state.volume, repeatMode: state.repeatMode });
}
if (state.lastfmLovedCache !== prev.lastfmLovedCache) {
persistLastfmLovedCache(state.lastfmLovedCache);
}
});
usePlayerStore.subscribe((state, prev) => {
if (
state.currentTime === prev.currentTime &&
+22 -1
View File
@@ -1,4 +1,4 @@
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
import { createJSONStorage, type PersistStorage, type StateStorage } from 'zustand/middleware';
/**
* `localStorage` wrapped so a failed write never throws.
@@ -56,3 +56,24 @@ const safeLocalStorage: StateStorage = {
* never throw. Use for any persist store whose slice can grow unbounded.
*/
export const createSafeJSONStorage = <S>() => createJSONStorage<S>(() => safeLocalStorage);
/**
* Wraps a persist storage so `setItem` is a no-op until the store finishes its
* first hydration. Zustand v5 persists on every `setState`; without this gate,
* startup side effects (e.g. `runInitialAudioSync`) can overwrite the saved
* blob with in-memory defaults before async rehydration merges localStorage.
*/
export function createHydrationGatedStorage<S>(
base: PersistStorage<S> | undefined,
isWriteAllowed: () => boolean,
): PersistStorage<S> | undefined {
if (!base) return undefined;
return {
getItem: base.getItem.bind(base),
removeItem: base.removeItem.bind(base),
setItem: (name, value) => {
if (!isWriteAllowed()) return;
return base.setItem(name, value);
},
};
}
+3
View File
@@ -17,6 +17,9 @@ const BACKUP_KEYS = [
'psysonic-eq',
'psysonic_global_shortcuts',
'psysonic-player',
'psysonic_player_prefs',
'psysonic_queue_visible',
'psysonic_lastfm_loved_cache',
'psysonic_home',
];