diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8538ed..632a067d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)** diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 73735beb..2736b65c 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/store/audioListenerSetup/initialAudioSync.ts b/src/store/audioListenerSetup/initialAudioSync.ts index d1f39f3f..4b8cb77d 100644 --- a/src/store/audioListenerSetup/initialAudioSync.ts +++ b/src/store/audioListenerSetup/initialAudioSync.ts @@ -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(); diff --git a/src/store/lastfmLovedCacheStorage.test.ts b/src/store/lastfmLovedCacheStorage.test.ts new file mode 100644 index 00000000..5de0e1c1 --- /dev/null +++ b/src/store/lastfmLovedCacheStorage.test.ts @@ -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 }); + }); +}); diff --git a/src/store/lastfmLovedCacheStorage.ts b/src/store/lastfmLovedCacheStorage.ts new file mode 100644 index 00000000..8a454a8e --- /dev/null +++ b/src/store/lastfmLovedCacheStorage.ts @@ -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 { + if (!raw || typeof raw !== 'object') return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + if (typeof key === 'string' && key.length > 0 && typeof value === 'boolean') { + out[key] = value; + } + } + return out; +} + +function readLegacyCacheFromPlayerBlob(): Record | 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 { + 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): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(sanitizeCache(cache))); + } catch { + // best-effort + } +} diff --git a/src/store/playerPrefsStorage.test.ts b/src/store/playerPrefsStorage.test.ts new file mode 100644 index 00000000..6da75d9d --- /dev/null +++ b/src/store/playerPrefsStorage.test.ts @@ -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' }); + }); +}); diff --git a/src/store/playerPrefsStorage.ts b/src/store/playerPrefsStorage.ts new file mode 100644 index 00000000..ef3cd2f2 --- /dev/null +++ b/src/store/playerPrefsStorage.ts @@ -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 | 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 }; + 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; + 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 + } +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 258fb057..e676266c 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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()( persist( (set, get) => { @@ -48,10 +54,10 @@ export const usePlayerStore = create()( 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()( 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()( // 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()( // `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()( // 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()( ) ); +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 && diff --git a/src/store/safeStorage.ts b/src/store/safeStorage.ts index d2855509..ff65732f 100644 --- a/src/store/safeStorage.ts +++ b/src/store/safeStorage.ts @@ -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 = () => createJSONStorage(() => 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( + base: PersistStorage | undefined, + isWriteAllowed: () => boolean, +): PersistStorage | 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); + }, + }; +} diff --git a/src/utils/export/backup.ts b/src/utils/export/backup.ts index 569221db..edaf5589 100644 --- a/src/utils/export/backup.ts +++ b/src/utils/export/backup.ts @@ -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', ];