From 0f5ece6d03fc4ace16a2da57d1214f54484f5955 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Fri, 15 May 2026 02:19:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(settings):=20library=20card=20grid=20max?= =?UTF-8?q?=20columns=20in=20Appearance=20(4=E2=80=9312,=20default=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist libraryGridMaxColumns, wire useCardGridMetrics and card grid layout, add i18n and settings search index; document performance hint in copy. --- src/components/settings/AppearanceTab.tsx | 46 ++++++++++++++++++++++- src/components/settings/settingsTabs.ts | 1 + src/hooks/useCardGridMetrics.ts | 17 ++++++--- src/locales/de/settings.ts | 4 ++ src/locales/en/settings.ts | 4 ++ src/locales/es/settings.ts | 4 ++ src/locales/fr/settings.ts | 4 ++ src/locales/nb/settings.ts | 4 ++ src/locales/nl/settings.ts | 4 ++ src/locales/ro/settings.ts | 4 ++ src/locales/ru/settings.ts | 4 ++ src/locales/zh/settings.ts | 4 ++ src/store/authStore.settings.test.ts | 15 ++++++++ src/store/authStore.ts | 2 + src/store/authStoreDefaults.ts | 5 +++ src/store/authStoreHelpers.ts | 10 +++++ src/store/authStoreRehydrate.ts | 4 ++ src/store/authStoreTypes.ts | 6 +++ src/store/authUiAppearanceActions.ts | 3 ++ src/utils/cardGridLayout.test.ts | 22 ++++++++--- src/utils/cardGridLayout.ts | 18 +++++++-- 21 files changed, 169 insertions(+), 16 deletions(-) diff --git a/src/components/settings/AppearanceTab.tsx b/src/components/settings/AppearanceTab.tsx index 802bfac4..279481be 100644 --- a/src/components/settings/AppearanceTab.tsx +++ b/src/components/settings/AppearanceTab.tsx @@ -1,8 +1,12 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { invoke } from '@tauri-apps/api/core'; -import { Clock, Maximize2, Palette, Sliders, Type, ZoomIn } from 'lucide-react'; +import { Clock, LayoutGrid, Maximize2, Palette, Sliders, Type, ZoomIn } from 'lucide-react'; import { useAuthStore } from '../../store/authStore'; +import { + LIBRARY_GRID_MAX_COLUMNS_MAX, + LIBRARY_GRID_MAX_COLUMNS_MIN, +} from '../../store/authStoreDefaults'; import type { SeekbarStyle } from '../../store/authStoreTypes'; import { useFontStore, FontId } from '../../store/fontStore'; import { useThemeStore } from '../../store/themeStore'; @@ -108,6 +112,46 @@ export function AppearanceTab() { + } + > +
+
+ {t('settings.libraryGridMaxColumnsPerfHint')} +
+
+ +
+ auth.setLibraryGridMaxColumns(Number(e.target.value))} + style={{ flex: 1, maxWidth: 360 }} + aria-valuemin={LIBRARY_GRID_MAX_COLUMNS_MIN} + aria-valuemax={LIBRARY_GRID_MAX_COLUMNS_MAX} + aria-valuenow={auth.libraryGridMaxColumns} + /> + + {auth.libraryGridMaxColumns} + +
+
+

+ {t('settings.libraryGridMaxColumnsDesc')} +

