mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(auth): E.43 — extract authStore types + defaults + helpers (#608)
First slice of the authStore split. Pure code-move: no behaviour change. - `authStoreTypes.ts` — `ServerProfile`, `AuthState`, plus all union types (`SeekbarStyle`, `LoggingMode`, `NormalizationEngine`, `DiscordCoverSource`, `LoudnessLufsPreset`, `LyricsSourceId`, `LyricsSourceConfig`, `TrackPreviewLocation`, `TrackPreviewLocations`). - `authStoreDefaults.ts` — `LOUDNESS_LUFS_PRESETS`, `DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB`, `TRACK_PREVIEW_LOCATIONS`, `DEFAULT_TRACK_PREVIEW_LOCATIONS`, `DEFAULT_LYRICS_SOURCES`, `MIX_MIN_RATING_FILTER_MAX_STARS`, `RANDOM_MIX_SIZE_OPTIONS`. - `authStoreHelpers.ts` — `generateId`, `sanitizeLoudnessLufsPreset`, `sanitizeLoudnessPreAnalysisFromStorage`, `clampMixFilterMinStars`, `clampRandomMixSize`, `clampSkipStarThreshold`, `skipStarCountStorageKey`, `sanitizeSkipStarCounts`. 12 external call sites migrated to direct imports from the new modules (no re-export shims left in authStore.ts — applies the [feedback_prevent_god_modules] rule 5: avoid re-export debt). authStore.ts: 889 → 518 LOC (−371).
This commit is contained in:
committed by
GitHub
parent
9fac6eb490
commit
4d564e5016
@@ -1,10 +1,11 @@
|
|||||||
|
import type { ServerProfile } from '../store/authStoreTypes';
|
||||||
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Check, ChevronDown } from 'lucide-react';
|
import { Check, ChevronDown } from 'lucide-react';
|
||||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||||
import { useAuthStore, type ServerProfile } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { serverListDisplayLabel } from '../utils/serverDisplayName';
|
import { serverListDisplayLabel } from '../utils/serverDisplayName';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* snapshots — tests survive a refactor that re-orders or re-styles the
|
* snapshots — tests survive a refactor that re-orders or re-styles the
|
||||||
* markup as long as the menu items + their handlers stay observable.
|
* markup as long as the menu items + their handlers stay observable.
|
||||||
*/
|
*/
|
||||||
|
import type { ServerProfile } from '@/store/authStoreTypes';
|
||||||
import type { Track } from '@/store/playerStoreTypes';
|
import type { Track } from '@/store/playerStoreTypes';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
|
|||||||
import ContextMenu from './ContextMenu';
|
import ContextMenu from './ContextMenu';
|
||||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||||
import { usePlayerStore } from '@/store/playerStore';
|
import { usePlayerStore } from '@/store/playerStore';
|
||||||
import { useAuthStore, type ServerProfile } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||||
import { makeTrack, makeServer } from '@/test/helpers/factories';
|
import { makeTrack, makeServer } from '@/test/helpers/factories';
|
||||||
import { onInvoke } from '@/test/mocks/tauri';
|
import { onInvoke } from '@/test/mocks/tauri';
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import type { SeekbarStyle } from '../store/authStoreTypes';
|
||||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { usePreviewStore } from '../store/previewStore';
|
import { usePreviewStore } from '../store/previewStore';
|
||||||
import { useAuthStore, type SeekbarStyle } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { bumpPerfCounter } from '../utils/perfTelemetry';
|
import { bumpPerfCounter } from '../utils/perfTelemetry';
|
||||||
function fmt(s: number): string {
|
function fmt(s: number): string {
|
||||||
if (!s || isNaN(s)) return '0:00';
|
if (!s || isNaN(s)) return '0:00';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { RANDOM_MIX_SIZE_OPTIONS } from '../store/authStoreDefaults';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { usePreviewStore } from '../store/previewStore';
|
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 { Play, RefreshCw, ChevronDown, ChevronRight, ChevronUp, Heart, Square, AudioLines } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
import { useDragDrop } from '../contexts/DragDropContext';
|
||||||
|
|||||||
+3
-13
@@ -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 React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { version as appVersion } from '../../package.json';
|
import { version as appVersion } from '../../package.json';
|
||||||
@@ -27,19 +29,7 @@ import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol';
|
|||||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||||
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
|
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import {
|
import { useAuthStore } from '../store/authStore';
|
||||||
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 { SeekbarPreview } from '../components/WaveformSeek';
|
import { SeekbarPreview } from '../components/WaveformSeek';
|
||||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||||
import { useThemeStore } from '../store/themeStore';
|
import { useThemeStore } from '../store/themeStore';
|
||||||
|
|||||||
+21
-392
@@ -1,11 +1,8 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
|
||||||
import {
|
import {
|
||||||
isNavidromeAudiomuseSoftwareEligible,
|
isNavidromeAudiomuseSoftwareEligible,
|
||||||
type InstantMixProbeResult,
|
|
||||||
type SubsonicServerIdentity,
|
|
||||||
} from '../utils/subsonicServerIdentity';
|
} from '../utils/subsonicServerIdentity';
|
||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
import { IS_LINUX } from '../utils/platform';
|
import { IS_LINUX } from '../utils/platform';
|
||||||
@@ -13,397 +10,29 @@ import {
|
|||||||
LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS,
|
LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS,
|
||||||
clampStoredLoudnessPreAnalysisAttenuationRefDb,
|
clampStoredLoudnessPreAnalysisAttenuationRefDb,
|
||||||
} from '../utils/loudnessPreAnalysisSlider';
|
} from '../utils/loudnessPreAnalysisSlider';
|
||||||
|
import {
|
||||||
export interface ServerProfile {
|
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
|
||||||
id: string;
|
DEFAULT_LYRICS_SOURCES,
|
||||||
name: string;
|
DEFAULT_TRACK_PREVIEW_LOCATIONS,
|
||||||
url: string;
|
} from './authStoreDefaults';
|
||||||
username: string;
|
import {
|
||||||
password: string;
|
clampMixFilterMinStars,
|
||||||
}
|
clampRandomMixSize,
|
||||||
|
clampSkipStarThreshold,
|
||||||
export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
|
generateId,
|
||||||
export type LoggingMode = 'off' | 'normal' | 'debug';
|
sanitizeLoudnessLufsPreset,
|
||||||
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
|
sanitizeLoudnessPreAnalysisFromStorage,
|
||||||
export type DiscordCoverSource = 'none' | 'apple' | 'server';
|
sanitizeSkipStarCounts,
|
||||||
|
skipStarCountStorageKey,
|
||||||
/** Integrated-loudness target presets (Settings + analysis). */
|
} from './authStoreHelpers';
|
||||||
export type LoudnessLufsPreset = -16 | -14 | -12 | -10;
|
import type {
|
||||||
|
AuthState,
|
||||||
const LOUDNESS_LUFS_PRESETS: LoudnessLufsPreset[] = [-16, -14, -12, -10];
|
DiscordCoverSource,
|
||||||
|
LyricsSourceConfig,
|
||||||
/** Settings default + Rust engine cold default until `audio_set_normalization` runs. */
|
SeekbarStyle,
|
||||||
export const DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB = -4.5;
|
} from './authStoreTypes';
|
||||||
|
|
||||||
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;
|
|
||||||
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 `<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;
|
|
||||||
/** 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<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;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -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<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;
|
||||||
|
}
|
||||||
@@ -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<TrackPreviewLocation, boolean>;
|
||||||
|
|
||||||
|
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 `<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;
|
||||||
|
/** 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<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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
|
import type { ServerProfile } from './authStoreTypes';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { ServerProfile } from './authStore';
|
|
||||||
|
|
||||||
let _resolve: ((server: ServerProfile | null) => void) | null = null;
|
let _resolve: ((server: ServerProfile | null) => void) | null = null;
|
||||||
|
|
||||||
interface OrbitAccountPickerStore {
|
interface OrbitAccountPickerStore {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import type { TrackPreviewLocation } from './authStoreTypes';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { buildStreamUrl } from '../api/subsonic';
|
import { buildStreamUrl } from '../api/subsonic';
|
||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
import { useAuthStore, type TrackPreviewLocation } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { useOrbitStore } from './orbitStore';
|
import { useOrbitStore } from './orbitStore';
|
||||||
|
|
||||||
/** Minimal track info needed to surface the preview in the player bar UI. */
|
/** Minimal track info needed to surface the preview in the player bar UI. */
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* the fields the test cares about. Keeps tests focused on behaviour rather
|
* the fields the test cares about. Keeps tests focused on behaviour rather
|
||||||
* than on assembling boilerplate.
|
* than on assembling boilerplate.
|
||||||
*/
|
*/
|
||||||
|
import type { ServerProfile } from '@/store/authStoreTypes';
|
||||||
import type { Track } from '@/store/playerStoreTypes';
|
import type { Track } from '@/store/playerStoreTypes';
|
||||||
import type { ServerProfile } from '@/store/authStore';
|
|
||||||
import type { SubsonicSong } from '@/api/subsonic';
|
import type { SubsonicSong } from '@/api/subsonic';
|
||||||
|
|
||||||
let trackCounter = 0;
|
let trackCounter = 0;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import type { ServerProfile } from '../store/authStoreTypes';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { ServerProfile } from '../store/authStore';
|
|
||||||
import { serverListDisplayLabel, shortHostFromServerUrl } from './serverDisplayName';
|
import { serverListDisplayLabel, shortHostFromServerUrl } from './serverDisplayName';
|
||||||
|
|
||||||
function srv(p: Partial<ServerProfile> & Pick<ServerProfile, 'id'>): ServerProfile {
|
function srv(p: Partial<ServerProfile> & Pick<ServerProfile, 'id'>): ServerProfile {
|
||||||
|
|||||||
@@ -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`. */
|
/** Host (+ port) from a server base URL, e.g. `https://music.one.com/foo` → `music.one.com`. */
|
||||||
export function shortHostFromServerUrl(urlRaw: string): string {
|
export function shortHostFromServerUrl(urlRaw: string): string {
|
||||||
const t = urlRaw.trim();
|
const t = urlRaw.trim();
|
||||||
|
|||||||
@@ -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. */
|
/** Library share (track / album / artist / queue). Same naming family as `psysonic1-` server invites. */
|
||||||
export const PSYSONIC_SHARE_PREFIX = 'psysonic2-';
|
export const PSYSONIC_SHARE_PREFIX = 'psysonic2-';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import type { ServerProfile } from '../store/authStoreTypes';
|
||||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
|
||||||
import type { ServerProfile } from '../store/authStore';
|
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
import { endOrbitSession, leaveOrbitSession } from './orbit';
|
import { endOrbitSession, leaveOrbitSession } from './orbit';
|
||||||
|
|||||||
Reference in New Issue
Block a user