diff --git a/src/components/VirtualCardGrid.tsx b/src/components/VirtualCardGrid.tsx new file mode 100644 index 00000000..8b6ad7d2 --- /dev/null +++ b/src/components/VirtualCardGrid.tsx @@ -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 = { + 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({ + items, + itemKey, + renderItem, + rowVariant, + disableVirtualization, + layoutSignal, + wrapClassName = 'album-grid-wrap', + wrapStyle, + gridGap = 'var(--space-4)', +}: VirtualCardGridProps): React.JSX.Element { + const wrapRef = useRef(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 ( +
+ {items.map((item, i) => ( + {renderItem(item)} + ))} +
+ ); + } + + return ( +
+
+ {virtualizer.getVirtualItems().map(vRow => { + const start = vRow.index * cols; + const rowItems = items.slice(start, start + cols); + return ( +
+ {rowItems.map((item, i) => ( + {renderItem(item)} + ))} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/artists/ArtistsGridView.tsx b/src/components/artists/ArtistsGridView.tsx index 5baa7a1d..7b279919 100644 --- a/src/components/artists/ArtistsGridView.tsx +++ b/src/components/artists/ArtistsGridView.tsx @@ -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; +}; + +interface TileProps { + artist: SubsonicArtist; + selectionMode: boolean; + selectedIds: Set; + selectedArtists: SubsonicArtist[]; + showArtistImages: boolean; + toggleSelect: (id: string) => void; + navigate: NavigateFunction; + openContextMenu: PlayerState['openContextMenu']; + t: TFunction; +} + +type TilePropsShared = Omit; + +function ArtistGridTile({ artist, ...rest }: TileProps) { + return ( +
{ + 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 && ( +
+ {rest.selectedIds.has(artist.id) && } +
+ )} + +
+
{artist.name}
+ {artist.albumCount != null && ( +
{rest.t('artists.albumCount', { count: artist.albumCount })}
+ )} +
+
+ ); +} + 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; + virtualization?: ArtistsGridVirtualization | null; selectionMode: boolean; selectedIds: Set; 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 ( -
- {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 ( +
{ - 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 && ( -
- {selectedIds.has(artist.id) && } -
- )} - -
-
{artist.name}
- {artist.albumCount != null && ( -
{t('artists.albumCount', { count: artist.albumCount })}
- )} -
+ {virtualizer.getVirtualItems().map(vRow => { + const start = vRow.index * cols; + const rowArtists = visible.slice(start, start + cols); + return ( +
+ {rowArtists.map(artist => ( + + ))} +
+ ); + })}
+
+ ); + } + + return ( +
+ {visible.map(artist => ( + ))}
); diff --git a/src/hooks/useCardGridMetrics.ts b/src/hooks/useCardGridMetrics.ts new file mode 100644 index 00000000..7a7852c7 --- /dev/null +++ b/src/hooks/useCardGridMetrics.ts @@ -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, + 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 }; +} diff --git a/src/hooks/useRemeasureGridVirtualizer.ts b/src/hooks/useRemeasureGridVirtualizer.ts new file mode 100644 index 00000000..28d733cd --- /dev/null +++ b/src/hooks/useRemeasureGridVirtualizer.ts @@ -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( + virtualizer: Virtualizer, + 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, + ]); +} diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 08dd5e8c..1386dd34 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -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 = () => {

{t('albumDetail.moreByArtist', { artist: info.artist })}

-
- {relatedAlbums.map(a => )} -
+ `${a.id}-${i}`} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={relatedAlbums.length} + renderItem={a => } + />
)}
diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index f2969061..84752eea 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -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(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 ? ( -
- {visibleAlbums.map(a => ( - - ))} -
- ) : ( -
-
- {albumGridVirtualizer.getVirtualItems().map(vRow => { - const start = vRow.index * albumGridCols; - const rowAlbums = visibleAlbums.slice(start, start + albumGridCols); - return ( -
- {rowAlbums.map(a => ( - - ))} -
- ); - })} -
-
- ) + a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={visibleAlbums.length} + renderItem={a => ( + + )} + /> )} {!genreFiltered && (
diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 0d8c0a65..cc7c483c 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -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() { {albums.length > 0 ? ( groupedAlbums.length === 1 ? ( -
- {albums.map((a, i) => )} -
+ `${a.id}-${i}`} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={albums.length} + wrapClassName="album-grid-wrap album-grid-wrap--artist" + renderItem={a => } + /> ) : groupedAlbums.map(([label, group]) => (

{label}

{group.length}
-
- {group.map((a, i) => )} -
+ `${a.id}-${i}`} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={group.length} + wrapClassName="album-grid-wrap album-grid-wrap--artist" + renderItem={a => } + />
)) ) : ( @@ -396,9 +411,16 @@ export default function ArtistDetail() { ))}
) : ( -
- {featuredAlbums.map((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 => } + /> )} ); diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index 88f1017a..5a3d874f 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -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(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' && ( (); const navigate = useNavigate(); const [artist, setArtist] = useState(null); @@ -266,9 +269,14 @@ export default function ComposerDetail() { {t('composerDetail.noWorks')}
) : ( -
- {albums.map((a, i) => )} -
+ `${a.id}-${i}`} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={albums.length} + renderItem={a => } + /> )} ); diff --git a/src/pages/Composers.tsx b/src/pages/Composers.tsx index 353d342b..3a2192dd 100644 --- a/src/pages/Composers.tsx +++ b/src/pages/Composers.tsx @@ -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 &&
} {!loading && viewMode === 'grid' && ( -
- {visible.map(artist => ( + a.id} + rowVariant="composer" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={visible.length} + wrapClassName="composer-grid-wrap" + gridGap="var(--space-2)" + renderItem={artist => (
navigate(`/composer/${artist.id}`)} onContextMenu={(e) => { @@ -294,8 +301,8 @@ export default function Composers() {
)}
- ))} -
+ )} + /> )} {!loading && viewMode === 'list' && ( diff --git a/src/pages/GenreDetail.tsx b/src/pages/GenreDetail.tsx index 3e2994c2..546766ac 100644 --- a/src/pages/GenreDetail.tsx +++ b/src/pages/GenreDetail.tsx @@ -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([]); const [loading, setLoading] = useState(true); @@ -72,9 +75,14 @@ export default function GenreDetail() { {!loading && albums.length === 0 &&

{t('genres.albumsEmpty')}

} {albums.length > 0 && ( -
- {albums.map(album => )} -
+ a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={albums.length} + renderItem={album => } + /> )} {hasMore && !loading && ( diff --git a/src/pages/InternetRadio.tsx b/src/pages/InternetRadio.tsx index 9f3547b9..6c2e9bb6 100644 --- a/src/pages/InternetRadio.tsx +++ b/src/pages/InternetRadio.tsx @@ -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 ? (
{t('radio.noFavorites')}
) : ( -
- {displayedStations.map(s => ( + s.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={displayedStations.length} + renderItem={s => ( handleReorder(srcId, s.id, side)} onCardMouseLeave={() => { if (deleteConfirmId === s.id) setDeleteConfirmId(null); }} /> - ))} -
+ )} + /> )} )} diff --git a/src/pages/LabelAlbums.tsx b/src/pages/LabelAlbums.tsx index 6916b6b5..4d5a24ed 100644 --- a/src/pages/LabelAlbums.tsx +++ b/src/pages/LabelAlbums.tsx @@ -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([]); @@ -53,11 +56,14 @@ export default function LabelAlbums() { ) : albums.length === 0 ? (
{t('common.noAlbums')}
) : ( -
- {albums.map(a => ( - - ))} -
+ a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={albums.length} + renderItem={a => } + /> )} ); diff --git a/src/pages/LosslessAlbums.tsx b/src/pages/LosslessAlbums.tsx index ccc507cb..43329a5d 100644 --- a/src/pages/LosslessAlbums.tsx +++ b/src/pages/LosslessAlbums.tsx @@ -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() { ) : ( <> -
- {albums.map(a => ( + a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={albums.length} + renderItem={a => ( - ))} -
+ )} + />
{loading && hasMore &&
}
diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx index c156d8eb..05fa083d 100644 --- a/src/pages/NewReleases.tsx +++ b/src/pages/NewReleases.tsx @@ -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 { 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() {
) : ( <> -
- {albums.map(a => ( + a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={albums.length} + renderItem={a => ( - ))} -
+ )} + /> {!filtered && (
{loading && hasMore &&
} diff --git a/src/pages/OfflineLibrary.tsx b/src/pages/OfflineLibrary.tsx index d2e47ecc..5217b578 100644 --- a/src/pages/OfflineLibrary.tsx +++ b/src/pages/OfflineLibrary.tsx @@ -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 ( -
+
{coverUrl ? ( @@ -124,9 +127,14 @@ export default function OfflineLibrary() { return sortedArtists.map(artistName => (

{artistName}

-
- {groups[artistName].map(renderCard)} -
+ `${a.serverId}:${a.id}`} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={groups[artistName].length} + renderItem={renderCard} + />
)); }; @@ -172,9 +180,14 @@ export default function OfflineLibrary() { ) : filter === 'artist' ? ( renderArtistGroups() ) : ( -
- {filtered.map(renderCard)} -
+ `${a.serverId}:${a.id}`} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={filtered.length} + renderItem={renderCard} + /> )}
); diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index c0abfc39..4ac547e6 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -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 ? (
{t('playlists.empty')}
) : ( -
- {playlists.map((pl) => ( + pl.id} + rowVariant="playlist" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={playlists.length} + renderItem={pl => ( - ))} -
+ )} + /> )} diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index 65a6963f..35aa1286 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -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 { 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() {
) : ( -
- {albums.map(a => ( + a.id} + rowVariant="album" + disableVirtualization={perfFlags.disableMainstageVirtualLists} + layoutSignal={albums.length} + renderItem={a => ( - ))} -
+ )} + /> )}
); diff --git a/src/utils/cardGridLayout.test.ts b/src/utils/cardGridLayout.test.ts new file mode 100644 index 00000000..75faff41 --- /dev/null +++ b/src/utils/cardGridLayout.test.ts @@ -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); + }); +}); diff --git a/src/utils/cardGridLayout.ts b/src/utils/cardGridLayout.ts new file mode 100644 index 00000000..9b10e4d0 --- /dev/null +++ b/src/utils/cardGridLayout.ts @@ -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 = { + 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))); +}