+
+
+ } diff --git a/src/components/settings/settingsTabs.ts b/src/components/settings/settingsTabs.ts index dc89f132..343619b4 100644 --- a/src/components/settings/settingsTabs.ts +++ b/src/components/settings/settingsTabs.ts @@ -46,6 +46,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [ { tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' }, { tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'home page customize sections' }, { tab: 'personalisation',titleKey: 'settings.queueToolbarTitle', keywords: 'queue toolbar buttons reorder customize shuffle save load' }, + { tab: 'appearance', titleKey: 'settings.libraryGridMaxColumnsTitle', keywords: 'grid columns album artist playlist cards layout appearance performance scroll paint' }, { tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' }, { tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' }, { tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' }, diff --git a/src/hooks/useCardGridMetrics.ts b/src/hooks/useCardGridMetrics.ts index 7a7852c7..24db38d9 100644 --- a/src/hooks/useCardGridMetrics.ts +++ b/src/hooks/useCardGridMetrics.ts @@ -1,6 +1,7 @@ import { useLayoutEffect, useState, type RefObject } from 'react'; +import { useAuthStore } from '../store/authStore'; +import { clampLibraryGridMaxColumns } from '../store/authStoreHelpers'; import { - CARD_GRID_MAX_COLS, type CardGridRowHeightVariant, computeCardGridColumnCount, computeCellWidthPx, @@ -8,8 +9,8 @@ import { } from '../utils/cardGridLayout'; /** - * ResizeObserver-driven column count (max six) and virtual row height estimate - * from the measured cell width. + * ResizeObserver-driven column count (capped by Settings → Library) and + * virtual row height estimate from the measured cell width. */ export function useCardGridMetrics( measureRef: RefObject, @@ -17,9 +18,13 @@ export function useCardGridMetrics( variant: CardGridRowHeightVariant, layoutSignal: number, ): { gridCols: number; rowHeightEst: number } { + const maxCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns)); const [gridCols, setGridCols] = useState(4); const [rowHeightEst, setRowHeightEst] = useState(() => - estimateRowHeightPx(computeCellWidthPx(960, CARD_GRID_MAX_COLS), variant), + estimateRowHeightPx( + computeCellWidthPx(960, clampLibraryGridMaxColumns(useAuthStore.getState().libraryGridMaxColumns)), + variant, + ), ); useLayoutEffect(() => { @@ -28,7 +33,7 @@ export function useCardGridMetrics( if (!el) return; const onResize = () => { const w = el.clientWidth; - const cols = computeCardGridColumnCount(w); + const cols = computeCardGridColumnCount(w, maxCols); setGridCols(cols); setRowHeightEst(estimateRowHeightPx(computeCellWidthPx(w, cols), variant)); }; @@ -36,7 +41,7 @@ export function useCardGridMetrics( const ro = new ResizeObserver(onResize); ro.observe(el); return () => ro.disconnect(); - }, [observerEnabled, variant, layoutSignal]); + }, [observerEnabled, variant, layoutSignal, maxCols]); return { gridCols, rowHeightEst }; } diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 5173f45b..d37391e1 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -228,6 +228,10 @@ export const settings = { aboutContributorsLabel: 'Mitwirkende', showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen", showChangelogOnUpdateDesc: 'Blendet nach einem Update einen dezenten Changelog-Banner über Now Playing ein. Klick öffnet die Release Notes, X blendet ihn aus.', + libraryGridMaxColumnsTitle: 'Kartenraster in der Bibliothek', + libraryGridMaxColumnsPerfHint: 'Mehr Spalten bedeuten mehr Kacheln pro Zeile und oft mehr Layout- und Zeichenaufwand — bei sehr großen Bibliotheken oder langsamerer Hardware spürbarer.', + libraryGridMaxColumnsRangeLabel: 'Maximale Spalten ({{min}}–{{max}})', + libraryGridMaxColumnsDesc: 'Gilt für Album-, Künstler-, Wiedergabelisten-, Radio-, Offline- und andere Kartenansichten. Weniger Spalten = größere Kacheln und meist weniger CPU-Last.', randomMixTitle: 'Zufallsmix-Blacklist', luckyMixMenuTitle: 'Glücks-Mix im Menü anzeigen', luckyMixMenuDesc: 'Aktiviert Glücks-Mix im "Mix erstellen"-Hub und als separaten Menüeintrag bei getrennter Navigation. Sichtbar nur bei aktiviertem AudioMuse auf dem aktiven Server.', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 2dd1fac4..02ddb3f8 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -231,6 +231,10 @@ export const settings = { aboutContributorsLabel: 'Contributors', showChangelogOnUpdate: "Show 'What's New' on update", showChangelogOnUpdateDesc: "Show a discreet changelog banner above Now Playing after an update. Click opens the release notes; X dismisses it.", + libraryGridMaxColumnsTitle: 'Library card grids', + libraryGridMaxColumnsPerfHint: 'Higher values pack more tiles per row and can increase layout and painting work—noticeable on very large libraries or slower devices.', + libraryGridMaxColumnsRangeLabel: 'Maximum columns ({{min}}–{{max}})', + libraryGridMaxColumnsDesc: 'Applies to album, artist, playlist, radio, offline, and other card-based library views. Lower values use larger tiles and are usually easier on the CPU.', randomMixTitle: 'Random Mix Blacklist', luckyMixMenuTitle: 'Show Lucky Mix in menu', luckyMixMenuDesc: 'Enables Lucky Mix in Build a Mix and as a separate menu item when split navigation is on. Visible only when AudioMuse is enabled on the active server.', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 56dadc00..86718e1e 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -227,6 +227,10 @@ export const settings = { aboutContributorsLabel: 'Contribuidores', showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar", showChangelogOnUpdateDesc: 'Muestra un discreto banner de changelog encima de Now Playing tras una actualización. Clic abre las notas de la versión; X lo oculta.', + libraryGridMaxColumnsTitle: 'Rejillas de tarjetas en la biblioteca', + libraryGridMaxColumnsPerfHint: 'Más columnas caben más mosaicos por fila y pueden aumentar el trabajo de diseño y pintado; se nota más en bibliotecas muy grandes o equipos lentos.', + libraryGridMaxColumnsRangeLabel: 'Máximo de columnas ({{min}}–{{max}})', + libraryGridMaxColumnsDesc: 'Se aplica a vistas de álbum, artista, lista de reproducción, radio, sin conexión y otras con tarjetas. Menos columnas = mosaicos más grandes y suele aligerar la CPU.', randomMixTitle: 'Lista negra de Mezcla Aleatoria', luckyMixMenuTitle: 'Mostrar Mezcla Suerte en el menú', luckyMixMenuDesc: 'Activa Mezcla Suerte en "Crear Mezcla" y como elemento de menú separado cuando la navegación dividida está activa. Solo visible cuando AudioMuse está activo en el servidor actual.', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 479766d7..d6384edb 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -225,6 +225,10 @@ export const settings = { aboutContributorsLabel: 'Contributeurs', showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour", showChangelogOnUpdateDesc: "Affiche une bannière discrète du changelog au-dessus de Now Playing après une mise à jour. Un clic ouvre les notes de version, le X la masque.", + libraryGridMaxColumnsTitle: 'Grilles de cartes dans la bibliothèque', + libraryGridMaxColumnsPerfHint: 'Plus de colonnes signifie plus de tuiles par ligne et davantage de travail de mise en page et de peinture — surtout sur de grosses bibliothèques ou du matériel lent.', + libraryGridMaxColumnsRangeLabel: 'Nombre maximal de colonnes ({{min}}–{{max}})', + libraryGridMaxColumnsDesc: 'S’applique aux vues album, artiste, liste de lecture, radio, hors ligne et autres pages en cartes. Moins de colonnes = tuiles plus grandes et en général moins de charge CPU.', randomMixTitle: 'Liste noire du mix aléatoire', luckyMixMenuTitle: 'Afficher Mix Chance dans le menu', luckyMixMenuDesc: 'Active Mix Chance dans "Créer un mix" et comme entrée séparée quand la navigation est scindée. Visible uniquement si AudioMuse est actif sur le serveur courant.', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 1e5015fe..ca7fd64b 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -224,6 +224,10 @@ export const settings = { aboutContributorsLabel: 'Bidragsytere', showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon", showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.", + libraryGridMaxColumnsTitle: 'Kortrutenett i biblioteket', + libraryGridMaxColumnsPerfHint: 'Flere kolonner gir flere fliser per rad og kan øke layout- og tegnearbeid — merkbart på veldig store biblioteker eller tregere maskiner.', + libraryGridMaxColumnsRangeLabel: 'Maksimalt antall kolonner ({{min}}–{{max}})', + libraryGridMaxColumnsDesc: 'Gjelder album-, artist-, spilleliste-, radio-, offline- og andre kortbaserte bibliotekvisninger. Færre kolonner = større fliser og vanligvis mindre CPU-belastning.', randomMixTitle: 'Svarteliste for tilfeldig miks', luckyMixMenuTitle: 'Vis Lykkemiks i menyen', luckyMixMenuDesc: 'Aktiverer Lykkemiks i "Lag en miks" og som eget menypunkt når delt navigasjon er aktiv. Vises bare når AudioMuse er aktiv på gjeldende server.', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 421fb0fd..037d5abf 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -225,6 +225,10 @@ export const settings = { aboutContributorsLabel: 'Bijdragers', showChangelogOnUpdate: "'Wat is nieuw' tonen bij update", showChangelogOnUpdateDesc: 'Toont een discrete changelog-banner boven Now Playing na een update. Klik opent de release-notities; X verbergt hem.', + libraryGridMaxColumnsTitle: 'Kaartenrasters in de bibliotheek', + libraryGridMaxColumnsPerfHint: 'Meer kolommen betekent meer tegels per rij en vaak meer lay-out- en schilderwerk — merkbaar bij zeer grote bibliotheken of tragere hardware.', + libraryGridMaxColumnsRangeLabel: 'Maximum aantal kolommen ({{min}}–{{max}})', + libraryGridMaxColumnsDesc: 'Geldt voor album-, artiest-, afspeellijst-, radio-, offline- en andere kaartweergaven. Minder kolommen = grotere tegels en meestal minder CPU-belasting.', randomMixTitle: 'Willekeurige mix-blacklist', luckyMixMenuTitle: 'Toon Geluksmix in menu', luckyMixMenuDesc: 'Schakelt Geluksmix in bij "Mix samenstellen" en als apart menu-item bij gesplitste navigatie. Alleen zichtbaar wanneer AudioMuse actief is op de huidige server.', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 28b9362a..c1535c83 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -231,6 +231,10 @@ export const settings = { aboutContributorsLabel: 'Contribuabili', showChangelogOnUpdate: "Arată 'Ce este nou' în update", showChangelogOnUpdateDesc: "Arată un banner discret cu lista modificărilor deasupra Now Playing după un update. Apasă pentru a deschide lista modificărilor; X îl respinge.", + libraryGridMaxColumnsTitle: 'Grile cu carduri în bibliotecă', + libraryGridMaxColumnsPerfHint: 'Mai multe coloane înseamnă mai multe plăcuțe pe rând și mai multă muncă de layout și desen — se simte pe biblioteci foarte mari sau hardware lent.', + libraryGridMaxColumnsRangeLabel: 'Număr maxim de coloane ({{min}}–{{max}})', + libraryGridMaxColumnsDesc: 'Se aplică la albume, artiști, liste de redare, radio, offline și alte ecrane cu carduri. Mai puține coloane = plăcuțe mai mari și de obicei mai puțină încărcare pe CPU.', randomMixTitle: 'Lista neagră Mix Aleatoriu', luckyMixMenuTitle: 'Arată Mixul Norocos în meniu', luckyMixMenuDesc: 'Activează Mixul Norocos în Construiește un Mix și ca un element separat în meniu când navigarea împărțită este pornită. Vizibil doar când AudioMuse este activat pe serverul activ.', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index e7920bb6..cf59ae2a 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -237,6 +237,10 @@ export const settings = { aboutContributorsLabel: 'Участники', showChangelogOnUpdate: 'Показывать «Что нового» после обновления', showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.', + libraryGridMaxColumnsTitle: 'Сетки карточек в библиотеке', + libraryGridMaxColumnsPerfHint: 'Больше колонок — больше плиток в ряд и больше работы по вёрстке и отрисовке; на очень больших библиотеках или слабом железе это заметнее.', + libraryGridMaxColumnsRangeLabel: 'Максимум колонок ({{min}}–{{max}})', + libraryGridMaxColumnsDesc: 'Действует для альбомов, исполнителей, плейлистов, радио, офлайн-библиотеки и других экранов с карточками. Меньше колонок — крупнее плитки и обычно меньше нагрузка на CPU.', randomMixTitle: 'Чёрный список случайного микса', luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню', luckyMixMenuDesc: diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 16c3d77d..d59b189b 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -225,6 +225,10 @@ export const settings = { aboutContributorsLabel: '贡献者', showChangelogOnUpdate: '更新时显示"新功能"', showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。', + libraryGridMaxColumnsTitle: '资料库卡片网格', + libraryGridMaxColumnsPerfHint: '列数越多,每行显示的卡片越多,布局与绘制开销通常也越大;在超大曲库或较慢设备上更明显。', + libraryGridMaxColumnsRangeLabel: '最大列数({{min}}–{{max}})', + libraryGridMaxColumnsDesc: '适用于专辑、艺人、播放列表、电台、离线及其他卡片式资料库视图。列数越少卡片越大,通常对 CPU 更友好。', randomMixTitle: '随机混音黑名单', luckyMixMenuTitle: '在菜单中显示“好运混音”', luckyMixMenuDesc: '在“创建混音”中启用“好运混音”,并在分离导航时作为独立菜单项显示。仅当当前服务器启用 AudioMuse 时可见。', diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts index b9e790e2..60fd96fe 100644 --- a/src/store/authStore.settings.test.ts +++ b/src/store/authStore.settings.test.ts @@ -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); diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 0b41eda1..fa786245 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -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()( infiniteQueueEnabled: false, preservePlayNextOrder: false, showArtistImages: false, + libraryGridMaxColumns: DEFAULT_LIBRARY_GRID_MAX_COLUMNS, showTrayIcon: true, minimizeToTray: false, showOrbitTrigger: true, diff --git a/src/store/authStoreDefaults.ts b/src/store/authStoreDefaults.ts index 37bcfa77..0dc9f512 100644 --- a/src/store/authStoreDefaults.ts +++ b/src/store/authStoreDefaults.ts @@ -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; diff --git a/src/store/authStoreHelpers.ts b/src/store/authStoreHelpers.ts index 2105f374..2421e5da 100644 --- a/src/store/authStoreHelpers.ts +++ b/src/store/authStoreHelpers.ts @@ -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))); diff --git a/src/store/authStoreRehydrate.ts b/src/store/authStoreRehydrate.ts index 7ecb2b59..969b0d4c 100644 --- a/src/store/authStoreRehydrate.ts +++ b/src/store/authStoreRehydrate.ts @@ -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 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; diff --git a/src/store/authUiAppearanceActions.ts b/src/store/authUiAppearanceActions.ts index 477b83f5..bd964c05 100644 --- a/src/store/authUiAppearanceActions.ts +++ b/src/store/authUiAppearanceActions.ts @@ -1,4 +1,5 @@ import type { AuthState } from './authStoreTypes'; +import { clampLibraryGridMaxColumns } from './authStoreHelpers'; type SetState = ( partial: Partial | ((state: AuthState) => Partial), @@ -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 }), diff --git a/src/utils/cardGridLayout.test.ts b/src/utils/cardGridLayout.test.ts index 75faff41..cbdb9e67 100644 --- a/src/utils/cardGridLayout.test.ts +++ b/src/utils/cardGridLayout.test.ts @@ -1,16 +1,26 @@ import { describe, expect, it } from 'vitest'; -import { computeCardGridColumnCount, CARD_GRID_MAX_COLS } from './cardGridLayout'; +import { computeCardGridColumnCount } from './cardGridLayout'; +import { LIBRARY_GRID_MAX_COLUMNS_MAX, LIBRARY_GRID_MAX_COLUMNS_MIN } from '../store/authStoreDefaults'; describe('computeCardGridColumnCount', () => { - it('never exceeds CARD_GRID_MAX_COLS', () => { - expect(computeCardGridColumnCount(20_000)).toBe(CARD_GRID_MAX_COLS); + it('never exceeds the configured max', () => { + expect(computeCardGridColumnCount(20_000, 6)).toBe(6); + expect(computeCardGridColumnCount(20_000, 4)).toBe(4); + }); + + it('clamps requested max to store-wide upper bound', () => { + expect(computeCardGridColumnCount(20_000, 99)).toBe(LIBRARY_GRID_MAX_COLUMNS_MAX); + }); + + it('clamps requested max to store-wide lower bound', () => { + expect(computeCardGridColumnCount(20_000, 2)).toBe(LIBRARY_GRID_MAX_COLUMNS_MIN); }); it('returns at least one column', () => { - expect(computeCardGridColumnCount(50)).toBe(1); + expect(computeCardGridColumnCount(50, 6)).toBe(1); }); - it('uses six columns on wide desktop widths', () => { - expect(computeCardGridColumnCount(1200)).toBe(6); + it('uses six columns on wide desktop when max allows', () => { + expect(computeCardGridColumnCount(1200, 6)).toBe(6); }); }); diff --git a/src/utils/cardGridLayout.ts b/src/utils/cardGridLayout.ts index 9b10e4d0..dd82a95c 100644 --- a/src/utils/cardGridLayout.ts +++ b/src/utils/cardGridLayout.ts @@ -3,15 +3,27 @@ * and row-height estimates derived from measured cell width (TanStack virtual rows). */ +import { + DEFAULT_LIBRARY_GRID_MAX_COLUMNS, + LIBRARY_GRID_MAX_COLUMNS_MAX, + LIBRARY_GRID_MAX_COLUMNS_MIN, +} from '../store/authStoreDefaults'; + export const CARD_GRID_GAP_PX = 16; export const CARD_GRID_MIN_TILE_PX = 140; -export const CARD_GRID_MAX_COLS = 6; -export function computeCardGridColumnCount(containerWidthPx: number): number { +/** @deprecated use `DEFAULT_LIBRARY_GRID_MAX_COLUMNS` from `authStoreDefaults` */ +export const CARD_GRID_MAX_COLS = DEFAULT_LIBRARY_GRID_MAX_COLUMNS; + +export function computeCardGridColumnCount(containerWidthPx: number, maxColumns: number): number { + const cap = Math.max( + LIBRARY_GRID_MAX_COLUMNS_MIN, + Math.min(LIBRARY_GRID_MAX_COLUMNS_MAX, Math.round(maxColumns)), + ); const raw = Math.floor( (containerWidthPx + CARD_GRID_GAP_PX) / (CARD_GRID_MIN_TILE_PX + CARD_GRID_GAP_PX), ); - return Math.min(CARD_GRID_MAX_COLS, Math.max(1, raw)); + return Math.min(cap, Math.max(1, raw)); } export function computeCellWidthPx(containerWidthPx: number, columnCount: number): number {