mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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
@@ -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' }}>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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' && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }} />}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user