mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
+18
-101
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user