Files
psysonic/src/store/authStore.ts
T
Frank Stellmacher 98ff73d17a feat(perf): 3-state animation mode (Full / Reduced / Static) (#441)
* feat(perf): 3-state animation mode (Full / Reduced / Static)

Replaces the boolean `reducedAnimations` toggle with a three-way
`animationMode` setting, suggested by Viktor Petrovich after the
Windows audio fix (PR #426) shipped and confirmed a measurable
GPU drop:

- `full` (default): native frame rate, marquee scrolls normally
- `reduced`: 30 fps cap on the animated seekbar wave; player
  marquee runs at half speed
- `static`: rAF loop disabled; the seekbar repaints from the
  ~2 Hz audio:progress heartbeat. Player title/artist truncate
  with ellipsis instead of scrolling.

Migration in `onRehydrateStorage` maps legacy
`reducedAnimations: true` to `'reduced'`, anything else to
`'full'`. Static is opt-in only.

Settings UI follows the ReplayGain Auto/Track/Album pattern with
a contextual hint that explains what each mode does.

i18n: 5 new keys across 8 locales, 2 legacy keys removed.

* docs(changelog): add #441 3-state animation mode entry

* chore(credits): add #441 to Psychotoxical contributions
2026-05-03 14:12:27 +02:00

867 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import type { EntityRatingSupportLevel } from '../api/subsonic';
import {
isNavidromeAudiomuseSoftwareEligible,
type InstantMixProbeResult,
type SubsonicServerIdentity,
} from '../utils/subsonicServerIdentity';
import { usePlayerStore } from './playerStore';
import { IS_LINUX } from '../utils/platform';
import {
LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS,
clampStoredLoudnessPreAnalysisAttenuationRefDb,
} from '../utils/loudnessPreAnalysisSlider';
export interface ServerProfile {
id: string;
name: string;
url: string;
username: string;
password: string;
}
export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
export type LoggingMode = 'off' | 'normal' | 'debug';
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
export type AnimationMode = 'full' | 'reduced' | 'static';
/** Integrated-loudness target presets (Settings + analysis). */
export type LoudnessLufsPreset = -16 | -14 | -12 | -10;
const LOUDNESS_LUFS_PRESETS: LoudnessLufsPreset[] = [-16, -14, -12, -10];
/** Settings default + Rust engine cold default until `audio_set_normalization` runs. */
export const DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB = -4.5;
function sanitizeLoudnessLufsPreset(v: unknown, fallback: LoudnessLufsPreset): LoudnessLufsPreset {
return (LOUDNESS_LUFS_PRESETS as readonly number[]).includes(v as number)
? (v as LoudnessLufsPreset)
: fallback;
}
function sanitizeLoudnessPreAnalysisFromStorage(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB;
return clampStoredLoudnessPreAnalysisAttenuationRefDb(n);
}
export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
export type TrackPreviewLocation =
| 'suggestions'
| 'albums'
| 'playlists'
| 'favorites'
| 'artist'
| 'randomMix';
export type TrackPreviewLocations = Record<TrackPreviewLocation, boolean>;
export const TRACK_PREVIEW_LOCATIONS: readonly TrackPreviewLocation[] = [
'suggestions',
'albums',
'playlists',
'favorites',
'artist',
'randomMix',
];
const DEFAULT_TRACK_PREVIEW_LOCATIONS: TrackPreviewLocations = {
suggestions: true,
albums: true,
playlists: true,
favorites: true,
artist: true,
randomMix: true,
};
const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [
{ id: 'server', enabled: true },
{ id: 'lrclib', enabled: true },
{ id: 'netease', enabled: false },
];
interface AuthState {
// Multi-server
servers: ServerProfile[];
activeServerId: string | null;
// Last.fm (global)
lastfmApiKey: string;
lastfmApiSecret: string;
lastfmSessionKey: string;
lastfmUsername: string;
// Settings (global)
scrobblingEnabled: boolean;
maxCacheMb: number;
downloadFolder: string;
offlineDownloadDir: string;
excludeAudiobooks: boolean;
customGenreBlacklist: string[];
replayGainEnabled: boolean;
normalizationEngine: NormalizationEngine;
loudnessTargetLufs: LoudnessLufsPreset;
/**
* dB extra quieting until loudness is saved, **calibrated for 14 LUFS** target; engine applies
* `+ (loudnessTargetLufs - (14))` for other targets. See `effectiveLoudnessPreAnalysisAttenuationDb`.
*/
loudnessPreAnalysisAttenuationDb: number;
/** Persisted: stored pre is ref @ 14 (v1+); legacy falsey entries migrate once in onRehydrate. */
loudnessPreIsRefV1?: boolean;
replayGainMode: 'track' | 'album' | 'auto';
replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB)
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
/** Show inline Play+Preview buttons in tracklists. Default on per Q3. Master kill switch — when off, all locations are off. */
trackPreviewsEnabled: boolean;
/** Per-location toggles. Only honoured when `trackPreviewsEnabled` is true. */
trackPreviewLocations: TrackPreviewLocations;
/** Mid-track start position as a 0…1 ratio. Default 0.33 = 33%. */
trackPreviewStartRatio: number;
/** Preview window length in seconds. Default 30 s. */
trackPreviewDurationSec: number;
preloadMode: 'off' | 'balanced' | 'early' | 'custom';
preloadCustomSeconds: number;
infiniteQueueEnabled: boolean;
showArtistImages: boolean;
showTrayIcon: boolean;
minimizeToTray: boolean;
/** Whether the "Orbit" topbar trigger is rendered. Users who never
* touch Orbit can hide it so the header stays uncluttered. */
showOrbitTrigger: boolean;
discordRichPresence: boolean;
enableAppleMusicCoversDiscord: boolean;
/** Opt-in: fetch upcoming tour dates from Bandsintown for the Now-Playing info panel. */
enableBandsintown: boolean;
discordTemplateDetails: string;
discordTemplateState: string;
discordTemplateLargeText: string;
useCustomTitlebar: boolean;
/** Pre-build the mini-player webview at app start on Linux/macOS so content is available instantly
* on first open. Ignored on Windows — that platform always pre-creates as a hang workaround. */
preloadMiniPlayer: boolean;
/** Linux WebKitGTK: smooth wheel on when true; off only after explicit opt-out in Settings. */
linuxWebkitKineticScroll: boolean;
/** Runtime backend logging level. */
loggingMode: LoggingMode;
nowPlayingEnabled: boolean;
lyricsServerFirst: boolean;
enableNeteaselyrics: boolean;
lyricsSources: LyricsSourceConfig[];
/**
* `'standard'` → server + lrclib + netease pipeline (configurable order).
* `'lyricsplus'` → YouLyPlus / lyricsplus first, silent fallback to standard
* pipeline when no data is returned.
*/
lyricsMode: 'standard' | 'lyricsplus';
/**
* Render synced lines as static text (no auto-scroll, no word highlighting).
* Honoured in both lyrics modes.
*/
lyricsStaticOnly: boolean;
showFullscreenLyrics: boolean;
/** 'rail' = classic 5-line sliding rail; 'apple' = full-screen scrolling list */
fsLyricsStyle: 'rail' | 'apple';
/** Sidebar lyrics scroll style: 'classic' = scrollIntoView center; 'apple' = scroll to 35% */
sidebarLyricsStyle: 'classic' | 'apple';
showFsArtistPortrait: boolean;
/** Portrait dimming 0100 (percent), applied as CSS rgba alpha */
fsPortraitDim: number;
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
seekbarStyle: SeekbarStyle;
/** Animation budget across the UI:
* - `full` = native frame rate
* - `reduced`= 30 fps cap on animated seekbar; marquee at half frame rate
* - `static` = ~1 fps progress (no rAF interpolation); marquee disabled (truncate)
*/
animationMode: AnimationMode;
/** Persisted UI toggle: is the Now Playing section in queue panel collapsed */
queueNowPlayingCollapsed: boolean;
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
enableHiRes: boolean;
/** Selected audio output device name. null = system default. */
audioOutputDevice: string | null;
/** Alpha: ephemeral queue prefetch cache on disk */
hotCacheEnabled: boolean;
hotCacheMaxMb: number;
hotCacheDebounceSec: number;
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
hotCacheDownloadDir: string;
/** After this many manual skips of the same track, set track rating to 1 if still unrated (below 1 star). */
skipStarOnManualSkipsEnabled: boolean;
/** Manual skips per track before applying rating 1 (when enabled). */
skipStarManualSkipThreshold: number;
/**
* Manual Next-count per track for skip→1★. Key = `${serverId}\\u001f${trackId}`
* (empty serverId when none). Persisted; cleared when the track finishes naturally or when threshold is reached.
*/
skipStarManualSkipCountsByKey: Record<string, number>;
/** Increment skip count for current server + track; clears stored count when threshold reached. */
recordSkipStarManualAdvance: (trackId: string) => { crossedThreshold: boolean } | null;
/** Drop persisted skip count for this track on the active server (e.g. natural playback end). */
clearSkipStarManualCountForTrack: (trackId: string) => void;
/** Random mixes, random albums, home hero: drop nonzero ratings at or below peraxis thresholds (0 = unrated, kept). */
mixMinRatingFilterEnabled: boolean;
/** 0 = ignore; 13 = cutoff (UI); exclude track rating r when 0 < r ≤ cutoff. */
mixMinRatingSong: number;
/** 0 = ignore; album entity rating from payload or `getAlbum` when missing. */
mixMinRatingAlbum: number;
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
mixMinRatingArtist: number;
/** Show "Lucky Mix" as a regular sidebar/menu item. */
showLuckyMixMenu: boolean;
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
musicFolders: Array<{ id: string; name: string }>;
/**
* Per server: `all` = no musicFolderId param; otherwise a single folder id.
* Only one library or all — no multi-folder merge.
*/
musicLibraryFilterByServer: Record<string, 'all' | string>;
/** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */
musicLibraryFilterVersion: number;
/**
* Per server: whether `setRating` is assumed to work for album/artist ids (OpenSubsonic-style).
* Absent key = not probed yet (`unknown` in UI).
*/
entityRatingSupportByServer: Record<string, EntityRatingSupportLevel>;
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
/**
* Per server: Navidrome has the AudioMuse-AI plugin — use `getSimilarSongs` (Instant Mix) and
* `getArtistInfo2` similar artists instead of Last.fm for discovery on this server.
*/
audiomuseNavidromeByServer: Record<string, boolean>;
setAudiomuseNavidromeEnabled: (serverId: string, enabled: boolean) => void;
/** From `ping` — used to show the AudioMuse toggle only on Navidrome ≥ 0.60. */
subsonicServerIdentityByServer: Record<string, SubsonicServerIdentity>;
setSubsonicServerIdentity: (serverId: string, identity: SubsonicServerIdentity) => void;
/** Instant Mix / similar path failed while this server had AudioMuse enabled (cleared on success or toggle off). */
audiomuseNavidromeIssueByServer: Record<string, boolean>;
setAudiomuseNavidromeIssue: (serverId: string, hasIssue: boolean) => void;
/**
* `getSimilarSongs` probe per server (after ping). `empty` hides the AudioMuse row; re-run by testing connection.
*/
instantMixProbeByServer: Record<string, InstantMixProbeResult>;
setInstantMixProbe: (serverId: string, result: InstantMixProbeResult) => void;
// Status
isLoggedIn: boolean;
isConnecting: boolean;
connectionError: string | null;
lastfmSessionError: boolean;
// Actions
addServer: (profile: Omit<ServerProfile, 'id'>) => string;
updateServer: (id: string, data: Partial<Omit<ServerProfile, 'id'>>) => void;
removeServer: (id: string) => void;
setServers: (servers: ServerProfile[]) => void;
setActiveServer: (id: string) => void;
setLoggedIn: (v: boolean) => void;
setConnecting: (v: boolean) => void;
setConnectionError: (e: string | null) => void;
setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void;
connectLastfm: (sessionKey: string, username: string) => void;
disconnectLastfm: () => void;
setLastfmSessionError: (v: boolean) => void;
setScrobblingEnabled: (v: boolean) => void;
setMaxCacheMb: (v: number) => void;
setDownloadFolder: (v: string) => void;
setOfflineDownloadDir: (v: string) => void;
setExcludeAudiobooks: (v: boolean) => void;
setCustomGenreBlacklist: (v: string[]) => void;
setReplayGainEnabled: (v: boolean) => void;
setNormalizationEngine: (v: NormalizationEngine) => void;
setLoudnessTargetLufs: (v: LoudnessLufsPreset) => void;
setLoudnessPreAnalysisAttenuationDb: (v: number) => void;
resetLoudnessPreAnalysisAttenuationDbDefault: () => void;
setReplayGainMode: (v: 'track' | 'album' | 'auto') => void;
setReplayGainPreGainDb: (v: number) => void;
setReplayGainFallbackDb: (v: number) => void;
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setTrackPreviewsEnabled: (v: boolean) => void;
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;
setShowArtistImages: (v: boolean) => void;
setShowTrayIcon: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setShowOrbitTrigger: (v: boolean) => void;
setDiscordRichPresence: (v: boolean) => void;
setEnableAppleMusicCoversDiscord: (v: boolean) => void;
setEnableBandsintown: (v: boolean) => void;
setDiscordTemplateDetails: (v: string) => void;
setDiscordTemplateState: (v: string) => void;
setDiscordTemplateLargeText: (v: string) => void;
setUseCustomTitlebar: (v: boolean) => void;
setPreloadMiniPlayer: (v: boolean) => void;
setLinuxWebkitKineticScroll: (v: boolean) => void;
setLoggingMode: (v: LoggingMode) => void;
setNowPlayingEnabled: (v: boolean) => void;
setLyricsServerFirst: (v: boolean) => void;
setEnableNeteaselyrics: (v: boolean) => void;
setLyricsSources: (sources: LyricsSourceConfig[]) => void;
setLyricsMode: (v: 'standard' | 'lyricsplus') => void;
setLyricsStaticOnly: (v: boolean) => void;
setShowFullscreenLyrics: (v: boolean) => void;
setFsLyricsStyle: (v: 'rail' | 'apple') => void;
setSidebarLyricsStyle: (v: 'classic' | 'apple') => void;
setShowFsArtistPortrait: (v: boolean) => void;
setFsPortraitDim: (v: number) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
setSeekbarStyle: (v: SeekbarStyle) => void;
setAnimationMode: (v: AnimationMode) => void;
setQueueNowPlayingCollapsed: (v: boolean) => void;
setEnableHiRes: (v: boolean) => void;
setAudioOutputDevice: (v: string | null) => void;
setHotCacheEnabled: (v: boolean) => void;
setHotCacheMaxMb: (v: number) => void;
setHotCacheDebounceSec: (v: number) => void;
setHotCacheDownloadDir: (v: string) => void;
setSkipStarOnManualSkipsEnabled: (v: boolean) => void;
setSkipStarManualSkipThreshold: (v: number) => void;
setMixMinRatingFilterEnabled: (v: boolean) => void;
setMixMinRatingSong: (v: number) => void;
setMixMinRatingAlbum: (v: number) => void;
setMixMinRatingArtist: (v: number) => void;
setShowLuckyMixMenu: (v: boolean) => void;
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
setMusicLibraryFilter: (folderId: 'all' | string) => void;
/** Navigation style for Mix pages: single hub ('hub') or separate sidebar entries ('separate'). */
randomNavMode: 'hub' | 'separate';
setRandomNavMode: (v: 'hub' | 'separate') => void;
logout: () => void;
// Derived
getBaseUrl: () => string;
getActiveServer: () => ServerProfile | undefined;
}
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
/** Upper bound for mix min-rating thresholds (UI shows five stars, only 1…this many are selectable). */
export const MIX_MIN_RATING_FILTER_MAX_STARS = 3;
function clampMixFilterMinStars(v: number): number {
if (!Number.isFinite(v)) return 0;
return Math.max(0, Math.min(MIX_MIN_RATING_FILTER_MAX_STARS, Math.round(v)));
}
function clampSkipStarThreshold(v: number): number {
if (!Number.isFinite(v)) return 3;
return Math.max(1, Math.min(99, Math.round(v)));
}
function skipStarCountStorageKey(serverId: string | null | undefined, trackId: string): string {
return `${serverId ?? ''}\u001f${trackId}`;
}
function sanitizeSkipStarCounts(raw: unknown): Record<string, number> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const next: Record<string, number> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
const n = Number(v);
if (Number.isFinite(n) && n > 0) next[k] = Math.min(Math.floor(n), 1_000_000);
}
return next;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
servers: [],
activeServerId: null,
lastfmApiKey: '',
lastfmApiSecret: '',
lastfmSessionKey: '',
lastfmUsername: '',
scrobblingEnabled: true,
maxCacheMb: 500,
downloadFolder: '',
offlineDownloadDir: '',
excludeAudiobooks: false,
customGenreBlacklist: [],
replayGainEnabled: false,
normalizationEngine: 'off',
loudnessTargetLufs: -12,
loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
loudnessPreIsRefV1: true,
replayGainMode: 'auto',
replayGainPreGainDb: 0,
replayGainFallbackDb: 0,
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
trackPreviewsEnabled: true,
trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS },
trackPreviewStartRatio: 0.33,
trackPreviewDurationSec: 30,
preloadMode: 'balanced',
preloadCustomSeconds: 30,
infiniteQueueEnabled: false,
showArtistImages: false,
showTrayIcon: true,
minimizeToTray: false,
showOrbitTrigger: true,
discordRichPresence: false,
enableAppleMusicCoversDiscord: false,
enableBandsintown: false,
discordTemplateDetails: '{artist} - {title}',
discordTemplateState: '{album}',
discordTemplateLargeText: '{album}',
useCustomTitlebar: false,
preloadMiniPlayer: false,
linuxWebkitKineticScroll: true,
loggingMode: 'normal',
nowPlayingEnabled: false,
lyricsServerFirst: true,
enableNeteaselyrics: false,
lyricsSources: DEFAULT_LYRICS_SOURCES,
lyricsMode: 'standard',
lyricsStaticOnly: false,
showFullscreenLyrics: true,
fsLyricsStyle: 'rail',
sidebarLyricsStyle: 'classic',
showFsArtistPortrait: true,
fsPortraitDim: 28,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
seekbarStyle: 'truewave',
animationMode: 'full',
queueNowPlayingCollapsed: false,
enableHiRes: false,
audioOutputDevice: null,
hotCacheEnabled: false,
hotCacheMaxMb: 256,
hotCacheDebounceSec: 30,
hotCacheDownloadDir: '',
skipStarOnManualSkipsEnabled: false,
skipStarManualSkipThreshold: 3,
skipStarManualSkipCountsByKey: {},
mixMinRatingFilterEnabled: false,
mixMinRatingSong: 0,
mixMinRatingAlbum: 0,
mixMinRatingArtist: 0,
showLuckyMixMenu: true,
randomNavMode: 'hub',
musicFolders: [],
musicLibraryFilterByServer: {},
musicLibraryFilterVersion: 0,
entityRatingSupportByServer: {},
audiomuseNavidromeByServer: {},
subsonicServerIdentityByServer: {},
audiomuseNavidromeIssueByServer: {},
instantMixProbeByServer: {},
isLoggedIn: false,
isConnecting: false,
connectionError: null,
lastfmSessionError: false,
addServer: (profile) => {
const id = generateId();
set(s => ({ servers: [...s.servers, { ...profile, id }] }));
return id;
},
updateServer: (id, data) => {
set(s => ({
servers: s.servers.map(srv => srv.id === id ? { ...srv, ...data } : srv),
}));
},
removeServer: (id) => {
set(s => {
const newServers = s.servers.filter(srv => srv.id !== id);
const switchedAway = s.activeServerId === id;
const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer;
const { [id]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
const { [id]: _idn, ...identityRest } = s.subsonicServerIdentityByServer;
const { [id]: _iss, ...issueRest } = s.audiomuseNavidromeIssueByServer;
const { [id]: _pr, ...probeRest } = s.instantMixProbeByServer;
return {
servers: newServers,
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
isLoggedIn: switchedAway ? false : s.isLoggedIn,
entityRatingSupportByServer: entityRatingRest,
audiomuseNavidromeByServer: audiomuseRest,
subsonicServerIdentityByServer: identityRest,
audiomuseNavidromeIssueByServer: issueRest,
instantMixProbeByServer: probeRest,
};
});
},
setServers: (servers) => set({ servers }),
setActiveServer: (id) => set({ activeServerId: id, musicFolders: [] }),
setLoggedIn: (v) => set({ isLoggedIn: v }),
setConnecting: (v) => set({ isConnecting: v }),
setConnectionError: (e) => set({ connectionError: e }),
setLastfm: (apiKey, apiSecret, sessionKey, username) =>
set({ lastfmApiKey: apiKey, lastfmApiSecret: apiSecret, lastfmSessionKey: sessionKey, lastfmUsername: username }),
connectLastfm: (sessionKey, username) =>
set({ lastfmSessionKey: sessionKey, lastfmUsername: username }),
disconnectLastfm: () =>
set({ lastfmSessionKey: '', lastfmUsername: '', lastfmSessionError: false }),
setLastfmSessionError: (v) => set({ lastfmSessionError: v }),
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
setDownloadFolder: (v) => set({ downloadFolder: v }),
setOfflineDownloadDir: (v) => set({ offlineDownloadDir: v }),
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
setReplayGainEnabled: (v) => {
set({ replayGainEnabled: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setNormalizationEngine: (v) => {
set({ normalizationEngine: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setLoudnessTargetLufs: (v) => {
set({ loudnessTargetLufs: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setLoudnessPreAnalysisAttenuationDb: (v) => {
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return;
set({ loudnessPreAnalysisAttenuationDb: clampStoredLoudnessPreAnalysisAttenuationRefDb(n) });
},
resetLoudnessPreAnalysisAttenuationDbDefault: () => {
set({ loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setReplayGainMode: (v) => {
set({ replayGainMode: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setReplayGainPreGainDb: (v) => {
set({ replayGainPreGainDb: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setReplayGainFallbackDb: (v) => {
set({ replayGainFallbackDb: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setTrackPreviewsEnabled: (v) => set({ trackPreviewsEnabled: !!v }),
setTrackPreviewLocation: (location, enabled) => set(state => ({
trackPreviewLocations: { ...state.trackPreviewLocations, [location]: !!enabled },
})),
setTrackPreviewStartRatio: (v) => set({ trackPreviewStartRatio: Math.max(0, Math.min(0.9, v)) }),
setTrackPreviewDurationSec: (v) => set({ trackPreviewDurationSec: Math.max(5, Math.min(120, Math.round(v))) }),
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
setShowArtistImages: (v) => set({ showArtistImages: v }),
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }),
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }),
setEnableBandsintown: (v) => set({ enableBandsintown: v }),
setDiscordTemplateDetails: (v) => set({ discordTemplateDetails: v }),
setDiscordTemplateState: (v) => set({ discordTemplateState: v }),
setDiscordTemplateLargeText: (v) => set({ discordTemplateLargeText: v }),
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }),
setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }),
setLoggingMode: (v) => set({ loggingMode: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
setLyricsSources: (sources) => set({ lyricsSources: sources }),
setLyricsMode: (v) => set({ lyricsMode: v }),
setLyricsStaticOnly: (v) => set({ lyricsStaticOnly: v }),
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }),
setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }),
setShowFsArtistPortrait: (v: boolean) => set({ showFsArtistPortrait: v }),
setFsPortraitDim: (v: number) => set({ fsPortraitDim: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
setSeekbarStyle: (v) => set({ seekbarStyle: v }),
setAnimationMode: (v) => set({ animationMode: v }),
setQueueNowPlayingCollapsed: (v: boolean) => set({ queueNowPlayingCollapsed: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }),
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
setSkipStarOnManualSkipsEnabled: (v) =>
set({
skipStarOnManualSkipsEnabled: v,
...(v ? {} : { skipStarManualSkipCountsByKey: {} }),
}),
setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }),
recordSkipStarManualAdvance: (trackId: string) => {
const s = get();
if (!s.skipStarOnManualSkipsEnabled || s.skipStarManualSkipThreshold < 1) return null;
const key = skipStarCountStorageKey(s.activeServerId, trackId);
const prev = s.skipStarManualSkipCountsByKey[key] ?? 0;
const threshold = s.skipStarManualSkipThreshold;
const next = prev + 1;
if (next >= threshold) {
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
set({ skipStarManualSkipCountsByKey: rest });
return { crossedThreshold: true };
}
set({
skipStarManualSkipCountsByKey: { ...s.skipStarManualSkipCountsByKey, [key]: next },
});
return { crossedThreshold: false };
},
clearSkipStarManualCountForTrack: (trackId: string) => {
const s = get();
const key = skipStarCountStorageKey(s.activeServerId, trackId);
if (s.skipStarManualSkipCountsByKey[key] === undefined) return;
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
set({ skipStarManualSkipCountsByKey: rest });
},
setMixMinRatingFilterEnabled: (v) => set({ mixMinRatingFilterEnabled: v }),
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
setShowLuckyMixMenu: (v) => set({ showLuckyMixMenu: v }),
setRandomNavMode: (v) => set({ randomNavMode: v }),
setMusicFolders: (folders) => {
const sid = get().activeServerId;
set(s => {
const f = sid ? s.musicLibraryFilterByServer[sid] : undefined;
const invalidFilter = f && f !== 'all' && !folders.some(x => x.id === f);
return {
musicFolders: folders,
...(sid && invalidFilter
? { musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: 'all' } }
: {}),
};
});
},
setMusicLibraryFilter: (folderId) => {
const sid = get().activeServerId;
if (!sid) return;
set(s => ({
musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: folderId },
musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1,
}));
},
setEntityRatingSupport: (serverId, level) =>
set(s => ({
entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level },
})),
setAudiomuseNavidromeEnabled: (serverId, enabled) =>
set(s => {
const audiomuseNavidromeByServer = enabled
? { ...s.audiomuseNavidromeByServer, [serverId]: true }
: (() => {
const { [serverId]: _removed, ...rest } = s.audiomuseNavidromeByServer;
return rest;
})();
const { [serverId]: _issueRm, ...issueRest } = s.audiomuseNavidromeIssueByServer;
return { audiomuseNavidromeByServer, audiomuseNavidromeIssueByServer: issueRest };
}),
setSubsonicServerIdentity: (serverId, identity) =>
set(s => {
const subsonicServerIdentityByServer = { ...s.subsonicServerIdentityByServer, [serverId]: { ...identity } };
if (!isNavidromeAudiomuseSoftwareEligible(identity)) {
const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer;
const { [serverId]: _p, ...probeRest } = s.instantMixProbeByServer;
return {
subsonicServerIdentityByServer,
audiomuseNavidromeByServer: audiomuseRest,
audiomuseNavidromeIssueByServer: issueRest,
instantMixProbeByServer: probeRest,
};
}
return { subsonicServerIdentityByServer };
}),
setInstantMixProbe: (serverId, result) =>
set(s => {
const instantMixProbeByServer = { ...s.instantMixProbeByServer, [serverId]: result };
if (result === 'empty') {
const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer;
return {
instantMixProbeByServer,
audiomuseNavidromeByServer: audiomuseRest,
audiomuseNavidromeIssueByServer: issueRest,
};
}
return { instantMixProbeByServer };
}),
setAudiomuseNavidromeIssue: (serverId, hasIssue) =>
set(s =>
hasIssue
? { audiomuseNavidromeIssueByServer: { ...s.audiomuseNavidromeIssueByServer, [serverId]: true } }
: (() => {
const { [serverId]: _rm, ...rest } = s.audiomuseNavidromeIssueByServer;
return { audiomuseNavidromeIssueByServer: rest };
})(),
),
logout: () => set({ isLoggedIn: false, musicFolders: [] }),
getBaseUrl: () => {
const s = get();
const server = s.servers.find(srv => srv.id === s.activeServerId);
if (!server?.url) return '';
const base = server.url.startsWith('http') ? server.url : `http://${server.url}`;
return base.replace(/\/$/, '');
},
getActiveServer: () => {
const s = get();
return s.servers.find(srv => srv.id === s.activeServerId);
},
}),
{
name: 'psysonic-auth',
storage: createJSONStorage(() => localStorage),
partialize: state => {
const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state;
return rest;
},
onRehydrateStorage: () => (state, error) => {
if (error || !state) return;
// 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 }
: {};
// Migrate lyricsServerFirst + enableNeteaselyrics → lyricsSources (one-time).
let lyricsSourcesMigrated: { lyricsSources?: LyricsSourceConfig[] } = {};
try {
const raw = JSON.parse(localStorage.getItem('psysonic-auth') ?? '{}') as { state?: Record<string, unknown> };
if (!raw?.state?.lyricsSources) {
const serverFirst = (raw?.state?.lyricsServerFirst as boolean | undefined) ?? true;
const neteaseOn = (raw?.state?.enableNeteaselyrics as boolean | undefined) ?? false;
const migrated: LyricsSourceConfig[] = serverFirst
? [{ id: 'server', enabled: true }, { id: 'lrclib', enabled: true }, { id: 'netease', enabled: neteaseOn }]
: [{ id: 'lrclib', enabled: true }, { id: 'server', enabled: true }, { id: 'netease', enabled: neteaseOn }];
lyricsSourcesMigrated = { lyricsSources: migrated };
}
} catch { /* ignore */ }
// One-time: older builds could persist smooth=false as the default. Force smooth on once
// so updates do not leave users on discrete scrolling; after this flag exists, only an
// explicit toggle in Settings may turn it off (persisted in psysonic-auth).
const wheelSmoothMigrationKey = 'psysonic-linux-webkit-smooth-v1';
let wheelSmoothOneTime: { linuxWebkitKineticScroll?: boolean } = {};
if (IS_LINUX) {
try {
if (!localStorage.getItem(wheelSmoothMigrationKey)) {
wheelSmoothOneTime = { linuxWebkitKineticScroll: true };
localStorage.setItem(wheelSmoothMigrationKey, '1');
}
} catch { /* ignore */ }
}
// 'waveform' style was renamed to 'truewave' (with 'pseudowave' added
// as the deterministic legacy variant). Any persisted value that is
// not a valid SeekbarStyle (legacy 'waveform', undefined, tampered
// strings) lands on the new bins-based default — otherwise the
// dispatcher's switch finds no match and the seekbar renders blank.
const VALID_SEEKBAR_STYLES = new Set<string>([
'truewave', 'pseudowave', 'linedot', 'bar', 'thick',
'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape',
]);
const seekbarStyleMigrated = VALID_SEEKBAR_STYLES.has(state.seekbarStyle as string)
? {}
: { seekbarStyle: 'truewave' as SeekbarStyle };
// `reducedAnimations: boolean` superseded by `animationMode: AnimationMode`.
// Map legacy true → 'reduced', false/missing → 'full'. Static is opt-in only.
let animationModeMigrated: { animationMode?: AnimationMode } = {};
const legacyReduced = (state as { reducedAnimations?: unknown }).reducedAnimations;
const VALID_ANIM_MODES = new Set<string>(['full', 'reduced', 'static']);
if (!VALID_ANIM_MODES.has(state.animationMode as string)) {
animationModeMigrated = { animationMode: legacyReduced === true ? 'reduced' : 'full' };
}
const st = state as {
loudnessTargetLufs?: unknown;
loudnessPreAnalysisAttenuationDb?: unknown;
loudnessPreIsRefV1?: unknown;
};
const targetSan = sanitizeLoudnessLufsPreset(st.loudnessTargetLufs, -12);
const rawN = st.loudnessPreAnalysisAttenuationDb;
const n = typeof rawN === 'number' ? rawN : Number(rawN);
const preSan =
st.loudnessPreIsRefV1 === true
? sanitizeLoudnessPreAnalysisFromStorage(rawN)
: (Number.isFinite(n)
? clampStoredLoudnessPreAnalysisAttenuationRefDb(
n - (targetSan - LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS),
)
: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB);
useAuthStore.setState({
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
),
loudnessTargetLufs: targetSan,
loudnessPreAnalysisAttenuationDb: preSan,
loudnessPreIsRefV1: true,
...conflictingLegacyState,
...lyricsSourcesMigrated,
...wheelSmoothOneTime,
...seekbarStyleMigrated,
...animationModeMigrated,
});
},
}
)
);