feat(ui): shared card grid with max six columns and virtualization

Add cardGridLayout helpers, useCardGridMetrics, useRemeasureGridVirtualizer, and VirtualCardGrid (TanStack row virtualization, always remeasure on layout changes).

Apply to Artists (grid + list unchanged policy), Albums, Composers grid, playlists, radio stations, offline library, and album-heavy browse/detail pages. Respect disableMainstageVirtualLists for a non-virtual grid with the same column rules.

Includes vitest coverage for column cap.
This commit is contained in:
Maxim Isaev
2026-05-15 02:05:34 +03:00
parent 778c9e00fd
commit 7c6d3694d4
20 changed files with 601 additions and 202 deletions
+18 -101
View File
@@ -4,7 +4,7 @@ import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { dedupeById } from '../utils/dedupeById';
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import YearFilterButton from '../components/YearFilterButton';
@@ -20,16 +20,9 @@ import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/ui/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { useRangeSelection } from '../hooks/useRangeSelection';
const ALBUM_GRID_GAP_PX = 16; // matches --space-4
const ALBUM_GRID_MIN_CARD_PX = 140;
/** Estimated row height for virtual window (card + margin). */
const ALBUM_VIRTUAL_ROW_HEIGHT = 288;
import { VirtualCardGrid } from '../components/VirtualCardGrid';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
type CompFilter = 'all' | 'only' | 'hide';
@@ -94,38 +87,6 @@ export default function Albums() {
resetSelection();
};
const albumGridWrapRef = useRef<HTMLDivElement>(null);
const [albumGridCols, setAlbumGridCols] = useState(4);
useLayoutEffect(() => {
if (perfFlags.disableMainstageVirtualLists) return;
const el = albumGridWrapRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const w = el.clientWidth;
const cols = Math.max(1, Math.floor((w + ALBUM_GRID_GAP_PX) / (ALBUM_GRID_MIN_CARD_PX + ALBUM_GRID_GAP_PX)));
setAlbumGridCols(cols);
});
ro.observe(el);
return () => ro.disconnect();
}, [perfFlags.disableMainstageVirtualLists, visibleAlbums.length]);
const albumVirtualRowCount = Math.max(0, Math.ceil(visibleAlbums.length / albumGridCols));
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
/** ~One full viewport of grid rows above + below visible range (TanStack overscan = rows per side). */
const albumGridOverscan = Math.max(
2,
Math.ceil(mainScrollViewportHeight / ALBUM_VIRTUAL_ROW_HEIGHT),
);
const albumGridVirtualizer = useVirtualizer({
count: perfFlags.disableMainstageVirtualLists ? 0 : albumVirtualRowCount,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
estimateSize: () => ALBUM_VIRTUAL_ROW_HEIGHT,
overscan: albumGridOverscan,
});
const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const enqueue = usePlayerStore(state => state.enqueue);
@@ -352,66 +313,22 @@ export default function Albums() {
) : (
<>
{!perfFlags.disableMainstageGridCards && (
perfFlags.disableMainstageVirtualLists ? (
<div className="album-grid-wrap">
{visibleAlbums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
) : (
<div
ref={albumGridWrapRef}
className="album-grid-wrap"
style={{ display: 'block', position: 'relative', width: '100%' }}
>
<div
style={{
height: albumVirtualRowCount === 0 ? 0 : albumGridVirtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{albumGridVirtualizer.getVirtualItems().map(vRow => {
const start = vRow.index * albumGridCols;
const rowAlbums = visibleAlbums.slice(start, start + albumGridCols);
return (
<div
key={vRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vRow.start}px)`,
display: 'grid',
gridTemplateColumns: `repeat(${albumGridCols}, minmax(0, 1fr))`,
gap: 'var(--space-4)',
alignItems: 'start',
}}
>
{rowAlbums.map(a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
);
})}
</div>
</div>
)
<VirtualCardGrid
items={visibleAlbums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={visibleAlbums.length}
renderItem={a => (
<AlbumCard
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
)}
/>
)}
{!genreFiltered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>