mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
98ff73d17a
* 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
867 lines
37 KiB
TypeScript
867 lines
37 KiB
TypeScript
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 0–100 (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 non‑zero ratings at or below per‑axis thresholds (0 = unrated, kept). */
|
||
mixMinRatingFilterEnabled: boolean;
|
||
/** 0 = ignore; 1–3 = 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,
|
||
});
|
||
},
|
||
}
|
||
)
|
||
);
|