mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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
|
## 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
|
### In-page browse — virtual scroll and cover-art priority
|
||||||
|
|
||||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)**
|
**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: 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)',
|
'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)',
|
'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.
|
// Initial sync of audio settings to Rust engine on startup.
|
||||||
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
|
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_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
|
||||||
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
|
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
|
||||||
const normCfg = useAuthStore.getState();
|
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 { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
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 { emitPlaybackProgress } from './playbackProgress';
|
||||||
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
|
import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
|
||||||
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
@@ -19,6 +21,10 @@ import { createTransportLightActions } from './transportLightActions';
|
|||||||
import { createUiStateActions } from './uiStateActions';
|
import { createUiStateActions } from './uiStateActions';
|
||||||
import { createUndoRedoActions } from './undoRedoActions';
|
import { createUndoRedoActions } from './undoRedoActions';
|
||||||
|
|
||||||
|
const initialPlayerPrefs = readInitialPlayerPrefs();
|
||||||
|
const initialLastfmLovedCache = readInitialLastfmLovedCache();
|
||||||
|
let playerPersistWritesEnabled = false;
|
||||||
|
|
||||||
export const usePlayerStore = create<PlayerState>()(
|
export const usePlayerStore = create<PlayerState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => {
|
(set, get) => {
|
||||||
@@ -48,10 +54,10 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
progress: 0,
|
progress: 0,
|
||||||
buffered: 0,
|
buffered: 0,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
volume: 0.8,
|
volume: initialPlayerPrefs.volume,
|
||||||
scrobbled: false,
|
scrobbled: false,
|
||||||
lastfmLoved: false,
|
lastfmLoved: false,
|
||||||
lastfmLovedCache: {},
|
lastfmLovedCache: initialLastfmLovedCache,
|
||||||
starredOverrides: {},
|
starredOverrides: {},
|
||||||
userRatingOverrides: {},
|
userRatingOverrides: {},
|
||||||
isQueueVisible: readInitialQueueVisibility(),
|
isQueueVisible: readInitialQueueVisibility(),
|
||||||
@@ -60,7 +66,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
scheduledPauseStartMs: null,
|
scheduledPauseStartMs: null,
|
||||||
scheduledResumeAtMs: null,
|
scheduledResumeAtMs: null,
|
||||||
scheduledResumeStartMs: null,
|
scheduledResumeStartMs: null,
|
||||||
repeatMode: 'off',
|
repeatMode: initialPlayerPrefs.repeatMode,
|
||||||
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
|
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
|
||||||
songInfoModal: { isOpen: false, songId: 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)
|
// Quota-safe: a failed persist write (huge queue > localStorage quota)
|
||||||
// must never throw, or it aborts the `set()` it fires from — that is what
|
// must never throw, or it aborts the `set()` it fires from — that is what
|
||||||
// killed `playTrack` before `audio_play`. See safeStorage.ts.
|
// killed `playTrack` before `audio_play`. See safeStorage.ts.
|
||||||
storage: createSafeJSONStorage(),
|
storage: createHydrationGatedStorage(
|
||||||
|
createSafeJSONStorage(),
|
||||||
|
() => playerPersistWritesEnabled,
|
||||||
|
),
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
volume: state.volume,
|
// volume/repeatMode → psysonic_player_prefs; isQueueVisible →
|
||||||
repeatMode: state.repeatMode,
|
// psysonic_queue_visible; lastfmLovedCache → psysonic_lastfm_loved_cache.
|
||||||
|
// Kept out of this blob so a huge queue cannot block their writes.
|
||||||
currentTrack: state.currentTrack,
|
currentTrack: state.currentTrack,
|
||||||
queueServerId: state.queueServerId,
|
queueServerId: state.queueServerId,
|
||||||
// Thin-state: persist the whole ordered ref list (tiny) — no windowed
|
// 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.
|
// `hydrateQueueFromIndex` the refs still need a full resolve.
|
||||||
queueItems: state.queueItems,
|
queueItems: state.queueItems,
|
||||||
queueItemsIndex: state.queueIndex,
|
queueItemsIndex: state.queueIndex,
|
||||||
isQueueVisible: state.isQueueVisible,
|
|
||||||
// currentTime is intentionally NOT persisted here.
|
// currentTime is intentionally NOT persisted here.
|
||||||
// handleAudioProgress fires every 100ms and each setState with a
|
// handleAudioProgress fires every 100ms and each setState with a
|
||||||
// persisted field triggers a full JSON serialisation to localStorage.
|
// persisted field triggers a full JSON serialisation to localStorage.
|
||||||
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
|
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
|
||||||
lastfmLovedCache: state.lastfmLovedCache,
|
|
||||||
}),
|
}),
|
||||||
// Rebuild `queueItems` from ANY older persisted blob shape so an upgrade
|
// Rebuild `queueItems` from ANY older persisted blob shape so an upgrade
|
||||||
// restores the queue. Order of preference: an existing `queueItems` ref
|
// 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.
|
// Drop the obsolete windowed fat-array key — `queueItems` is canonical.
|
||||||
delete blob.queue;
|
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
|
// Persist the canonical form back onto the merged blob so subsequent
|
||||||
// reads of state.queueServerId always see the index key.
|
// reads of state.queueServerId always see the index key.
|
||||||
if (canonicalSid !== null) {
|
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) => {
|
usePlayerStore.subscribe((state, prev) => {
|
||||||
if (
|
if (
|
||||||
state.currentTime === prev.currentTime &&
|
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.
|
* `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.
|
* never throw. Use for any persist store whose slice can grow unbounded.
|
||||||
*/
|
*/
|
||||||
export const createSafeJSONStorage = <S>() => createJSONStorage<S>(() => safeLocalStorage);
|
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-eq',
|
||||||
'psysonic_global_shortcuts',
|
'psysonic_global_shortcuts',
|
||||||
'psysonic-player',
|
'psysonic-player',
|
||||||
|
'psysonic_player_prefs',
|
||||||
|
'psysonic_queue_visible',
|
||||||
|
'psysonic_lastfm_loved_cache',
|
||||||
'psysonic_home',
|
'psysonic_home',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user