diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index 400f3cab..e15395ea 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -1,10 +1,11 @@ +import type { ServerProfile } from '../store/authStoreTypes'; import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { Check, ChevronDown } from 'lucide-react'; import { ConnectionStatus } from '../hooks/useConnectionStatus'; -import { useAuthStore, type ServerProfile } from '../store/authStore'; +import { useAuthStore } from '../store/authStore'; import { switchActiveServer } from '../utils/switchActiveServer'; import { showToast } from '../utils/toast'; import { serverListDisplayLabel } from '../utils/serverDisplayName'; diff --git a/src/components/ContextMenu.test.tsx b/src/components/ContextMenu.test.tsx index 59b99f63..a691714a 100644 --- a/src/components/ContextMenu.test.tsx +++ b/src/components/ContextMenu.test.tsx @@ -6,6 +6,7 @@ * snapshots — tests survive a refactor that re-orders or re-styles the * markup as long as the menu items + their handlers stay observable. */ +import type { ServerProfile } from '@/store/authStoreTypes'; import type { Track } from '@/store/playerStoreTypes'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -45,7 +46,7 @@ vi.mock('@/utils/orbitBulkGuard', () => ({ import ContextMenu from './ContextMenu'; import { renderWithProviders } from '@/test/helpers/renderWithProviders'; import { usePlayerStore } from '@/store/playerStore'; -import { useAuthStore, type ServerProfile } from '@/store/authStore'; +import { useAuthStore } from '@/store/authStore'; import { resetAllStores } from '@/test/helpers/storeReset'; import { makeTrack, makeServer } from '@/test/helpers/factories'; import { onInvoke } from '@/test/mocks/tauri'; diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 8724c46e..dcac74be 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -1,8 +1,9 @@ +import type { SeekbarStyle } from '../store/authStoreTypes'; import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress'; import React, { useEffect, useRef, useState } from 'react'; import { usePlayerStore } from '../store/playerStore'; import { usePreviewStore } from '../store/previewStore'; -import { useAuthStore, type SeekbarStyle } from '../store/authStore'; +import { useAuthStore } from '../store/authStore'; import { bumpPerfCounter } from '../utils/perfTelemetry'; function fmt(s: number): string { if (!s || isNaN(s)) return '0:00'; diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 2248b69c..547559da 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -1,9 +1,10 @@ +import { RANDOM_MIX_SIZE_OPTIONS } from '../store/authStoreDefaults'; import { songToTrack } from '../utils/songToTrack'; import React, { useEffect, useMemo, useState } from 'react'; import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { usePreviewStore } from '../store/previewStore'; -import { useAuthStore, RANDOM_MIX_SIZE_OPTIONS } from '../store/authStore'; +import { useAuthStore } from '../store/authStore'; import { Play, RefreshCw, ChevronDown, ChevronRight, ChevronUp, Heart, Square, AudioLines } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index e6ddbc00..ceff53b8 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,3 +1,5 @@ +import type { ServerProfile, SeekbarStyle, LyricsSourceId, LyricsSourceConfig, LoggingMode, LoudnessLufsPreset, TrackPreviewLocation } from '../store/authStoreTypes'; +import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, MIX_MIN_RATING_FILTER_MAX_STARS, TRACK_PREVIEW_LOCATIONS } from '../store/authStoreDefaults'; import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { version as appVersion } from '../../package.json'; @@ -27,19 +29,7 @@ import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; import { useShallow } from 'zustand/react/shallow'; -import { - useAuthStore, - DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, - ServerProfile, - MIX_MIN_RATING_FILTER_MAX_STARS, - TRACK_PREVIEW_LOCATIONS, - type SeekbarStyle, - type LyricsSourceId, - type LyricsSourceConfig, - type LoggingMode, - type LoudnessLufsPreset, - type TrackPreviewLocation, -} from '../store/authStore'; +import { useAuthStore } from '../store/authStore'; import { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; diff --git a/src/store/authStore.ts b/src/store/authStore.ts index ccd278e0..7549a4a8 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -1,11 +1,8 @@ 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'; @@ -13,397 +10,29 @@ 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 DiscordCoverSource = 'none' | 'apple' | 'server'; - -/** 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; - -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; - preservePlayNextOrder: 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; - discordCoverSource: DiscordCoverSource; - /** 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; - /** 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 `/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; - /** 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; - /** Random Mix target list size (50, 75, 100, 125, or 150). */ - randomMixSize: 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; - /** 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; - 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; - setAudiomuseNavidromeEnabled: (serverId: string, enabled: boolean) => void; - - /** From `ping` — used to show the AudioMuse toggle only on Navidrome ≥ 0.60. */ - subsonicServerIdentityByServer: Record; - 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; - setAudiomuseNavidromeIssue: (serverId: string, hasIssue: boolean) => void; - - /** - * `getSimilarSongs` probe per server (after ping). `empty` hides the AudioMuse row; re-run by testing connection. - */ - instantMixProbeByServer: Record; - setInstantMixProbe: (serverId: string, result: InstantMixProbeResult) => void; - - // Status - isLoggedIn: boolean; - isConnecting: boolean; - connectionError: string | null; - lastfmSessionError: boolean; - - // Actions - addServer: (profile: Omit) => string; - updateServer: (id: string, data: Partial>) => 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; - setPreservePlayNextOrder: (v: boolean) => void; - setShowArtistImages: (v: boolean) => void; - setShowTrayIcon: (v: boolean) => void; - setMinimizeToTray: (v: boolean) => void; - setShowOrbitTrigger: (v: boolean) => void; - setDiscordRichPresence: (v: boolean) => void; - setDiscordCoverSource: (v: DiscordCoverSource) => 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; - 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; - setRandomMixSize: (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))); -} - -export const RANDOM_MIX_SIZE_OPTIONS: readonly number[] = [50, 75, 100, 125, 150]; - -function clampRandomMixSize(v: number): number { - if (!Number.isFinite(v)) return 50; - // Snap to the nearest allowed option so a tampered persisted value can't break the picker. - let nearest = RANDOM_MIX_SIZE_OPTIONS[0]; - let bestDelta = Math.abs(v - nearest); - for (const opt of RANDOM_MIX_SIZE_OPTIONS) { - const d = Math.abs(v - opt); - if (d < bestDelta) { nearest = opt; bestDelta = d; } - } - return nearest; -} +import { + DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, + DEFAULT_LYRICS_SOURCES, + DEFAULT_TRACK_PREVIEW_LOCATIONS, +} from './authStoreDefaults'; +import { + clampMixFilterMinStars, + clampRandomMixSize, + clampSkipStarThreshold, + generateId, + sanitizeLoudnessLufsPreset, + sanitizeLoudnessPreAnalysisFromStorage, + sanitizeSkipStarCounts, + skipStarCountStorageKey, +} from './authStoreHelpers'; +import type { + AuthState, + DiscordCoverSource, + LyricsSourceConfig, + SeekbarStyle, +} from './authStoreTypes'; -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 { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; - const next: Record = {}; - for (const [k, v] of Object.entries(raw as Record)) { - 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()( persist( diff --git a/src/store/authStoreDefaults.ts b/src/store/authStoreDefaults.ts new file mode 100644 index 00000000..37bcfa77 --- /dev/null +++ b/src/store/authStoreDefaults.ts @@ -0,0 +1,40 @@ +import type { + LoudnessLufsPreset, + LyricsSourceConfig, + TrackPreviewLocation, + TrackPreviewLocations, +} from './authStoreTypes'; + +export 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; + +export const TRACK_PREVIEW_LOCATIONS: readonly TrackPreviewLocation[] = [ + 'suggestions', + 'albums', + 'playlists', + 'favorites', + 'artist', + 'randomMix', +]; + +export const DEFAULT_TRACK_PREVIEW_LOCATIONS: TrackPreviewLocations = { + suggestions: true, + albums: true, + playlists: true, + favorites: true, + artist: true, + randomMix: true, +}; + +export const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [ + { id: 'server', enabled: true }, + { id: 'lrclib', enabled: true }, + { id: 'netease', enabled: false }, +]; + +/** 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; + +export const RANDOM_MIX_SIZE_OPTIONS: readonly number[] = [50, 75, 100, 125, 150]; diff --git a/src/store/authStoreHelpers.ts b/src/store/authStoreHelpers.ts new file mode 100644 index 00000000..4c16cbcf --- /dev/null +++ b/src/store/authStoreHelpers.ts @@ -0,0 +1,61 @@ +import { clampStoredLoudnessPreAnalysisAttenuationRefDb } from '../utils/loudnessPreAnalysisSlider'; +import { + DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, + LOUDNESS_LUFS_PRESETS, + MIX_MIN_RATING_FILTER_MAX_STARS, + RANDOM_MIX_SIZE_OPTIONS, +} from './authStoreDefaults'; +import type { LoudnessLufsPreset } from './authStoreTypes'; + +export function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2); +} + +export function sanitizeLoudnessLufsPreset(v: unknown, fallback: LoudnessLufsPreset): LoudnessLufsPreset { + return (LOUDNESS_LUFS_PRESETS as readonly number[]).includes(v as number) + ? (v as LoudnessLufsPreset) + : fallback; +} + +export 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 function clampMixFilterMinStars(v: unknown): number { + const n = typeof v === 'number' ? v : Number(v); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(MIX_MIN_RATING_FILTER_MAX_STARS, Math.round(n))); +} + +export function clampRandomMixSize(v: number): number { + if (!Number.isFinite(v)) return 50; + // Snap to the nearest allowed option so a tampered persisted value can't break the picker. + let nearest = RANDOM_MIX_SIZE_OPTIONS[0]; + let bestDelta = Math.abs(v - nearest); + for (const opt of RANDOM_MIX_SIZE_OPTIONS) { + const d = Math.abs(v - opt); + if (d < bestDelta) { nearest = opt; bestDelta = d; } + } + return nearest; +} + +export function clampSkipStarThreshold(v: number): number { + if (!Number.isFinite(v)) return 3; + return Math.max(1, Math.min(99, Math.round(v))); +} + +export function skipStarCountStorageKey(serverId: string | null | undefined, trackId: string): string { + return `${serverId ?? ''}\u001f${trackId}`; +} + +export function sanitizeSkipStarCounts(raw: unknown): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + const next: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + const n = Number(v); + if (Number.isFinite(n) && n > 0) next[k] = Math.min(Math.floor(n), 1_000_000); + } + return next; +} diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts new file mode 100644 index 00000000..3f539bd4 --- /dev/null +++ b/src/store/authStoreTypes.ts @@ -0,0 +1,309 @@ +import type { EntityRatingSupportLevel } from '../api/subsonic'; +import type { + InstantMixProbeResult, + SubsonicServerIdentity, +} from '../utils/subsonicServerIdentity'; + +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 DiscordCoverSource = 'none' | 'apple' | 'server'; + +/** Integrated-loudness target presets (Settings + analysis). */ +export type LoudnessLufsPreset = -16 | -14 | -12 | -10; + +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; + +export 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; + preservePlayNextOrder: 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; + discordCoverSource: DiscordCoverSource; + /** 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; + /** 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 `/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; + /** 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; + /** Random Mix target list size (50, 75, 100, 125, or 150). */ + randomMixSize: 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; + /** 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; + 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; + setAudiomuseNavidromeEnabled: (serverId: string, enabled: boolean) => void; + + /** From `ping` — used to show the AudioMuse toggle only on Navidrome ≥ 0.60. */ + subsonicServerIdentityByServer: Record; + 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; + setAudiomuseNavidromeIssue: (serverId: string, hasIssue: boolean) => void; + + /** + * `getSimilarSongs` probe per server (after ping). `empty` hides the AudioMuse row; re-run by testing connection. + */ + instantMixProbeByServer: Record; + setInstantMixProbe: (serverId: string, result: InstantMixProbeResult) => void; + + // Status + isLoggedIn: boolean; + isConnecting: boolean; + connectionError: string | null; + lastfmSessionError: boolean; + + // Actions + addServer: (profile: Omit) => string; + updateServer: (id: string, data: Partial>) => 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; + setPreservePlayNextOrder: (v: boolean) => void; + setShowArtistImages: (v: boolean) => void; + setShowTrayIcon: (v: boolean) => void; + setMinimizeToTray: (v: boolean) => void; + setShowOrbitTrigger: (v: boolean) => void; + setDiscordRichPresence: (v: boolean) => void; + setDiscordCoverSource: (v: DiscordCoverSource) => 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; + 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; + setRandomMixSize: (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; +} diff --git a/src/store/orbitAccountPickerStore.ts b/src/store/orbitAccountPickerStore.ts index 74675dfa..9ce6a3ad 100644 --- a/src/store/orbitAccountPickerStore.ts +++ b/src/store/orbitAccountPickerStore.ts @@ -1,6 +1,5 @@ +import type { ServerProfile } from './authStoreTypes'; import { create } from 'zustand'; -import type { ServerProfile } from './authStore'; - let _resolve: ((server: ServerProfile | null) => void) | null = null; interface OrbitAccountPickerStore { diff --git a/src/store/previewStore.ts b/src/store/previewStore.ts index a9344e3a..06eb73cb 100644 --- a/src/store/previewStore.ts +++ b/src/store/previewStore.ts @@ -1,8 +1,9 @@ +import type { TrackPreviewLocation } from './authStoreTypes'; import { create } from 'zustand'; import { invoke } from '@tauri-apps/api/core'; import { buildStreamUrl } from '../api/subsonic'; import { usePlayerStore } from './playerStore'; -import { useAuthStore, type TrackPreviewLocation } from './authStore'; +import { useAuthStore } from './authStore'; import { useOrbitStore } from './orbitStore'; /** Minimal track info needed to surface the preview in the player bar UI. */ diff --git a/src/test/helpers/factories.ts b/src/test/helpers/factories.ts index c2bf23c0..2fcc6b28 100644 --- a/src/test/helpers/factories.ts +++ b/src/test/helpers/factories.ts @@ -5,8 +5,8 @@ * the fields the test cares about. Keeps tests focused on behaviour rather * than on assembling boilerplate. */ +import type { ServerProfile } from '@/store/authStoreTypes'; import type { Track } from '@/store/playerStoreTypes'; -import type { ServerProfile } from '@/store/authStore'; import type { SubsonicSong } from '@/api/subsonic'; let trackCounter = 0; diff --git a/src/utils/serverDisplayName.test.ts b/src/utils/serverDisplayName.test.ts index d6bf81c6..6c529ad3 100644 --- a/src/utils/serverDisplayName.test.ts +++ b/src/utils/serverDisplayName.test.ts @@ -1,5 +1,5 @@ +import type { ServerProfile } from '../store/authStoreTypes'; import { describe, expect, it } from 'vitest'; -import type { ServerProfile } from '../store/authStore'; import { serverListDisplayLabel, shortHostFromServerUrl } from './serverDisplayName'; function srv(p: Partial & Pick): ServerProfile { diff --git a/src/utils/serverDisplayName.ts b/src/utils/serverDisplayName.ts index fb466800..188a8b13 100644 --- a/src/utils/serverDisplayName.ts +++ b/src/utils/serverDisplayName.ts @@ -1,5 +1,4 @@ -import type { ServerProfile } from '../store/authStore'; - +import type { ServerProfile } from '../store/authStoreTypes'; /** Host (+ port) from a server base URL, e.g. `https://music.one.com/foo` → `music.one.com`. */ export function shortHostFromServerUrl(urlRaw: string): string { const t = urlRaw.trim(); diff --git a/src/utils/shareLink.ts b/src/utils/shareLink.ts index 27036fbe..7b2d938a 100644 --- a/src/utils/shareLink.ts +++ b/src/utils/shareLink.ts @@ -1,5 +1,4 @@ -import type { ServerProfile } from '../store/authStore'; - +import type { ServerProfile } from '../store/authStoreTypes'; /** Library share (track / album / artist / queue). Same naming family as `psysonic1-` server invites. */ export const PSYSONIC_SHARE_PREFIX = 'psysonic2-'; diff --git a/src/utils/switchActiveServer.ts b/src/utils/switchActiveServer.ts index 555cd4c6..f74fbb20 100644 --- a/src/utils/switchActiveServer.ts +++ b/src/utils/switchActiveServer.ts @@ -1,5 +1,5 @@ +import type { ServerProfile } from '../store/authStoreTypes'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; -import type { ServerProfile } from '../store/authStore'; import { useAuthStore } from '../store/authStore'; import { useOrbitStore } from '../store/orbitStore'; import { endOrbitSession, leaveOrbitSession } from './orbit';