mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
@@ -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() {
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.libraryGridMaxColumnsTitle')}
|
||||
icon={<LayoutGrid size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.libraryGridMaxColumnsPerfHint')}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" htmlFor="library-grid-max-cols">
|
||||
{t('settings.libraryGridMaxColumnsRangeLabel', {
|
||||
min: LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||
max: LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||
})}
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: 8 }}>
|
||||
<input
|
||||
id="library-grid-max-cols"
|
||||
type="range"
|
||||
min={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||
max={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||
step={1}
|
||||
value={auth.libraryGridMaxColumns}
|
||||
onChange={e => 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}
|
||||
/>
|
||||
<span style={{ minWidth: 28, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{auth.libraryGridMaxColumns}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.75rem', lineHeight: 1.45 }}>
|
||||
{t('settings.libraryGridMaxColumnsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.visualOptionsTitle')}
|
||||
icon={<Palette size={16} />}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<HTMLElement | null>,
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -237,6 +237,10 @@ export const settings = {
|
||||
aboutContributorsLabel: 'Участники',
|
||||
showChangelogOnUpdate: 'Показывать «Что нового» после обновления',
|
||||
showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.',
|
||||
libraryGridMaxColumnsTitle: 'Сетки карточек в библиотеке',
|
||||
libraryGridMaxColumnsPerfHint: 'Больше колонок — больше плиток в ряд и больше работы по вёрстке и отрисовке; на очень больших библиотеках или слабом железе это заметнее.',
|
||||
libraryGridMaxColumnsRangeLabel: 'Максимум колонок ({{min}}–{{max}})',
|
||||
libraryGridMaxColumnsDesc: 'Действует для альбомов, исполнителей, плейлистов, радио, офлайн-библиотеки и других экранов с карточками. Меньше колонок — крупнее плитки и обычно меньше нагрузка на CPU.',
|
||||
randomMixTitle: 'Чёрный список случайного микса',
|
||||
luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню',
|
||||
luckyMixMenuDesc:
|
||||
|
||||
@@ -225,6 +225,10 @@ export const settings = {
|
||||
aboutContributorsLabel: '贡献者',
|
||||
showChangelogOnUpdate: '更新时显示"新功能"',
|
||||
showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。',
|
||||
libraryGridMaxColumnsTitle: '资料库卡片网格',
|
||||
libraryGridMaxColumnsPerfHint: '列数越多,每行显示的卡片越多,布局与绘制开销通常也越大;在超大曲库或较慢设备上更明显。',
|
||||
libraryGridMaxColumnsRangeLabel: '最大列数({{min}}–{{max}})',
|
||||
libraryGridMaxColumnsDesc: '适用于专辑、艺人、播放列表、电台、离线及其他卡片式资料库视图。列数越少卡片越大,通常对 CPU 更友好。',
|
||||
randomMixTitle: '随机混音黑名单',
|
||||
luckyMixMenuTitle: '在菜单中显示“好运混音”',
|
||||
luckyMixMenuDesc: '在“创建混音”中启用“好运混音”,并在分离导航时作为独立菜单项显示。仅当当前服务器启用 AudioMuse 时可见。',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user