mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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)**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user