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
+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>
)}
/>
)}
</>
)}