mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25: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:
@@ -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 React from 'react';
|
||||||
import type { NavigateFunction } from 'react-router-dom';
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
|
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||||
import type { PlayerState } from '../../store/playerStoreTypes';
|
import type { PlayerState } from '../../store/playerStoreTypes';
|
||||||
import { ArtistCardAvatar } from './ArtistAvatars';
|
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 {
|
interface Props {
|
||||||
visible: SubsonicArtist[];
|
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;
|
selectionMode: boolean;
|
||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
selectedArtists: SubsonicArtist[];
|
selectedArtists: SubsonicArtist[];
|
||||||
@@ -19,13 +84,14 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card grid for the artists page. Click navigates to the artist detail
|
* Card grid for the artists page. Optional row virtualization (TanStack) for
|
||||||
* (or toggles selection while in select-mode); right-click opens the
|
* large libraries; column count and `measureRef` always come from the parent.
|
||||||
* standard context menu, escalating to `multi-artist` when there is an
|
|
||||||
* active multi-selection.
|
|
||||||
*/
|
*/
|
||||||
export function ArtistsGridView({
|
export function ArtistsGridView({
|
||||||
visible,
|
visible,
|
||||||
|
gridCols,
|
||||||
|
measureRef,
|
||||||
|
virtualization,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
selectedArtists,
|
selectedArtists,
|
||||||
@@ -35,46 +101,77 @@ export function ArtistsGridView({
|
|||||||
openContextMenu,
|
openContextMenu,
|
||||||
t,
|
t,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
const tilePropsShared: TilePropsShared = {
|
||||||
<div className="album-grid-wrap">
|
selectionMode,
|
||||||
{visible.map(artist => (
|
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
|
<div
|
||||||
key={artist.id}
|
style={{
|
||||||
className={`artist-card${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}${selectionMode ? ' artist-card--selectable' : ''}`}
|
height: rowCount === 0 ? 0 : virtualizer.getTotalSize(),
|
||||||
onClick={() => {
|
width: '100%',
|
||||||
if (selectionMode) {
|
position: 'relative',
|
||||||
toggleSelect(artist.id);
|
|
||||||
} else {
|
|
||||||
navigate(`/artist/${artist.id}`);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
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 && (
|
{virtualizer.getVirtualItems().map(vRow => {
|
||||||
<div className={`artist-card-select-check${selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
|
const start = vRow.index * cols;
|
||||||
{selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
|
const rowArtists = visible.slice(start, start + cols);
|
||||||
</div>
|
return (
|
||||||
)}
|
<div
|
||||||
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
|
key={vRow.key}
|
||||||
<div style={{ textAlign: 'center' }}>
|
style={{
|
||||||
<div className="artist-card-name">{artist.name}</div>
|
position: 'absolute',
|
||||||
{artist.albumCount != null && (
|
top: 0,
|
||||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
left: 0,
|
||||||
)}
|
width: '100%',
|
||||||
</div>
|
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>
|
||||||
|
</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>
|
</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 { useSelectionStore } from '../store/selectionStore';
|
||||||
import { sanitizeFilename } from '../utils/componentHelpers/albumDetailHelpers';
|
import { sanitizeFilename } from '../utils/componentHelpers/albumDetailHelpers';
|
||||||
import { deriveAlbumHeaderArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
import { deriveAlbumHeaderArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
export default function AlbumDetail() {
|
export default function AlbumDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -359,9 +362,14 @@ const handleShuffleAll = () => {
|
|||||||
<div className="album-related">
|
<div className="album-related">
|
||||||
<div className="album-related-divider" />
|
<div className="album-related-divider" />
|
||||||
<h2 className="section-title album-related-title">{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
|
<h2 className="section-title album-related-title">{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
|
||||||
<div className="album-grid-wrap">
|
<VirtualCardGrid
|
||||||
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
items={relatedAlbums}
|
||||||
</div>
|
itemKey={(a, i) => `${a.id}-${i}`}
|
||||||
|
rowVariant="album"
|
||||||
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
|
layoutSignal={relatedAlbums.length}
|
||||||
|
renderItem={a => <AlbumCard album={a} />}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+18
-101
@@ -4,7 +4,7 @@ import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
|||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import { dedupeById } from '../utils/dedupeById';
|
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 AlbumCard from '../components/AlbumCard';
|
||||||
import GenreFilterBar from '../components/GenreFilterBar';
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
import YearFilterButton from '../components/YearFilterButton';
|
import YearFilterButton from '../components/YearFilterButton';
|
||||||
@@ -20,16 +20,9 @@ import { join } from '@tauri-apps/api/path';
|
|||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||||
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react';
|
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 { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
import { useRangeSelection } from '../hooks/useRangeSelection';
|
import { useRangeSelection } from '../hooks/useRangeSelection';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
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;
|
|
||||||
|
|
||||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||||
type CompFilter = 'all' | 'only' | 'hide';
|
type CompFilter = 'all' | 'only' | 'hide';
|
||||||
@@ -94,38 +87,6 @@ export default function Albums() {
|
|||||||
resetSelection();
|
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 selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id));
|
||||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||||
const enqueue = usePlayerStore(state => state.enqueue);
|
const enqueue = usePlayerStore(state => state.enqueue);
|
||||||
@@ -352,66 +313,22 @@ export default function Albums() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!perfFlags.disableMainstageGridCards && (
|
{!perfFlags.disableMainstageGridCards && (
|
||||||
perfFlags.disableMainstageVirtualLists ? (
|
<VirtualCardGrid
|
||||||
<div className="album-grid-wrap">
|
items={visibleAlbums}
|
||||||
{visibleAlbums.map(a => (
|
itemKey={(a, _i) => a.id}
|
||||||
<AlbumCard
|
rowVariant="album"
|
||||||
key={a.id}
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
album={a}
|
layoutSignal={visibleAlbums.length}
|
||||||
selectionMode={selectionMode}
|
renderItem={a => (
|
||||||
selected={selectedIds.has(a.id)}
|
<AlbumCard
|
||||||
onToggleSelect={toggleSelect}
|
album={a}
|
||||||
selectedAlbums={selectedAlbums}
|
selectionMode={selectionMode}
|
||||||
/>
|
selected={selectedIds.has(a.id)}
|
||||||
))}
|
onToggleSelect={toggleSelect}
|
||||||
</div>
|
selectedAlbums={selectedAlbums}
|
||||||
) : (
|
/>
|
||||||
<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>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
{!genreFiltered && (
|
{!genreFiltered && (
|
||||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
<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 ArtistDetailHero from '../components/artistDetail/ArtistDetailHero';
|
||||||
import ArtistDetailTopTracks from '../components/artistDetail/ArtistDetailTopTracks';
|
import ArtistDetailTopTracks from '../components/artistDetail/ArtistDetailTopTracks';
|
||||||
import ArtistDetailSimilarArtists from '../components/artistDetail/ArtistDetailSimilarArtists';
|
import ArtistDetailSimilarArtists from '../components/artistDetail/ArtistDetailSimilarArtists';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
|
|
||||||
export default function ArtistDetail() {
|
export default function ArtistDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const {
|
||||||
@@ -364,18 +367,30 @@ export default function ArtistDetail() {
|
|||||||
</h2>
|
</h2>
|
||||||
{albums.length > 0 ? (
|
{albums.length > 0 ? (
|
||||||
groupedAlbums.length === 1 ? (
|
groupedAlbums.length === 1 ? (
|
||||||
<div className="album-grid-wrap album-grid-wrap--artist">
|
<VirtualCardGrid
|
||||||
{albums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
|
items={albums}
|
||||||
</div>
|
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]) => (
|
) : groupedAlbums.map(([label, group]) => (
|
||||||
<div key={label} className="artist-release-group">
|
<div key={label} className="artist-release-group">
|
||||||
<div className="artist-release-group__header">
|
<div className="artist-release-group__header">
|
||||||
<h3>{label}</h3>
|
<h3>{label}</h3>
|
||||||
<span className="artist-release-group__count">{group.length}</span>
|
<span className="artist-release-group__count">{group.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="album-grid-wrap album-grid-wrap--artist">
|
<VirtualCardGrid
|
||||||
{group.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
|
items={group}
|
||||||
</div>
|
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>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -396,9 +411,16 @@ export default function ArtistDetail() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="album-grid-wrap album-grid-wrap--artist" style={{ animation: 'fadeIn 0.3s ease' }}>
|
<VirtualCardGrid
|
||||||
{featuredAlbums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
|
items={featuredAlbums}
|
||||||
</div>
|
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>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
+43
-1
@@ -1,6 +1,6 @@
|
|||||||
import { getArtists } from '../api/subsonicArtists';
|
import { getArtists } from '../api/subsonicArtists';
|
||||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react';
|
import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react';
|
||||||
import StarFilterButton from '../components/StarFilterButton';
|
import StarFilterButton from '../components/StarFilterButton';
|
||||||
@@ -10,6 +10,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||||
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
||||||
|
import { useCardGridMetrics } from '../hooks/useCardGridMetrics';
|
||||||
|
import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer';
|
||||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
import {
|
import {
|
||||||
ALL_SENTINEL,
|
ALL_SENTINEL,
|
||||||
@@ -81,6 +83,39 @@ export default function Artists() {
|
|||||||
} = useArtistsFiltering({ artists, filter, letterFilter, starredOnly, visibleCount, viewMode });
|
} = useArtistsFiltering({ artists, filter, letterFilter, starredOnly, visibleCount, viewMode });
|
||||||
|
|
||||||
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
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. */
|
/** Mixed row heights; smallest typical step ≈ artist row — one viewport of extra indices each side. */
|
||||||
const artistListOverscan = Math.max(
|
const artistListOverscan = Math.max(
|
||||||
12,
|
12,
|
||||||
@@ -188,6 +223,13 @@ export default function Artists() {
|
|||||||
{!loading && viewMode === 'grid' && (
|
{!loading && viewMode === 'grid' && (
|
||||||
<ArtistsGridView
|
<ArtistsGridView
|
||||||
visible={visible}
|
visible={visible}
|
||||||
|
gridCols={artistGridCols}
|
||||||
|
measureRef={artistGridMeasureRef}
|
||||||
|
virtualization={
|
||||||
|
perfFlags.disableMainstageVirtualLists
|
||||||
|
? null
|
||||||
|
: { virtualizer: artistGridVirtualizer }
|
||||||
|
}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
selectedArtists={selectedArtists}
|
selectedArtists={selectedArtists}
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
|
import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
export default function ComposerDetail() {
|
export default function ComposerDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||||
@@ -266,9 +269,14 @@ export default function ComposerDetail() {
|
|||||||
{t('composerDetail.noWorks')}
|
{t('composerDetail.noWorks')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="album-grid-wrap">
|
<VirtualCardGrid
|
||||||
{albums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
|
items={albums}
|
||||||
</div>
|
itemKey={(a, i) => `${a.id}-${i}`}
|
||||||
|
rowVariant="album"
|
||||||
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
|
layoutSignal={albums.length}
|
||||||
|
renderItem={a => <AlbumCard album={a} />}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-5
@@ -11,6 +11,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
|||||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||||
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
||||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
const ALL_SENTINEL = 'ALL';
|
const ALL_SENTINEL = 'ALL';
|
||||||
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
|
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 && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
|
||||||
|
|
||||||
{!loading && viewMode === 'grid' && (
|
{!loading && viewMode === 'grid' && (
|
||||||
<div className="composer-grid-wrap">
|
<VirtualCardGrid
|
||||||
{visible.map(artist => (
|
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
|
<div
|
||||||
key={artist.id}
|
|
||||||
className="composer-card"
|
className="composer-card"
|
||||||
onClick={() => navigate(`/composer/${artist.id}`)}
|
onClick={() => navigate(`/composer/${artist.id}`)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
@@ -294,8 +301,8 @@ export default function Composers() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && viewMode === 'list' && (
|
{!loading && viewMode === 'list' && (
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { ArrowLeft, Disc3 } from 'lucide-react';
|
import { ArrowLeft, Disc3 } from 'lucide-react';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ export default function GenreDetail() {
|
|||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
const genre = decodeURIComponent(name ?? '');
|
const genre = decodeURIComponent(name ?? '');
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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>}
|
{!loading && albums.length === 0 && <p className="loading-text">{t('genres.albumsEmpty')}</p>}
|
||||||
|
|
||||||
{albums.length > 0 && (
|
{albums.length > 0 && (
|
||||||
<div className="album-grid-wrap">
|
<VirtualCardGrid
|
||||||
{albums.map(album => <AlbumCard key={album.id} album={album} />)}
|
items={albums}
|
||||||
</div>
|
itemKey={(a, _i) => a.id}
|
||||||
|
rowVariant="album"
|
||||||
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
|
layoutSignal={albums.length}
|
||||||
|
renderItem={album => <AlbumCard album={album} />}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasMore && !loading && (
|
{hasMore && !loading && (
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ import AlphabetFilterBar from '../components/internetRadio/AlphabetFilterBar';
|
|||||||
import RadioCard from '../components/internetRadio/RadioCard';
|
import RadioCard from '../components/internetRadio/RadioCard';
|
||||||
import RadioEditModal from '../components/internetRadio/RadioEditModal';
|
import RadioEditModal from '../components/internetRadio/RadioEditModal';
|
||||||
import RadioDirectoryModal from '../components/internetRadio/RadioDirectoryModal';
|
import RadioDirectoryModal from '../components/internetRadio/RadioDirectoryModal';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
export default function InternetRadio() {
|
export default function InternetRadio() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
const playRadio = usePlayerStore(s => s.playRadio);
|
const playRadio = usePlayerStore(s => s.playRadio);
|
||||||
const stop = usePlayerStore(s => s.stop);
|
const stop = usePlayerStore(s => s.stop);
|
||||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||||
@@ -247,10 +250,14 @@ export default function InternetRadio() {
|
|||||||
{displayedStations.length === 0 ? (
|
{displayedStations.length === 0 ? (
|
||||||
<div className="empty-state">{t('radio.noFavorites')}</div>
|
<div className="empty-state">{t('radio.noFavorites')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="album-grid-wrap">
|
<VirtualCardGrid
|
||||||
{displayedStations.map(s => (
|
items={displayedStations}
|
||||||
|
itemKey={(s, _i) => s.id}
|
||||||
|
rowVariant="album"
|
||||||
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
|
layoutSignal={displayedStations.length}
|
||||||
|
renderItem={s => (
|
||||||
<RadioCard
|
<RadioCard
|
||||||
key={s.id}
|
|
||||||
s={s}
|
s={s}
|
||||||
isActive={currentRadio?.id === s.id}
|
isActive={currentRadio?.id === s.id}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
@@ -267,8 +274,8 @@ export default function InternetRadio() {
|
|||||||
onDropOnto={(srcId, side) => handleReorder(srcId, s.id, side)}
|
onDropOnto={(srcId, side) => handleReorder(srcId, s.id, side)}
|
||||||
onCardMouseLeave={() => { if (deleteConfirmId === s.id) setDeleteConfirmId(null); }}
|
onCardMouseLeave={() => { if (deleteConfirmId === s.id) setDeleteConfirmId(null); }}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import { ChevronLeft } from 'lucide-react';
|
|||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
export default function LabelAlbums() {
|
export default function LabelAlbums() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
@@ -53,11 +56,14 @@ export default function LabelAlbums() {
|
|||||||
) : albums.length === 0 ? (
|
) : albums.length === 0 ? (
|
||||||
<div className="empty-state">{t('common.noAlbums')}</div>
|
<div className="empty-state">{t('common.noAlbums')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="album-grid-wrap">
|
<VirtualCardGrid
|
||||||
{albums.map(a => (
|
items={albums}
|
||||||
<AlbumCard key={a.id} album={a} />
|
itemKey={(a, _i) => a.id}
|
||||||
))}
|
rowVariant="album"
|
||||||
</div>
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
|
layoutSignal={albums.length}
|
||||||
|
renderItem={a => <AlbumCard album={a} />}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { showToast } from '../utils/ui/toast';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { join } from '@tauri-apps/api/path';
|
import { join } from '@tauri-apps/api/path';
|
||||||
import { CheckSquare2, Download, HardDriveDownload, ListPlus } from 'lucide-react';
|
import { CheckSquare2, Download, HardDriveDownload, ListPlus } from 'lucide-react';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
/** Per-loadMore budget — tuned for snappy initial paint over completeness.
|
/** Per-loadMore budget — tuned for snappy initial paint over completeness.
|
||||||
* 100 songs ≈ 500 KB response (Navidrome's /api/song carries lyrics/tags/
|
* 100 songs ≈ 500 KB response (Navidrome's /api/song carries lyrics/tags/
|
||||||
@@ -261,18 +262,22 @@ export default function LosslessAlbums() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="album-grid-wrap">
|
<VirtualCardGrid
|
||||||
{albums.map(a => (
|
items={albums}
|
||||||
|
itemKey={(a, _i) => a.id}
|
||||||
|
rowVariant="album"
|
||||||
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
|
layoutSignal={albums.length}
|
||||||
|
renderItem={a => (
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={a.id}
|
|
||||||
album={a}
|
album={a}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
selected={selectedIds.has(a.id)}
|
selected={selectedIds.has(a.id)}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
selectedAlbums={selectedAlbums}
|
selectedAlbums={selectedAlbums}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
/>
|
||||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { join } from '@tauri-apps/api/path';
|
|||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||||
import { useRangeSelection } from '../hooks/useRangeSelection';
|
import { useRangeSelection } from '../hooks/useRangeSelection';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
const PAGE_SIZE = 30;
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
|||||||
|
|
||||||
export default function NewReleases() {
|
export default function NewReleases() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||||
@@ -177,18 +180,22 @@ export default function NewReleases() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="album-grid-wrap">
|
<VirtualCardGrid
|
||||||
{albums.map(a => (
|
items={albums}
|
||||||
|
itemKey={(a, _i) => a.id}
|
||||||
|
rowVariant="album"
|
||||||
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
|
layoutSignal={albums.length}
|
||||||
|
renderItem={a => (
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={a.id}
|
|
||||||
album={a}
|
album={a}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
selected={selectedIds.has(a.id)}
|
selected={selectedIds.has(a.id)}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
selectedAlbums={selectedAlbums}
|
selectedAlbums={selectedAlbums}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
/>
|
||||||
{!filtered && (
|
{!filtered && (
|
||||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||||
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
{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 { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import CachedImage from '../components/CachedImage';
|
import CachedImage from '../components/CachedImage';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
type FilterType = 'all' | 'album' | 'playlist' | 'artist';
|
type FilterType = 'all' | 'album' | 'playlist' | 'artist';
|
||||||
|
|
||||||
export default function OfflineLibrary() {
|
export default function OfflineLibrary() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||||
const offlineTracks = useOfflineStore(s => s.tracks);
|
const offlineTracks = useOfflineStore(s => s.tracks);
|
||||||
@@ -62,7 +65,7 @@ export default function OfflineLibrary() {
|
|||||||
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||||
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
|
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
|
||||||
return (
|
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">
|
<div className="album-card-cover">
|
||||||
{coverUrl ? (
|
{coverUrl ? (
|
||||||
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
|
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
|
||||||
@@ -124,9 +127,14 @@ export default function OfflineLibrary() {
|
|||||||
return sortedArtists.map(artistName => (
|
return sortedArtists.map(artistName => (
|
||||||
<div key={artistName} className="offline-artist-group">
|
<div key={artistName} className="offline-artist-group">
|
||||||
<h2 className="offline-artist-group-heading">{artistName}</h2>
|
<h2 className="offline-artist-group-heading">{artistName}</h2>
|
||||||
<div className="album-grid-wrap">
|
<VirtualCardGrid
|
||||||
{groups[artistName].map(renderCard)}
|
items={groups[artistName]}
|
||||||
</div>
|
itemKey={(a, _i) => `${a.serverId}:${a.id}`}
|
||||||
|
rowVariant="album"
|
||||||
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
|
layoutSignal={groups[artistName].length}
|
||||||
|
renderItem={renderCard}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
@@ -172,9 +180,14 @@ export default function OfflineLibrary() {
|
|||||||
) : filter === 'artist' ? (
|
) : filter === 'artist' ? (
|
||||||
renderArtistGroups()
|
renderArtistGroups()
|
||||||
) : (
|
) : (
|
||||||
<div className="album-grid-wrap">
|
<VirtualCardGrid
|
||||||
{filtered.map(renderCard)}
|
items={filtered}
|
||||||
</div>
|
itemKey={(a, _i) => `${a.serverId}:${a.id}`}
|
||||||
|
rowVariant="album"
|
||||||
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
|
layoutSignal={filtered.length}
|
||||||
|
renderItem={renderCard}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-5
@@ -27,6 +27,8 @@ import {
|
|||||||
import PlaylistsSmartEditor from '../components/playlists/PlaylistsSmartEditor';
|
import PlaylistsSmartEditor from '../components/playlists/PlaylistsSmartEditor';
|
||||||
import PlaylistsHeader from '../components/playlists/PlaylistsHeader';
|
import PlaylistsHeader from '../components/playlists/PlaylistsHeader';
|
||||||
import PlaylistCard from '../components/playlists/PlaylistCard';
|
import PlaylistCard from '../components/playlists/PlaylistCard';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
return formatHumanHoursMinutes(seconds);
|
return formatHumanHoursMinutes(seconds);
|
||||||
@@ -34,6 +36,7 @@ function formatDuration(seconds: number): string {
|
|||||||
|
|
||||||
export default function Playlists() {
|
export default function Playlists() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const playTrack = usePlayerStore(s => s.playTrack);
|
const playTrack = usePlayerStore(s => s.playTrack);
|
||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
@@ -251,10 +254,14 @@ export default function Playlists() {
|
|||||||
{playlists.length === 0 ? (
|
{playlists.length === 0 ? (
|
||||||
<div className="empty-state">{t('playlists.empty')}</div>
|
<div className="empty-state">{t('playlists.empty')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="album-grid-wrap">
|
<VirtualCardGrid
|
||||||
{playlists.map((pl) => (
|
items={playlists}
|
||||||
|
itemKey={(pl, _i) => pl.id}
|
||||||
|
rowVariant="playlist"
|
||||||
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
|
layoutSignal={playlists.length}
|
||||||
|
renderItem={pl => (
|
||||||
<PlaylistCard
|
<PlaylistCard
|
||||||
key={pl.id}
|
|
||||||
pl={pl}
|
pl={pl}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
@@ -272,8 +279,8 @@ export default function Playlists() {
|
|||||||
filteredSongCountByPlaylist={filteredSongCountByPlaylist}
|
filteredSongCountByPlaylist={filteredSongCountByPlaylist}
|
||||||
filteredDurationByPlaylist={filteredDurationByPlaylist}
|
filteredDurationByPlaylist={filteredDurationByPlaylist}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { join } from '@tauri-apps/api/path';
|
|||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||||
import { useRangeSelection } from '../hooks/useRangeSelection';
|
import { useRangeSelection } from '../hooks/useRangeSelection';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
const ALBUM_COUNT = 30;
|
const ALBUM_COUNT = 30;
|
||||||
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
|
/** 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() {
|
export default function RandomAlbums() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const musicLibraryFilterVersion = auth.musicLibraryFilterVersion;
|
const musicLibraryFilterVersion = auth.musicLibraryFilterVersion;
|
||||||
const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled;
|
const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled;
|
||||||
@@ -178,18 +181,22 @@ export default function RandomAlbums() {
|
|||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="album-grid-wrap">
|
<VirtualCardGrid
|
||||||
{albums.map(a => (
|
items={albums}
|
||||||
|
itemKey={(a, _i) => a.id}
|
||||||
|
rowVariant="album"
|
||||||
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||||
|
layoutSignal={albums.length}
|
||||||
|
renderItem={a => (
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={a.id}
|
|
||||||
album={a}
|
album={a}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
selected={selectedIds.has(a.id)}
|
selected={selectedIds.has(a.id)}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
selectedAlbums={selectedAlbums}
|
selectedAlbums={selectedAlbums}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
</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