mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
Merge pull request #711 from Psychotoxical/experiment/card-grid-max-six-virtual
feat(ui): virtualized library card grids and configurable 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { Clock, Maximize2, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
|
import { Clock, LayoutGrid, Maximize2, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import {
|
||||||
|
LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||||
|
LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||||
|
} from '../../store/authStoreDefaults';
|
||||||
import type { SeekbarStyle } from '../../store/authStoreTypes';
|
import type { SeekbarStyle } from '../../store/authStoreTypes';
|
||||||
import { useFontStore, FontId } from '../../store/fontStore';
|
import { useFontStore, FontId } from '../../store/fontStore';
|
||||||
import { useThemeStore } from '../../store/themeStore';
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
@@ -108,6 +112,46 @@ export function AppearanceTab() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsSubSection>
|
</SettingsSubSection>
|
||||||
|
|
||||||
|
<SettingsSubSection
|
||||||
|
title={t('settings.libraryGridMaxColumnsTitle')}
|
||||||
|
icon={<LayoutGrid size={16} />}
|
||||||
|
>
|
||||||
|
<div className="settings-card">
|
||||||
|
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
{t('settings.libraryGridMaxColumnsPerfHint')}
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="settings-label" htmlFor="library-grid-max-cols">
|
||||||
|
{t('settings.libraryGridMaxColumnsRangeLabel', {
|
||||||
|
min: LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||||
|
max: LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||||
|
})}
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: 8 }}>
|
||||||
|
<input
|
||||||
|
id="library-grid-max-cols"
|
||||||
|
type="range"
|
||||||
|
min={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||||
|
max={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||||
|
step={1}
|
||||||
|
value={auth.libraryGridMaxColumns}
|
||||||
|
onChange={e => auth.setLibraryGridMaxColumns(Number(e.target.value))}
|
||||||
|
style={{ flex: 1, maxWidth: 360 }}
|
||||||
|
aria-valuemin={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||||
|
aria-valuemax={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||||
|
aria-valuenow={auth.libraryGridMaxColumns}
|
||||||
|
/>
|
||||||
|
<span style={{ minWidth: 28, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{auth.libraryGridMaxColumns}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.75rem', lineHeight: 1.45 }}>
|
||||||
|
{t('settings.libraryGridMaxColumnsDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SettingsSubSection>
|
||||||
|
|
||||||
<SettingsSubSection
|
<SettingsSubSection
|
||||||
title={t('settings.visualOptionsTitle')}
|
title={t('settings.visualOptionsTitle')}
|
||||||
icon={<Palette size={16} />}
|
icon={<Palette size={16} />}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
|||||||
{ tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' },
|
{ tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' },
|
||||||
{ tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'home page customize sections' },
|
{ tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'home page customize sections' },
|
||||||
{ tab: 'personalisation',titleKey: 'settings.queueToolbarTitle', keywords: 'queue toolbar buttons reorder customize shuffle save load' },
|
{ tab: 'personalisation',titleKey: 'settings.queueToolbarTitle', keywords: 'queue toolbar buttons reorder customize shuffle save load' },
|
||||||
|
{ tab: 'appearance', titleKey: 'settings.libraryGridMaxColumnsTitle', keywords: 'grid columns album artist playlist cards layout appearance performance scroll paint' },
|
||||||
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
|
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
|
||||||
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
|
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
|
||||||
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
|
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useLayoutEffect, useState, type RefObject } from 'react';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { clampLibraryGridMaxColumns } from '../store/authStoreHelpers';
|
||||||
|
import {
|
||||||
|
type CardGridRowHeightVariant,
|
||||||
|
computeCardGridColumnCount,
|
||||||
|
computeCellWidthPx,
|
||||||
|
estimateRowHeightPx,
|
||||||
|
} from '../utils/cardGridLayout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResizeObserver-driven column count (capped by Settings → Library) 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 maxCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns));
|
||||||
|
const [gridCols, setGridCols] = useState(4);
|
||||||
|
const [rowHeightEst, setRowHeightEst] = useState(() =>
|
||||||
|
estimateRowHeightPx(
|
||||||
|
computeCellWidthPx(960, clampLibraryGridMaxColumns(useAuthStore.getState().libraryGridMaxColumns)),
|
||||||
|
variant,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!observerEnabled) return;
|
||||||
|
const el = measureRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onResize = () => {
|
||||||
|
const w = el.clientWidth;
|
||||||
|
const cols = computeCardGridColumnCount(w, maxCols);
|
||||||
|
setGridCols(cols);
|
||||||
|
setRowHeightEst(estimateRowHeightPx(computeCellWidthPx(w, cols), variant));
|
||||||
|
};
|
||||||
|
onResize();
|
||||||
|
const ro = new ResizeObserver(onResize);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [observerEnabled, variant, layoutSignal, maxCols]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -228,6 +228,10 @@ export const settings = {
|
|||||||
aboutContributorsLabel: 'Mitwirkende',
|
aboutContributorsLabel: 'Mitwirkende',
|
||||||
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
|
showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen",
|
||||||
showChangelogOnUpdateDesc: 'Blendet nach einem Update einen dezenten Changelog-Banner über Now Playing ein. Klick öffnet die Release Notes, X blendet ihn aus.',
|
showChangelogOnUpdateDesc: 'Blendet nach einem Update einen dezenten Changelog-Banner über Now Playing ein. Klick öffnet die Release Notes, X blendet ihn aus.',
|
||||||
|
libraryGridMaxColumnsTitle: 'Kartenraster in der Bibliothek',
|
||||||
|
libraryGridMaxColumnsPerfHint: 'Mehr Spalten bedeuten mehr Kacheln pro Zeile und oft mehr Layout- und Zeichenaufwand — bei sehr großen Bibliotheken oder langsamerer Hardware spürbarer.',
|
||||||
|
libraryGridMaxColumnsRangeLabel: 'Maximale Spalten ({{min}}–{{max}})',
|
||||||
|
libraryGridMaxColumnsDesc: 'Gilt für Album-, Künstler-, Wiedergabelisten-, Radio-, Offline- und andere Kartenansichten. Weniger Spalten = größere Kacheln und meist weniger CPU-Last.',
|
||||||
randomMixTitle: 'Zufallsmix-Blacklist',
|
randomMixTitle: 'Zufallsmix-Blacklist',
|
||||||
luckyMixMenuTitle: 'Glücks-Mix im Menü anzeigen',
|
luckyMixMenuTitle: 'Glücks-Mix im Menü anzeigen',
|
||||||
luckyMixMenuDesc: 'Aktiviert Glücks-Mix im "Mix erstellen"-Hub und als separaten Menüeintrag bei getrennter Navigation. Sichtbar nur bei aktiviertem AudioMuse auf dem aktiven Server.',
|
luckyMixMenuDesc: 'Aktiviert Glücks-Mix im "Mix erstellen"-Hub und als separaten Menüeintrag bei getrennter Navigation. Sichtbar nur bei aktiviertem AudioMuse auf dem aktiven Server.',
|
||||||
|
|||||||
@@ -231,6 +231,10 @@ export const settings = {
|
|||||||
aboutContributorsLabel: 'Contributors',
|
aboutContributorsLabel: 'Contributors',
|
||||||
showChangelogOnUpdate: "Show 'What's New' on update",
|
showChangelogOnUpdate: "Show 'What's New' on update",
|
||||||
showChangelogOnUpdateDesc: "Show a discreet changelog banner above Now Playing after an update. Click opens the release notes; X dismisses it.",
|
showChangelogOnUpdateDesc: "Show a discreet changelog banner above Now Playing after an update. Click opens the release notes; X dismisses it.",
|
||||||
|
libraryGridMaxColumnsTitle: 'Library card grids',
|
||||||
|
libraryGridMaxColumnsPerfHint: 'Higher values pack more tiles per row and can increase layout and painting work—noticeable on very large libraries or slower devices.',
|
||||||
|
libraryGridMaxColumnsRangeLabel: 'Maximum columns ({{min}}–{{max}})',
|
||||||
|
libraryGridMaxColumnsDesc: 'Applies to album, artist, playlist, radio, offline, and other card-based library views. Lower values use larger tiles and are usually easier on the CPU.',
|
||||||
randomMixTitle: 'Random Mix Blacklist',
|
randomMixTitle: 'Random Mix Blacklist',
|
||||||
luckyMixMenuTitle: 'Show Lucky Mix in menu',
|
luckyMixMenuTitle: 'Show Lucky Mix in menu',
|
||||||
luckyMixMenuDesc: 'Enables Lucky Mix in Build a Mix and as a separate menu item when split navigation is on. Visible only when AudioMuse is enabled on the active server.',
|
luckyMixMenuDesc: 'Enables Lucky Mix in Build a Mix and as a separate menu item when split navigation is on. Visible only when AudioMuse is enabled on the active server.',
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ export const settings = {
|
|||||||
aboutContributorsLabel: 'Contribuidores',
|
aboutContributorsLabel: 'Contribuidores',
|
||||||
showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar",
|
showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar",
|
||||||
showChangelogOnUpdateDesc: 'Muestra un discreto banner de changelog encima de Now Playing tras una actualización. Clic abre las notas de la versión; X lo oculta.',
|
showChangelogOnUpdateDesc: 'Muestra un discreto banner de changelog encima de Now Playing tras una actualización. Clic abre las notas de la versión; X lo oculta.',
|
||||||
|
libraryGridMaxColumnsTitle: 'Rejillas de tarjetas en la biblioteca',
|
||||||
|
libraryGridMaxColumnsPerfHint: 'Más columnas caben más mosaicos por fila y pueden aumentar el trabajo de diseño y pintado; se nota más en bibliotecas muy grandes o equipos lentos.',
|
||||||
|
libraryGridMaxColumnsRangeLabel: 'Máximo de columnas ({{min}}–{{max}})',
|
||||||
|
libraryGridMaxColumnsDesc: 'Se aplica a vistas de álbum, artista, lista de reproducción, radio, sin conexión y otras con tarjetas. Menos columnas = mosaicos más grandes y suele aligerar la CPU.',
|
||||||
randomMixTitle: 'Lista negra de Mezcla Aleatoria',
|
randomMixTitle: 'Lista negra de Mezcla Aleatoria',
|
||||||
luckyMixMenuTitle: 'Mostrar Mezcla Suerte en el menú',
|
luckyMixMenuTitle: 'Mostrar Mezcla Suerte en el menú',
|
||||||
luckyMixMenuDesc: 'Activa Mezcla Suerte en "Crear Mezcla" y como elemento de menú separado cuando la navegación dividida está activa. Solo visible cuando AudioMuse está activo en el servidor actual.',
|
luckyMixMenuDesc: 'Activa Mezcla Suerte en "Crear Mezcla" y como elemento de menú separado cuando la navegación dividida está activa. Solo visible cuando AudioMuse está activo en el servidor actual.',
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ export const settings = {
|
|||||||
aboutContributorsLabel: 'Contributeurs',
|
aboutContributorsLabel: 'Contributeurs',
|
||||||
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
|
showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour",
|
||||||
showChangelogOnUpdateDesc: "Affiche une bannière discrète du changelog au-dessus de Now Playing après une mise à jour. Un clic ouvre les notes de version, le X la masque.",
|
showChangelogOnUpdateDesc: "Affiche une bannière discrète du changelog au-dessus de Now Playing après une mise à jour. Un clic ouvre les notes de version, le X la masque.",
|
||||||
|
libraryGridMaxColumnsTitle: 'Grilles de cartes dans la bibliothèque',
|
||||||
|
libraryGridMaxColumnsPerfHint: 'Plus de colonnes signifie plus de tuiles par ligne et davantage de travail de mise en page et de peinture — surtout sur de grosses bibliothèques ou du matériel lent.',
|
||||||
|
libraryGridMaxColumnsRangeLabel: 'Nombre maximal de colonnes ({{min}}–{{max}})',
|
||||||
|
libraryGridMaxColumnsDesc: 'S’applique aux vues album, artiste, liste de lecture, radio, hors ligne et autres pages en cartes. Moins de colonnes = tuiles plus grandes et en général moins de charge CPU.',
|
||||||
randomMixTitle: 'Liste noire du mix aléatoire',
|
randomMixTitle: 'Liste noire du mix aléatoire',
|
||||||
luckyMixMenuTitle: 'Afficher Mix Chance dans le menu',
|
luckyMixMenuTitle: 'Afficher Mix Chance dans le menu',
|
||||||
luckyMixMenuDesc: 'Active Mix Chance dans "Créer un mix" et comme entrée séparée quand la navigation est scindée. Visible uniquement si AudioMuse est actif sur le serveur courant.',
|
luckyMixMenuDesc: 'Active Mix Chance dans "Créer un mix" et comme entrée séparée quand la navigation est scindée. Visible uniquement si AudioMuse est actif sur le serveur courant.',
|
||||||
|
|||||||
@@ -224,6 +224,10 @@ export const settings = {
|
|||||||
aboutContributorsLabel: 'Bidragsytere',
|
aboutContributorsLabel: 'Bidragsytere',
|
||||||
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon",
|
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon",
|
||||||
showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.",
|
showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.",
|
||||||
|
libraryGridMaxColumnsTitle: 'Kortrutenett i biblioteket',
|
||||||
|
libraryGridMaxColumnsPerfHint: 'Flere kolonner gir flere fliser per rad og kan øke layout- og tegnearbeid — merkbart på veldig store biblioteker eller tregere maskiner.',
|
||||||
|
libraryGridMaxColumnsRangeLabel: 'Maksimalt antall kolonner ({{min}}–{{max}})',
|
||||||
|
libraryGridMaxColumnsDesc: 'Gjelder album-, artist-, spilleliste-, radio-, offline- og andre kortbaserte bibliotekvisninger. Færre kolonner = større fliser og vanligvis mindre CPU-belastning.',
|
||||||
randomMixTitle: 'Svarteliste for tilfeldig miks',
|
randomMixTitle: 'Svarteliste for tilfeldig miks',
|
||||||
luckyMixMenuTitle: 'Vis Lykkemiks i menyen',
|
luckyMixMenuTitle: 'Vis Lykkemiks i menyen',
|
||||||
luckyMixMenuDesc: 'Aktiverer Lykkemiks i "Lag en miks" og som eget menypunkt når delt navigasjon er aktiv. Vises bare når AudioMuse er aktiv på gjeldende server.',
|
luckyMixMenuDesc: 'Aktiverer Lykkemiks i "Lag en miks" og som eget menypunkt når delt navigasjon er aktiv. Vises bare når AudioMuse er aktiv på gjeldende server.',
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ export const settings = {
|
|||||||
aboutContributorsLabel: 'Bijdragers',
|
aboutContributorsLabel: 'Bijdragers',
|
||||||
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
|
showChangelogOnUpdate: "'Wat is nieuw' tonen bij update",
|
||||||
showChangelogOnUpdateDesc: 'Toont een discrete changelog-banner boven Now Playing na een update. Klik opent de release-notities; X verbergt hem.',
|
showChangelogOnUpdateDesc: 'Toont een discrete changelog-banner boven Now Playing na een update. Klik opent de release-notities; X verbergt hem.',
|
||||||
|
libraryGridMaxColumnsTitle: 'Kaartenrasters in de bibliotheek',
|
||||||
|
libraryGridMaxColumnsPerfHint: 'Meer kolommen betekent meer tegels per rij en vaak meer lay-out- en schilderwerk — merkbaar bij zeer grote bibliotheken of tragere hardware.',
|
||||||
|
libraryGridMaxColumnsRangeLabel: 'Maximum aantal kolommen ({{min}}–{{max}})',
|
||||||
|
libraryGridMaxColumnsDesc: 'Geldt voor album-, artiest-, afspeellijst-, radio-, offline- en andere kaartweergaven. Minder kolommen = grotere tegels en meestal minder CPU-belasting.',
|
||||||
randomMixTitle: 'Willekeurige mix-blacklist',
|
randomMixTitle: 'Willekeurige mix-blacklist',
|
||||||
luckyMixMenuTitle: 'Toon Geluksmix in menu',
|
luckyMixMenuTitle: 'Toon Geluksmix in menu',
|
||||||
luckyMixMenuDesc: 'Schakelt Geluksmix in bij "Mix samenstellen" en als apart menu-item bij gesplitste navigatie. Alleen zichtbaar wanneer AudioMuse actief is op de huidige server.',
|
luckyMixMenuDesc: 'Schakelt Geluksmix in bij "Mix samenstellen" en als apart menu-item bij gesplitste navigatie. Alleen zichtbaar wanneer AudioMuse actief is op de huidige server.',
|
||||||
|
|||||||
@@ -231,6 +231,10 @@ export const settings = {
|
|||||||
aboutContributorsLabel: 'Contribuabili',
|
aboutContributorsLabel: 'Contribuabili',
|
||||||
showChangelogOnUpdate: "Arată 'Ce este nou' în update",
|
showChangelogOnUpdate: "Arată 'Ce este nou' în update",
|
||||||
showChangelogOnUpdateDesc: "Arată un banner discret cu lista modificărilor deasupra Now Playing după un update. Apasă pentru a deschide lista modificărilor; X îl respinge.",
|
showChangelogOnUpdateDesc: "Arată un banner discret cu lista modificărilor deasupra Now Playing după un update. Apasă pentru a deschide lista modificărilor; X îl respinge.",
|
||||||
|
libraryGridMaxColumnsTitle: 'Grile cu carduri în bibliotecă',
|
||||||
|
libraryGridMaxColumnsPerfHint: 'Mai multe coloane înseamnă mai multe plăcuțe pe rând și mai multă muncă de layout și desen — se simte pe biblioteci foarte mari sau hardware lent.',
|
||||||
|
libraryGridMaxColumnsRangeLabel: 'Număr maxim de coloane ({{min}}–{{max}})',
|
||||||
|
libraryGridMaxColumnsDesc: 'Se aplică la albume, artiști, liste de redare, radio, offline și alte ecrane cu carduri. Mai puține coloane = plăcuțe mai mari și de obicei mai puțină încărcare pe CPU.',
|
||||||
randomMixTitle: 'Lista neagră Mix Aleatoriu',
|
randomMixTitle: 'Lista neagră Mix Aleatoriu',
|
||||||
luckyMixMenuTitle: 'Arată Mixul Norocos în meniu',
|
luckyMixMenuTitle: 'Arată Mixul Norocos în meniu',
|
||||||
luckyMixMenuDesc: 'Activează Mixul Norocos în Construiește un Mix și ca un element separat în meniu când navigarea împărțită este pornită. Vizibil doar când AudioMuse este activat pe serverul activ.',
|
luckyMixMenuDesc: 'Activează Mixul Norocos în Construiește un Mix și ca un element separat în meniu când navigarea împărțită este pornită. Vizibil doar când AudioMuse este activat pe serverul activ.',
|
||||||
|
|||||||
@@ -237,6 +237,10 @@ export const settings = {
|
|||||||
aboutContributorsLabel: 'Участники',
|
aboutContributorsLabel: 'Участники',
|
||||||
showChangelogOnUpdate: 'Показывать «Что нового» после обновления',
|
showChangelogOnUpdate: 'Показывать «Что нового» после обновления',
|
||||||
showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.',
|
showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.',
|
||||||
|
libraryGridMaxColumnsTitle: 'Сетки карточек в библиотеке',
|
||||||
|
libraryGridMaxColumnsPerfHint: 'Больше колонок — больше плиток в ряд и больше работы по вёрстке и отрисовке; на очень больших библиотеках или слабом железе это заметнее.',
|
||||||
|
libraryGridMaxColumnsRangeLabel: 'Максимум колонок ({{min}}–{{max}})',
|
||||||
|
libraryGridMaxColumnsDesc: 'Действует для альбомов, исполнителей, плейлистов, радио, офлайн-библиотеки и других экранов с карточками. Меньше колонок — крупнее плитки и обычно меньше нагрузка на CPU.',
|
||||||
randomMixTitle: 'Чёрный список случайного микса',
|
randomMixTitle: 'Чёрный список случайного микса',
|
||||||
luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню',
|
luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню',
|
||||||
luckyMixMenuDesc:
|
luckyMixMenuDesc:
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ export const settings = {
|
|||||||
aboutContributorsLabel: '贡献者',
|
aboutContributorsLabel: '贡献者',
|
||||||
showChangelogOnUpdate: '更新时显示"新功能"',
|
showChangelogOnUpdate: '更新时显示"新功能"',
|
||||||
showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。',
|
showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。',
|
||||||
|
libraryGridMaxColumnsTitle: '资料库卡片网格',
|
||||||
|
libraryGridMaxColumnsPerfHint: '列数越多,每行显示的卡片越多,布局与绘制开销通常也越大;在超大曲库或较慢设备上更明显。',
|
||||||
|
libraryGridMaxColumnsRangeLabel: '最大列数({{min}}–{{max}})',
|
||||||
|
libraryGridMaxColumnsDesc: '适用于专辑、艺人、播放列表、电台、离线及其他卡片式资料库视图。列数越少卡片越大,通常对 CPU 更友好。',
|
||||||
randomMixTitle: '随机混音黑名单',
|
randomMixTitle: '随机混音黑名单',
|
||||||
luckyMixMenuTitle: '在菜单中显示“好运混音”',
|
luckyMixMenuTitle: '在菜单中显示“好运混音”',
|
||||||
luckyMixMenuDesc: '在“创建混音”中启用“好运混音”,并在分离导航时作为独立菜单项显示。仅当当前服务器启用 AudioMuse 时可见。',
|
luckyMixMenuDesc: '在“创建混音”中启用“好运混音”,并在分离导航时作为独立菜单项显示。仅当当前服务器启用 AudioMuse 时可见。',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ vi.mock('@/api/subsonic', () => ({
|
|||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
|
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
|
||||||
import { onInvoke } from '@/test/mocks/tauri';
|
import { onInvoke } from '@/test/mocks/tauri';
|
||||||
|
import {
|
||||||
|
LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||||
|
LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||||
|
} from './authStoreDefaults';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAuthStore();
|
resetAuthStore();
|
||||||
@@ -120,6 +124,17 @@ describe('setters with validation / clamping', () => {
|
|||||||
expect(useAuthStore.getState().trackPreviewDurationSec).toBe(31);
|
expect(useAuthStore.getState().trackPreviewDurationSec).toBe(31);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('setLibraryGridMaxColumns clamps to the allowed column range', () => {
|
||||||
|
useAuthStore.getState().setLibraryGridMaxColumns(99);
|
||||||
|
expect(useAuthStore.getState().libraryGridMaxColumns).toBe(LIBRARY_GRID_MAX_COLUMNS_MAX);
|
||||||
|
|
||||||
|
useAuthStore.getState().setLibraryGridMaxColumns(1);
|
||||||
|
expect(useAuthStore.getState().libraryGridMaxColumns).toBe(LIBRARY_GRID_MAX_COLUMNS_MIN);
|
||||||
|
|
||||||
|
useAuthStore.getState().setLibraryGridMaxColumns(6);
|
||||||
|
expect(useAuthStore.getState().libraryGridMaxColumns).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
it('setTrackPreviewsEnabled coerces truthy/falsy to boolean', () => {
|
it('setTrackPreviewsEnabled coerces truthy/falsy to boolean', () => {
|
||||||
useAuthStore.getState().setTrackPreviewsEnabled('yes' as unknown as boolean);
|
useAuthStore.getState().setTrackPreviewsEnabled('yes' as unknown as boolean);
|
||||||
expect(useAuthStore.getState().trackPreviewsEnabled).toBe(true);
|
expect(useAuthStore.getState().trackPreviewsEnabled).toBe(true);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
|
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
|
||||||
DEFAULT_LYRICS_SOURCES,
|
DEFAULT_LYRICS_SOURCES,
|
||||||
DEFAULT_TRACK_PREVIEW_LOCATIONS,
|
DEFAULT_TRACK_PREVIEW_LOCATIONS,
|
||||||
|
DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
|
||||||
} from './authStoreDefaults';
|
} from './authStoreDefaults';
|
||||||
import { computeAuthStoreRehydration } from './authStoreRehydrate';
|
import { computeAuthStoreRehydration } from './authStoreRehydrate';
|
||||||
import type { AuthState } from './authStoreTypes';
|
import type { AuthState } from './authStoreTypes';
|
||||||
@@ -58,6 +59,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
infiniteQueueEnabled: false,
|
infiniteQueueEnabled: false,
|
||||||
preservePlayNextOrder: false,
|
preservePlayNextOrder: false,
|
||||||
showArtistImages: false,
|
showArtistImages: false,
|
||||||
|
libraryGridMaxColumns: DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
|
||||||
showTrayIcon: true,
|
showTrayIcon: true,
|
||||||
minimizeToTray: false,
|
minimizeToTray: false,
|
||||||
showOrbitTrigger: true,
|
showOrbitTrigger: true,
|
||||||
|
|||||||
@@ -38,3 +38,8 @@ export const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [
|
|||||||
export const MIX_MIN_RATING_FILTER_MAX_STARS = 3;
|
export const MIX_MIN_RATING_FILTER_MAX_STARS = 3;
|
||||||
|
|
||||||
export const RANDOM_MIX_SIZE_OPTIONS: readonly number[] = [50, 75, 100, 125, 150];
|
export const RANDOM_MIX_SIZE_OPTIONS: readonly number[] = [50, 75, 100, 125, 150];
|
||||||
|
|
||||||
|
/** Default max columns for album/artist/playlist card grids (Settings → Library). */
|
||||||
|
export const DEFAULT_LIBRARY_GRID_MAX_COLUMNS = 6;
|
||||||
|
export const LIBRARY_GRID_MAX_COLUMNS_MIN = 4;
|
||||||
|
export const LIBRARY_GRID_MAX_COLUMNS_MAX = 12;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { clampStoredLoudnessPreAnalysisAttenuationRefDb } from '../utils/audio/loudnessPreAnalysisSlider';
|
import { clampStoredLoudnessPreAnalysisAttenuationRefDb } from '../utils/audio/loudnessPreAnalysisSlider';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
|
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
|
||||||
|
DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
|
||||||
|
LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||||
|
LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||||
LOUDNESS_LUFS_PRESETS,
|
LOUDNESS_LUFS_PRESETS,
|
||||||
MIX_MIN_RATING_FILTER_MAX_STARS,
|
MIX_MIN_RATING_FILTER_MAX_STARS,
|
||||||
RANDOM_MIX_SIZE_OPTIONS,
|
RANDOM_MIX_SIZE_OPTIONS,
|
||||||
@@ -41,6 +44,13 @@ export function clampRandomMixSize(v: number): number {
|
|||||||
return nearest;
|
return nearest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persisted max columns for library card grids (albums, artists, playlists, …). */
|
||||||
|
export function clampLibraryGridMaxColumns(v: unknown): number {
|
||||||
|
const n = typeof v === 'number' ? v : Number(v);
|
||||||
|
if (!Number.isFinite(n)) return DEFAULT_LIBRARY_GRID_MAX_COLUMNS;
|
||||||
|
return Math.max(LIBRARY_GRID_MAX_COLUMNS_MIN, Math.min(LIBRARY_GRID_MAX_COLUMNS_MAX, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
export function clampSkipStarThreshold(v: number): number {
|
export function clampSkipStarThreshold(v: number): number {
|
||||||
if (!Number.isFinite(v)) return 3;
|
if (!Number.isFinite(v)) return 3;
|
||||||
return Math.max(1, Math.min(99, Math.round(v)));
|
return Math.max(1, Math.min(99, Math.round(v)));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from './authStoreDefault
|
|||||||
import {
|
import {
|
||||||
clampMixFilterMinStars,
|
clampMixFilterMinStars,
|
||||||
clampRandomMixSize,
|
clampRandomMixSize,
|
||||||
|
clampLibraryGridMaxColumns,
|
||||||
sanitizeLoudnessLufsPreset,
|
sanitizeLoudnessLufsPreset,
|
||||||
sanitizeLoudnessPreAnalysisFromStorage,
|
sanitizeLoudnessPreAnalysisFromStorage,
|
||||||
sanitizeSkipStarCounts,
|
sanitizeSkipStarCounts,
|
||||||
@@ -116,6 +117,9 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
|||||||
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
|
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
|
||||||
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
|
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
|
||||||
randomMixSize: clampRandomMixSize(state.randomMixSize as number),
|
randomMixSize: clampRandomMixSize(state.randomMixSize as number),
|
||||||
|
libraryGridMaxColumns: clampLibraryGridMaxColumns(
|
||||||
|
(state as { libraryGridMaxColumns?: unknown }).libraryGridMaxColumns,
|
||||||
|
),
|
||||||
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
|
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
|
||||||
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
|
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ export interface AuthState {
|
|||||||
infiniteQueueEnabled: boolean;
|
infiniteQueueEnabled: boolean;
|
||||||
preservePlayNextOrder: boolean;
|
preservePlayNextOrder: boolean;
|
||||||
showArtistImages: boolean;
|
showArtistImages: boolean;
|
||||||
|
/**
|
||||||
|
* Max columns for album/artist/playlist-style card grids (Settings → Library).
|
||||||
|
* Clamped 4…12; higher values mean more tiles per row and more layout/paint work.
|
||||||
|
*/
|
||||||
|
libraryGridMaxColumns: number;
|
||||||
showTrayIcon: boolean;
|
showTrayIcon: boolean;
|
||||||
minimizeToTray: boolean;
|
minimizeToTray: boolean;
|
||||||
/** Whether the "Orbit" topbar trigger is rendered. Users who never
|
/** Whether the "Orbit" topbar trigger is rendered. Users who never
|
||||||
@@ -252,6 +257,7 @@ export interface AuthState {
|
|||||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||||
setPreservePlayNextOrder: (v: boolean) => void;
|
setPreservePlayNextOrder: (v: boolean) => void;
|
||||||
setShowArtistImages: (v: boolean) => void;
|
setShowArtistImages: (v: boolean) => void;
|
||||||
|
setLibraryGridMaxColumns: (v: number) => void;
|
||||||
setShowTrayIcon: (v: boolean) => void;
|
setShowTrayIcon: (v: boolean) => void;
|
||||||
setMinimizeToTray: (v: boolean) => void;
|
setMinimizeToTray: (v: boolean) => void;
|
||||||
setShowOrbitTrigger: (v: boolean) => void;
|
setShowOrbitTrigger: (v: boolean) => void;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AuthState } from './authStoreTypes';
|
import type { AuthState } from './authStoreTypes';
|
||||||
|
import { clampLibraryGridMaxColumns } from './authStoreHelpers';
|
||||||
|
|
||||||
type SetState = (
|
type SetState = (
|
||||||
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
|
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
|
||||||
@@ -12,6 +13,7 @@ type SetState = (
|
|||||||
export function createUiAppearanceActions(set: SetState): Pick<
|
export function createUiAppearanceActions(set: SetState): Pick<
|
||||||
AuthState,
|
AuthState,
|
||||||
| 'setShowArtistImages'
|
| 'setShowArtistImages'
|
||||||
|
| 'setLibraryGridMaxColumns'
|
||||||
| 'setShowTrayIcon'
|
| 'setShowTrayIcon'
|
||||||
| 'setMinimizeToTray'
|
| 'setMinimizeToTray'
|
||||||
| 'setShowOrbitTrigger'
|
| 'setShowOrbitTrigger'
|
||||||
@@ -30,6 +32,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
|||||||
> {
|
> {
|
||||||
return {
|
return {
|
||||||
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
||||||
|
setLibraryGridMaxColumns: (v) => set({ libraryGridMaxColumns: clampLibraryGridMaxColumns(v) }),
|
||||||
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
|
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
|
||||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||||
setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }),
|
setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }),
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { computeCardGridColumnCount } from './cardGridLayout';
|
||||||
|
import { LIBRARY_GRID_MAX_COLUMNS_MAX, LIBRARY_GRID_MAX_COLUMNS_MIN } from '../store/authStoreDefaults';
|
||||||
|
|
||||||
|
describe('computeCardGridColumnCount', () => {
|
||||||
|
it('never exceeds the configured max', () => {
|
||||||
|
expect(computeCardGridColumnCount(20_000, 6)).toBe(6);
|
||||||
|
expect(computeCardGridColumnCount(20_000, 4)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps requested max to store-wide upper bound', () => {
|
||||||
|
expect(computeCardGridColumnCount(20_000, 99)).toBe(LIBRARY_GRID_MAX_COLUMNS_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps requested max to store-wide lower bound', () => {
|
||||||
|
expect(computeCardGridColumnCount(20_000, 2)).toBe(LIBRARY_GRID_MAX_COLUMNS_MIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns at least one column', () => {
|
||||||
|
expect(computeCardGridColumnCount(50, 6)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses six columns on wide desktop when max allows', () => {
|
||||||
|
expect(computeCardGridColumnCount(1200, 6)).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Shared responsive card grids: capped columns, even stretch (`minmax(0, 1fr)`),
|
||||||
|
* and row-height estimates derived from measured cell width (TanStack virtual rows).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
|
||||||
|
LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||||
|
LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||||
|
} from '../store/authStoreDefaults';
|
||||||
|
|
||||||
|
export const CARD_GRID_GAP_PX = 16;
|
||||||
|
export const CARD_GRID_MIN_TILE_PX = 140;
|
||||||
|
|
||||||
|
/** @deprecated use `DEFAULT_LIBRARY_GRID_MAX_COLUMNS` from `authStoreDefaults` */
|
||||||
|
export const CARD_GRID_MAX_COLS = DEFAULT_LIBRARY_GRID_MAX_COLUMNS;
|
||||||
|
|
||||||
|
export function computeCardGridColumnCount(containerWidthPx: number, maxColumns: number): number {
|
||||||
|
const cap = Math.max(
|
||||||
|
LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||||
|
Math.min(LIBRARY_GRID_MAX_COLUMNS_MAX, Math.round(maxColumns)),
|
||||||
|
);
|
||||||
|
const raw = Math.floor(
|
||||||
|
(containerWidthPx + CARD_GRID_GAP_PX) / (CARD_GRID_MIN_TILE_PX + CARD_GRID_GAP_PX),
|
||||||
|
);
|
||||||
|
return Math.min(cap, 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