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
+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>
);