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
+120
View File
@@ -0,0 +1,120 @@
import React, { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
import { useCardGridMetrics } from '../hooks/useCardGridMetrics';
import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer';
import type { CardGridRowHeightVariant } from '../utils/cardGridLayout';
export type VirtualCardGridProps<T> = {
items: readonly T[];
itemKey: (item: T, flatIndex: number) => string;
renderItem: (item: T) => React.ReactNode;
rowVariant: CardGridRowHeightVariant;
disableVirtualization: boolean;
/** Bumps layout when list shape changes (e.g. `items.length`). */
layoutSignal: number;
wrapClassName?: string;
/** Optional styles on the outer measurement wrapper (e.g. enter animation). */
wrapStyle?: React.CSSProperties;
/** Defaults to `var(--space-4)`; composer grid uses `var(--space-2)`. */
gridGap?: string;
};
/**
* Album-/playlist-style card grids: at most six columns, proportional stretch,
* optional row virtualization with scroll root `#APP_MAIN_SCROLL_VIEWPORT_ID`.
*/
export function VirtualCardGrid<T>({
items,
itemKey,
renderItem,
rowVariant,
disableVirtualization,
layoutSignal,
wrapClassName = 'album-grid-wrap',
wrapStyle,
gridGap = 'var(--space-4)',
}: VirtualCardGridProps<T>): React.JSX.Element {
const wrapRef = useRef<HTMLDivElement>(null);
const { gridCols, rowHeightEst } = useCardGridMetrics(wrapRef, true, rowVariant, layoutSignal);
const cols = Math.max(1, gridCols);
const virtualRowCount = Math.max(0, Math.ceil(items.length / cols));
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
const overscan = Math.max(2, Math.ceil(mainScrollViewportHeight / Math.max(1, rowHeightEst)));
const virtualizer = useVirtualizer({
count: disableVirtualization ? 0 : virtualRowCount,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
estimateSize: () => rowHeightEst,
overscan,
});
useRemeasureGridVirtualizer(virtualizer, {
active: !disableVirtualization && virtualRowCount > 0,
gridCols: cols,
rowHeightEst,
virtualRowCount,
});
if (disableVirtualization) {
return (
<div
ref={wrapRef}
className={wrapClassName}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gap: gridGap,
alignItems: 'start',
...wrapStyle,
}}
>
{items.map((item, i) => (
<React.Fragment key={itemKey(item, i)}>{renderItem(item)}</React.Fragment>
))}
</div>
);
}
return (
<div
ref={wrapRef}
className={wrapClassName}
style={{ display: 'block', position: 'relative', width: '100%', ...wrapStyle }}
>
<div
style={{
height: virtualRowCount === 0 ? 0 : virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(vRow => {
const start = vRow.index * cols;
const rowItems = items.slice(start, start + cols);
return (
<div
key={vRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vRow.start}px)`,
display: 'grid',
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gap: gridGap,
alignItems: 'start',
}}
>
{rowItems.map((item, i) => (
<React.Fragment key={itemKey(item, start + i)}>{renderItem(item)}</React.Fragment>
))}
</div>
);
})}
</div>
</div>
);
}
+137 -40
View File
@@ -1,13 +1,78 @@
import React from 'react';
import type { NavigateFunction } from 'react-router-dom';
import type { Virtualizer } from '@tanstack/react-virtual';
import { Check } from 'lucide-react';
import type { TFunction } from 'i18next';
import type { SubsonicArtist } from '../../api/subsonicTypes';
import type { PlayerState } from '../../store/playerStoreTypes';
import { ArtistCardAvatar } from './ArtistAvatars';
export type ArtistsGridVirtualization = {
virtualizer: Virtualizer<HTMLElement, Element>;
};
interface TileProps {
artist: SubsonicArtist;
selectionMode: boolean;
selectedIds: Set<string>;
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
navigate: NavigateFunction;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
type TilePropsShared = Omit<TileProps, 'artist'>;
function ArtistGridTile({ artist, ...rest }: TileProps) {
return (
<div
className={`artist-card${rest.selectionMode && rest.selectedIds.has(artist.id) ? ' selected' : ''}${rest.selectionMode ? ' artist-card--selectable' : ''}`}
onClick={() => {
if (rest.selectionMode) {
rest.toggleSelect(artist.id);
} else {
rest.navigate(`/artist/${artist.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
if (rest.selectionMode && rest.selectedIds.size > 0) {
rest.openContextMenu(e.clientX, e.clientY, rest.selectedArtists, 'multi-artist');
} else {
rest.openContextMenu(e.clientX, e.clientY, artist, 'artist');
}
}}
style={rest.selectionMode && rest.selectedIds.has(artist.id) ? {
outline: '2px solid var(--accent)',
outlineOffset: '2px',
borderRadius: 'var(--radius-md)',
} : {}}
>
{rest.selectionMode && (
<div className={`artist-card-select-check${rest.selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
{rest.selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
<ArtistCardAvatar artist={artist} showImages={rest.showArtistImages} />
<div style={{ textAlign: 'center' }}>
<div className="artist-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-card-meta">{rest.t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</div>
);
}
interface Props {
visible: SubsonicArtist[];
/** Column count from layout (capped at six in parent); drives `repeat(n, minmax(0,1fr))`. */
gridCols: number;
/** ResizeObserver target — same node for plain and virtual grid. */
measureRef: React.RefObject<HTMLDivElement | null>;
virtualization?: ArtistsGridVirtualization | null;
selectionMode: boolean;
selectedIds: Set<string>;
selectedArtists: SubsonicArtist[];
@@ -19,13 +84,14 @@ interface Props {
}
/**
* Card grid for the artists page. Click navigates to the artist detail
* (or toggles selection while in select-mode); right-click opens the
* standard context menu, escalating to `multi-artist` when there is an
* active multi-selection.
* Card grid for the artists page. Optional row virtualization (TanStack) for
* large libraries; column count and `measureRef` always come from the parent.
*/
export function ArtistsGridView({
visible,
gridCols,
measureRef,
virtualization,
selectionMode,
selectedIds,
selectedArtists,
@@ -35,46 +101,77 @@ export function ArtistsGridView({
openContextMenu,
t,
}: Props) {
return (
<div className="album-grid-wrap">
{visible.map(artist => (
const tilePropsShared: TilePropsShared = {
selectionMode,
selectedIds,
selectedArtists,
showArtistImages,
toggleSelect,
navigate,
openContextMenu,
t,
};
const cols = Math.max(1, gridCols);
if (virtualization) {
const { virtualizer } = virtualization;
const rowCount = Math.ceil(visible.length / cols);
return (
<div
ref={measureRef}
className="album-grid-wrap"
style={{ display: 'block', position: 'relative', width: '100%' }}
>
<div
key={artist.id}
className={`artist-card${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}${selectionMode ? ' artist-card--selectable' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(artist.id);
} else {
navigate(`/artist/${artist.id}`);
}
style={{
height: rowCount === 0 ? 0 : virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
onContextMenu={(e) => {
e.preventDefault();
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
} else {
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}
}}
style={selectionMode && selectedIds.has(artist.id) ? {
outline: '2px solid var(--accent)',
outlineOffset: '2px',
borderRadius: 'var(--radius-md)',
} : {}}
>
{selectionMode && (
<div className={`artist-card-select-check${selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
{selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'center' }}>
<div className="artist-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
{virtualizer.getVirtualItems().map(vRow => {
const start = vRow.index * cols;
const rowArtists = visible.slice(start, start + cols);
return (
<div
key={vRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vRow.start}px)`,
display: 'grid',
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gap: 'var(--space-4)',
alignItems: 'start',
}}
>
{rowArtists.map(artist => (
<ArtistGridTile key={artist.id} artist={artist} {...tilePropsShared} />
))}
</div>
);
})}
</div>
</div>
);
}
return (
<div
ref={measureRef}
className="album-grid-wrap"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gap: 'var(--space-4)',
alignItems: 'start',
}}
>
{visible.map(artist => (
<ArtistGridTile key={artist.id} artist={artist} {...tilePropsShared} />
))}
</div>
);
+42
View File
@@ -0,0 +1,42 @@
import { useLayoutEffect, useState, type RefObject } from 'react';
import {
CARD_GRID_MAX_COLS,
type CardGridRowHeightVariant,
computeCardGridColumnCount,
computeCellWidthPx,
estimateRowHeightPx,
} from '../utils/cardGridLayout';
/**
* ResizeObserver-driven column count (max six) and virtual row height estimate
* from the measured cell width.
*/
export function useCardGridMetrics(
measureRef: RefObject<HTMLElement | null>,
observerEnabled: boolean,
variant: CardGridRowHeightVariant,
layoutSignal: number,
): { gridCols: number; rowHeightEst: number } {
const [gridCols, setGridCols] = useState(4);
const [rowHeightEst, setRowHeightEst] = useState(() =>
estimateRowHeightPx(computeCellWidthPx(960, CARD_GRID_MAX_COLS), variant),
);
useLayoutEffect(() => {
if (!observerEnabled) return;
const el = measureRef.current;
if (!el) return;
const onResize = () => {
const w = el.clientWidth;
const cols = computeCardGridColumnCount(w);
setGridCols(cols);
setRowHeightEst(estimateRowHeightPx(computeCellWidthPx(w, cols), variant));
};
onResize();
const ro = new ResizeObserver(onResize);
ro.observe(el);
return () => ro.disconnect();
}, [observerEnabled, variant, layoutSignal]);
return { gridCols, rowHeightEst };
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from 'react';
import type { Virtualizer } from '@tanstack/react-virtual';
/** When grid column count or row height estimate changes, TanStack can keep stale offsets until scroll — force a remeasure. */
export function useRemeasureGridVirtualizer<TScroll extends Element, TItem extends Element>(
virtualizer: Virtualizer<TScroll, TItem>,
args: {
active: boolean;
gridCols: number;
rowHeightEst: number;
virtualRowCount: number;
},
): void {
useEffect(() => {
if (!args.active || args.virtualRowCount === 0) return;
virtualizer.measure();
}, [
args.active,
args.gridCols,
args.rowHeightEst,
args.virtualRowCount,
virtualizer,
]);
}
+11 -3
View File
@@ -27,9 +27,12 @@ import { showToast } from '../utils/ui/toast';
import { useSelectionStore } from '../store/selectionStore';
import { sanitizeFilename } from '../utils/componentHelpers/albumDetailHelpers';
import { deriveAlbumHeaderArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
export default function AlbumDetail() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const auth = useAuthStore();
@@ -359,9 +362,14 @@ const handleShuffleAll = () => {
<div className="album-related">
<div className="album-related-divider" />
<h2 className="section-title album-related-title">{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
<div className="album-grid-wrap">
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
<VirtualCardGrid
items={relatedAlbums}
itemKey={(a, i) => `${a.id}-${i}`}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={relatedAlbums.length}
renderItem={a => <AlbumCard album={a} />}
/>
</div>
)}
</div>
+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' }}>
+31 -9
View File
@@ -40,10 +40,13 @@ import {
import ArtistDetailHero from '../components/artistDetail/ArtistDetailHero';
import ArtistDetailTopTracks from '../components/artistDetail/ArtistDetailTopTracks';
import ArtistDetailSimilarArtists from '../components/artistDetail/ArtistDetailSimilarArtists';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
export default function ArtistDetail() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const {
@@ -364,18 +367,30 @@ export default function ArtistDetail() {
</h2>
{albums.length > 0 ? (
groupedAlbums.length === 1 ? (
<div className="album-grid-wrap album-grid-wrap--artist">
{albums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
</div>
<VirtualCardGrid
items={albums}
itemKey={(a, i) => `${a.id}-${i}`}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
wrapClassName="album-grid-wrap album-grid-wrap--artist"
renderItem={a => <AlbumCard album={a} />}
/>
) : groupedAlbums.map(([label, group]) => (
<div key={label} className="artist-release-group">
<div className="artist-release-group__header">
<h3>{label}</h3>
<span className="artist-release-group__count">{group.length}</span>
</div>
<div className="album-grid-wrap album-grid-wrap--artist">
{group.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
</div>
<VirtualCardGrid
items={group}
itemKey={(a, i) => `${a.id}-${i}`}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={group.length}
wrapClassName="album-grid-wrap album-grid-wrap--artist"
renderItem={a => <AlbumCard album={a} />}
/>
</div>
))
) : (
@@ -396,9 +411,16 @@ export default function ArtistDetail() {
))}
</div>
) : (
<div className="album-grid-wrap album-grid-wrap--artist" style={{ animation: 'fadeIn 0.3s ease' }}>
{featuredAlbums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
</div>
<VirtualCardGrid
items={featuredAlbums}
itemKey={(a, i) => `${a.id}-${i}`}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={featuredAlbums.length}
wrapClassName="album-grid-wrap album-grid-wrap--artist"
wrapStyle={{ animation: 'fadeIn 0.3s ease' }}
renderItem={a => <AlbumCard album={a} />}
/>
)}
</Fragment>
);
+43 -1
View File
@@ -1,6 +1,6 @@
import { getArtists } from '../api/subsonicArtists';
import type { SubsonicArtist } from '../api/subsonicTypes';
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react';
import StarFilterButton from '../components/StarFilterButton';
@@ -10,6 +10,8 @@ import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
import { useCardGridMetrics } from '../hooks/useCardGridMetrics';
import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import {
ALL_SENTINEL,
@@ -81,6 +83,39 @@ export default function Artists() {
} = useArtistsFiltering({ artists, filter, letterFilter, starredOnly, visibleCount, viewMode });
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
const artistGridMeasureRef = useRef<HTMLDivElement>(null);
const { gridCols: artistGridCols, rowHeightEst: artistGridRowHeightEst } = useCardGridMetrics(
artistGridMeasureRef,
viewMode === 'grid',
'artist',
visible.length,
);
const artistVirtualRowCount = Math.max(0, Math.ceil(visible.length / Math.max(1, artistGridCols)));
const artistGridOverscan = Math.max(
2,
Math.ceil(mainScrollViewportHeight / Math.max(1, artistGridRowHeightEst)),
);
const artistGridVirtualizer = useVirtualizer({
count:
perfFlags.disableMainstageVirtualLists || viewMode !== 'grid'
? 0
: artistVirtualRowCount,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
estimateSize: () => artistGridRowHeightEst,
overscan: artistGridOverscan,
});
useRemeasureGridVirtualizer(artistGridVirtualizer, {
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'grid' && artistVirtualRowCount > 0,
gridCols: artistGridCols,
rowHeightEst: artistGridRowHeightEst,
virtualRowCount: artistVirtualRowCount,
});
/** Mixed row heights; smallest typical step ≈ artist row — one viewport of extra indices each side. */
const artistListOverscan = Math.max(
12,
@@ -188,6 +223,13 @@ export default function Artists() {
{!loading && viewMode === 'grid' && (
<ArtistsGridView
visible={visible}
gridCols={artistGridCols}
measureRef={artistGridMeasureRef}
virtualization={
perfFlags.disableMainstageVirtualLists
? null
: { virtualizer: artistGridVirtualizer }
}
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedArtists={selectedArtists}
+11 -3
View File
@@ -16,9 +16,12 @@ import { useTranslation } from 'react-i18next';
import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
import { showToast } from '../utils/ui/toast';
import { sanitizeHtml } from '../utils/sanitizeHtml';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
export default function ComposerDetail() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
@@ -266,9 +269,14 @@ export default function ComposerDetail() {
{t('composerDetail.noWorks')}
</div>
) : (
<div className="album-grid-wrap">
{albums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
</div>
<VirtualCardGrid
items={albums}
itemKey={(a, i) => `${a.id}-${i}`}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
renderItem={a => <AlbumCard album={a} />}
/>
)}
</div>
);
+12 -5
View File
@@ -11,6 +11,7 @@ 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 { VirtualCardGrid } from '../components/VirtualCardGrid';
const ALL_SENTINEL = 'ALL';
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
@@ -276,10 +277,16 @@ export default function Composers() {
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
{!loading && viewMode === 'grid' && (
<div className="composer-grid-wrap">
{visible.map(artist => (
<VirtualCardGrid
items={visible}
itemKey={(a, _i) => a.id}
rowVariant="composer"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={visible.length}
wrapClassName="composer-grid-wrap"
gridGap="var(--space-2)"
renderItem={artist => (
<div
key={artist.id}
className="composer-card"
onClick={() => navigate(`/composer/${artist.id}`)}
onContextMenu={(e) => {
@@ -294,8 +301,8 @@ export default function Composers() {
</div>
)}
</div>
))}
</div>
)}
/>
)}
{!loading && viewMode === 'list' && (
+11 -3
View File
@@ -6,6 +6,8 @@ import { useTranslation } from 'react-i18next';
import { ArrowLeft, Disc3 } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import AlbumCard from '../components/AlbumCard';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
const PAGE_SIZE = 50;
@@ -13,6 +15,7 @@ export default function GenreDetail() {
const { name } = useParams<{ name: string }>();
const genre = decodeURIComponent(name ?? '');
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
@@ -72,9 +75,14 @@ export default function GenreDetail() {
{!loading && albums.length === 0 && <p className="loading-text">{t('genres.albumsEmpty')}</p>}
{albums.length > 0 && (
<div className="album-grid-wrap">
{albums.map(album => <AlbumCard key={album.id} album={album} />)}
</div>
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
renderItem={album => <AlbumCard album={album} />}
/>
)}
{hasMore && !loading && (
+12 -5
View File
@@ -16,9 +16,12 @@ import AlphabetFilterBar from '../components/internetRadio/AlphabetFilterBar';
import RadioCard from '../components/internetRadio/RadioCard';
import RadioEditModal from '../components/internetRadio/RadioEditModal';
import RadioDirectoryModal from '../components/internetRadio/RadioDirectoryModal';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
export default function InternetRadio() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const playRadio = usePlayerStore(s => s.playRadio);
const stop = usePlayerStore(s => s.stop);
const currentRadio = usePlayerStore(s => s.currentRadio);
@@ -247,10 +250,14 @@ export default function InternetRadio() {
{displayedStations.length === 0 ? (
<div className="empty-state">{t('radio.noFavorites')}</div>
) : (
<div className="album-grid-wrap">
{displayedStations.map(s => (
<VirtualCardGrid
items={displayedStations}
itemKey={(s, _i) => s.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={displayedStations.length}
renderItem={s => (
<RadioCard
key={s.id}
s={s}
isActive={currentRadio?.id === s.id}
isPlaying={isPlaying}
@@ -267,8 +274,8 @@ export default function InternetRadio() {
onDropOnto={(srcId, side) => handleReorder(srcId, s.id, side)}
onCardMouseLeave={() => { if (deleteConfirmId === s.id) setDeleteConfirmId(null); }}
/>
))}
</div>
)}
/>
)}
</>
)}
+11 -5
View File
@@ -6,9 +6,12 @@ import { ChevronLeft } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
export default function LabelAlbums() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -53,11 +56,14 @@ export default function LabelAlbums() {
) : albums.length === 0 ? (
<div className="empty-state">{t('common.noAlbums')}</div>
) : (
<div className="album-grid-wrap">
{albums.map(a => (
<AlbumCard key={a.id} album={a} />
))}
</div>
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
renderItem={a => <AlbumCard album={a} />}
/>
)}
</div>
);
+10 -5
View File
@@ -17,6 +17,7 @@ import { showToast } from '../utils/ui/toast';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { CheckSquare2, Download, HardDriveDownload, ListPlus } from 'lucide-react';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
/** Per-loadMore budget — tuned for snappy initial paint over completeness.
* 100 songs ≈ 500 KB response (Navidrome's /api/song carries lyrics/tags/
@@ -261,18 +262,22 @@ export default function LosslessAlbums() {
</div>
) : (
<>
<div className="album-grid-wrap">
{albums.map(a => (
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
renderItem={a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
)}
/>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
+12 -5
View File
@@ -17,6 +17,8 @@ import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/ui/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { useRangeSelection } from '../hooks/useRangeSelection';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
const PAGE_SIZE = 30;
@@ -31,6 +33,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function NewReleases() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -177,18 +180,22 @@ export default function NewReleases() {
</div>
) : (
<>
<div className="album-grid-wrap">
{albums.map(a => (
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
renderItem={a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
)}
/>
{!filtered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
+20 -7
View File
@@ -6,11 +6,14 @@ import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import CachedImage from '../components/CachedImage';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
type FilterType = 'all' | 'album' | 'playlist' | 'artist';
export default function OfflineLibrary() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const offlineAlbums = useOfflineStore(s => s.albums);
const offlineTracks = useOfflineStore(s => s.tracks);
@@ -62,7 +65,7 @@ export default function OfflineLibrary() {
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
return (
<div key={`${album.serverId}:${album.id}`} className="album-card card offline-library-card">
<div className="album-card card offline-library-card">
<div className="album-card-cover">
{coverUrl ? (
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
@@ -124,9 +127,14 @@ export default function OfflineLibrary() {
return sortedArtists.map(artistName => (
<div key={artistName} className="offline-artist-group">
<h2 className="offline-artist-group-heading">{artistName}</h2>
<div className="album-grid-wrap">
{groups[artistName].map(renderCard)}
</div>
<VirtualCardGrid
items={groups[artistName]}
itemKey={(a, _i) => `${a.serverId}:${a.id}`}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={groups[artistName].length}
renderItem={renderCard}
/>
</div>
));
};
@@ -172,9 +180,14 @@ export default function OfflineLibrary() {
) : filter === 'artist' ? (
renderArtistGroups()
) : (
<div className="album-grid-wrap">
{filtered.map(renderCard)}
</div>
<VirtualCardGrid
items={filtered}
itemKey={(a, _i) => `${a.serverId}:${a.id}`}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={filtered.length}
renderItem={renderCard}
/>
)}
</div>
);
+12 -5
View File
@@ -27,6 +27,8 @@ import {
import PlaylistsSmartEditor from '../components/playlists/PlaylistsSmartEditor';
import PlaylistsHeader from '../components/playlists/PlaylistsHeader';
import PlaylistCard from '../components/playlists/PlaylistCard';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds);
@@ -34,6 +36,7 @@ function formatDuration(seconds: number): string {
export default function Playlists() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const navigate = useNavigate();
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
@@ -251,10 +254,14 @@ export default function Playlists() {
{playlists.length === 0 ? (
<div className="empty-state">{t('playlists.empty')}</div>
) : (
<div className="album-grid-wrap">
{playlists.map((pl) => (
<VirtualCardGrid
items={playlists}
itemKey={(pl, _i) => pl.id}
rowVariant="playlist"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={playlists.length}
renderItem={pl => (
<PlaylistCard
key={pl.id}
pl={pl}
selectionMode={selectionMode}
selectedIds={selectedIds}
@@ -272,8 +279,8 @@ export default function Playlists() {
filteredSongCountByPlaylist={filteredSongCountByPlaylist}
filteredDurationByPlaylist={filteredDurationByPlaylist}
/>
))}
</div>
)}
/>
)}
+12 -5
View File
@@ -18,6 +18,8 @@ import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/ui/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { useRangeSelection } from '../hooks/useRangeSelection';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
const ALBUM_COUNT = 30;
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
@@ -38,6 +40,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
export default function RandomAlbums() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const auth = useAuthStore();
const musicLibraryFilterVersion = auth.musicLibraryFilterVersion;
const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled;
@@ -178,18 +181,22 @@ export default function RandomAlbums() {
<div className="spinner" />
</div>
) : (
<div className="album-grid-wrap">
{albums.map(a => (
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
renderItem={a => (
<AlbumCard
key={a.id}
album={a}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
)}
/>
)}
</div>
);
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { computeCardGridColumnCount, CARD_GRID_MAX_COLS } from './cardGridLayout';
describe('computeCardGridColumnCount', () => {
it('never exceeds CARD_GRID_MAX_COLS', () => {
expect(computeCardGridColumnCount(20_000)).toBe(CARD_GRID_MAX_COLS);
});
it('returns at least one column', () => {
expect(computeCardGridColumnCount(50)).toBe(1);
});
it('uses six columns on wide desktop widths', () => {
expect(computeCardGridColumnCount(1200)).toBe(6);
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Shared responsive card grids: capped columns, even stretch (`minmax(0, 1fr)`),
* and row-height estimates derived from measured cell width (TanStack virtual rows).
*/
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 {
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));
}
export function computeCellWidthPx(containerWidthPx: number, columnCount: number): number {
const c = Math.max(1, columnCount);
return (containerWidthPx - (c - 1) * CARD_GRID_GAP_PX) / c;
}
export type CardGridRowHeightVariant = 'artist' | 'album' | 'playlist' | 'composer';
const VARIANT: Record<CardGridRowHeightVariant, { extra: number; min: number; max: number }> = {
artist: { extra: 72, min: 200, max: 520 },
/** Cover scales with cell width; ~108px headroom matches prior ~288px row at ~180px tiles. */
album: { extra: 108, min: 260, max: 560 },
playlist: { extra: 108, min: 260, max: 560 },
/** Text-only composer tiles (~78px intrinsic) with some slack for wrapping. */
composer: { extra: 56, min: 88, max: 200 },
};
export function estimateRowHeightPx(cellWidthPx: number, variant: CardGridRowHeightVariant): number {
const { extra, min, max } = VARIANT[variant];
return Math.max(min, Math.min(max, Math.ceil(cellWidthPx + extra)));
}