feat(settings): library card grid max columns in Appearance (4–12, default 6)

Persist libraryGridMaxColumns, wire useCardGridMetrics and card grid layout,
add i18n and settings search index; document performance hint in copy.
This commit is contained in:
Maxim Isaev
2026-05-15 02:19:20 +03:00
parent 7c6d3694d4
commit 0f5ece6d03
21 changed files with 169 additions and 16 deletions
+15
View File
@@ -30,6 +30,10 @@ vi.mock('@/api/subsonic', () => ({
import { useAuthStore } from './authStore';
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
import { onInvoke } from '@/test/mocks/tauri';
import {
LIBRARY_GRID_MAX_COLUMNS_MAX,
LIBRARY_GRID_MAX_COLUMNS_MIN,
} from './authStoreDefaults';
beforeEach(() => {
resetAuthStore();
@@ -120,6 +124,17 @@ describe('setters with validation / clamping', () => {
expect(useAuthStore.getState().trackPreviewDurationSec).toBe(31);
});
it('setLibraryGridMaxColumns clamps to the allowed column range', () => {
useAuthStore.getState().setLibraryGridMaxColumns(99);
expect(useAuthStore.getState().libraryGridMaxColumns).toBe(LIBRARY_GRID_MAX_COLUMNS_MAX);
useAuthStore.getState().setLibraryGridMaxColumns(1);
expect(useAuthStore.getState().libraryGridMaxColumns).toBe(LIBRARY_GRID_MAX_COLUMNS_MIN);
useAuthStore.getState().setLibraryGridMaxColumns(6);
expect(useAuthStore.getState().libraryGridMaxColumns).toBe(6);
});
it('setTrackPreviewsEnabled coerces truthy/falsy to boolean', () => {
useAuthStore.getState().setTrackPreviewsEnabled('yes' as unknown as boolean);
expect(useAuthStore.getState().trackPreviewsEnabled).toBe(true);
+2
View File
@@ -17,6 +17,7 @@ import {
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
DEFAULT_LYRICS_SOURCES,
DEFAULT_TRACK_PREVIEW_LOCATIONS,
DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
} from './authStoreDefaults';
import { computeAuthStoreRehydration } from './authStoreRehydrate';
import type { AuthState } from './authStoreTypes';
@@ -58,6 +59,7 @@ export const useAuthStore = create<AuthState>()(
infiniteQueueEnabled: false,
preservePlayNextOrder: false,
showArtistImages: false,
libraryGridMaxColumns: DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
showTrayIcon: true,
minimizeToTray: false,
showOrbitTrigger: true,
+5
View File
@@ -38,3 +38,8 @@ export const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [
export const MIX_MIN_RATING_FILTER_MAX_STARS = 3;
export const RANDOM_MIX_SIZE_OPTIONS: readonly number[] = [50, 75, 100, 125, 150];
/** Default max columns for album/artist/playlist card grids (Settings → Library). */
export const DEFAULT_LIBRARY_GRID_MAX_COLUMNS = 6;
export const LIBRARY_GRID_MAX_COLUMNS_MIN = 4;
export const LIBRARY_GRID_MAX_COLUMNS_MAX = 12;
+10
View File
@@ -1,6 +1,9 @@
import { clampStoredLoudnessPreAnalysisAttenuationRefDb } from '../utils/audio/loudnessPreAnalysisSlider';
import {
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
LIBRARY_GRID_MAX_COLUMNS_MAX,
LIBRARY_GRID_MAX_COLUMNS_MIN,
LOUDNESS_LUFS_PRESETS,
MIX_MIN_RATING_FILTER_MAX_STARS,
RANDOM_MIX_SIZE_OPTIONS,
@@ -41,6 +44,13 @@ export function clampRandomMixSize(v: number): number {
return nearest;
}
/** Persisted max columns for library card grids (albums, artists, playlists, …). */
export function clampLibraryGridMaxColumns(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return DEFAULT_LIBRARY_GRID_MAX_COLUMNS;
return Math.max(LIBRARY_GRID_MAX_COLUMNS_MIN, Math.min(LIBRARY_GRID_MAX_COLUMNS_MAX, Math.round(n)));
}
export function clampSkipStarThreshold(v: number): number {
if (!Number.isFinite(v)) return 3;
return Math.max(1, Math.min(99, Math.round(v)));
+4
View File
@@ -7,6 +7,7 @@ import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from './authStoreDefault
import {
clampMixFilterMinStars,
clampRandomMixSize,
clampLibraryGridMaxColumns,
sanitizeLoudnessLufsPreset,
sanitizeLoudnessPreAnalysisFromStorage,
sanitizeSkipStarCounts,
@@ -116,6 +117,9 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
randomMixSize: clampRandomMixSize(state.randomMixSize as number),
libraryGridMaxColumns: clampLibraryGridMaxColumns(
(state as { libraryGridMaxColumns?: unknown }).libraryGridMaxColumns,
),
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
),
+6
View File
@@ -80,6 +80,11 @@ export interface AuthState {
infiniteQueueEnabled: boolean;
preservePlayNextOrder: boolean;
showArtistImages: boolean;
/**
* Max columns for album/artist/playlist-style card grids (Settings → Library).
* Clamped 2…12; higher values mean more tiles per row and more layout/paint work.
*/
libraryGridMaxColumns: number;
showTrayIcon: boolean;
minimizeToTray: boolean;
/** Whether the "Orbit" topbar trigger is rendered. Users who never
@@ -252,6 +257,7 @@ export interface AuthState {
setInfiniteQueueEnabled: (v: boolean) => void;
setPreservePlayNextOrder: (v: boolean) => void;
setShowArtistImages: (v: boolean) => void;
setLibraryGridMaxColumns: (v: number) => void;
setShowTrayIcon: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setShowOrbitTrigger: (v: boolean) => void;
+3
View File
@@ -1,4 +1,5 @@
import type { AuthState } from './authStoreTypes';
import { clampLibraryGridMaxColumns } from './authStoreHelpers';
type SetState = (
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
@@ -12,6 +13,7 @@ type SetState = (
export function createUiAppearanceActions(set: SetState): Pick<
AuthState,
| 'setShowArtistImages'
| 'setLibraryGridMaxColumns'
| 'setShowTrayIcon'
| 'setMinimizeToTray'
| 'setShowOrbitTrigger'
@@ -30,6 +32,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
> {
return {
setShowArtistImages: (v) => set({ showArtistImages: v }),
setLibraryGridMaxColumns: (v) => set({ libraryGridMaxColumns: clampLibraryGridMaxColumns(v) }),
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }),