Files
psysonic/src/components/artists/ArtistsListView.tsx
T
Frank Stellmacher 48a69754d6 fix(virtual): apply scrollMargin to remaining useVirtualizer call-sites (#766)
* refactor(virtual): extract scrollMargin measurement into a reusable hook

VirtualCardGrid carried its own useLayoutEffect + ResizeObserver block for
the scroll-margin computation introduced in #764. The same pattern is
needed on every other useVirtualizer site whose wrapper sits below other
content; pull it out as useVirtualizerScrollMargin so each call-site is a
single hook invocation instead of a 20-line duplicate.

No behavior change for VirtualCardGrid — same scroll element, same deps,
same observed nodes.

* fix(virtual): apply scrollMargin to the remaining useVirtualizer call-sites

#764 fixed the symptom for VirtualCardGrid (Albums / Artists grid via the
shared component, Composers grid, "More by …" rail, etc.) but four more
useVirtualizer call-sites have the same shape — wrapper sits below a
sticky page header and TanStack would otherwise place rows starting at
the scroll-element top, unmounting rows that are still in the viewport
and at higher offsets refusing to render at all:

- Artists grid view (Artists.tsx + ArtistsGridView.tsx) — under the
  sticky title + filter row + alphabet bar (~150–250 px depending on
  alphabet wrap).
- Artists list view (Artists.tsx + ArtistsListView.tsx) — same header
  stack; flat letter/row stream means a noticeable jump on long
  libraries.
- Composers list view (Composers.tsx) — identical header stack to
  Artists.
- VirtualSongList — ~36 px sticky song-list header inside its own
  scroll container; the offset was small enough to be absorbed by
  overscan, but the pattern was the same so wire it up for consistency.

Each call-site now feeds wrapRef + scroll-element lookup into
useVirtualizerScrollMargin and forwards the value into useVirtualizer +
the translateY of each rendered row.

* docs(changelog): note PR #766 under Fixed in v1.46.0
2026-05-18 01:27:38 +02:00

188 lines
5.5 KiB
TypeScript

import React from 'react';
import type { NavigateFunction } from 'react-router-dom';
import type { Virtualizer } from '@tanstack/react-virtual';
import type { TFunction } from 'i18next';
import type { SubsonicArtist } from '../../api/subsonicTypes';
import type { PlayerState } from '../../store/playerStoreTypes';
import type { ArtistListFlatRow } from '../../utils/componentHelpers/artistsHelpers';
import { ArtistRowAvatar } from './ArtistAvatars';
interface RowProps {
artist: SubsonicArtist;
selectionMode: boolean;
selectedIds: Set<string>;
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
navigate: NavigateFunction;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
function ArtistListRow({
artist,
selectionMode,
selectedIds,
selectedArtists,
showArtistImages,
toggleSelect,
navigate,
openContextMenu,
t,
}: RowProps) {
return (
<button
type="button"
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
onClick={() => {
if (selectionMode) {
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');
}
}}
id={`artist-${artist.id}`}
style={selectionMode && selectedIds.has(artist.id) ? {
background: 'var(--accent-dim)',
color: 'var(--accent)',
} : {}}
>
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
);
}
interface Props {
virtualized: boolean;
groups: Record<string, SubsonicArtist[]>;
letters: string[];
artistListFlatRows: ArtistListFlatRow[];
artistListVirtualizer: Virtualizer<HTMLElement, Element>;
artistListWrapRef: React.RefObject<HTMLDivElement | null>;
artistListScrollMargin: number;
selectionMode: boolean;
selectedIds: Set<string>;
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
navigate: NavigateFunction;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
/**
* List view for the artists page. Two render paths:
* - Non-virtualized — emits one `<div class="artist-list">` per starting
* letter, used when the `disableMainstageVirtualLists` perf flag is on
* (mostly for low-end devices where translate-Y positioning costs more
* than the saved DOM nodes).
* - Virtualized — flat `letter / artist / artist / …` row stream sitting
* on a single absolutely-positioned `<div>` whose height matches the
* virtualizer's totalSize.
*
* Both paths share `ArtistListRow` so click + context-menu behaviour is
* identical regardless of the rendering path.
*/
export function ArtistsListView({
virtualized,
groups,
letters,
artistListFlatRows,
artistListVirtualizer,
artistListWrapRef,
artistListScrollMargin,
selectionMode,
selectedIds,
selectedArtists,
showArtistImages,
toggleSelect,
navigate,
openContextMenu,
t,
}: Props) {
const rowCommonProps = {
selectionMode, selectedIds, selectedArtists, showArtistImages,
toggleSelect, navigate, openContextMenu, t,
};
if (!virtualized) {
return (
<>
{letters.map(letter => (
<div key={letter} style={{ marginBottom: '1.5rem' }}>
<h3 className="letter-heading">{letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => (
<ArtistListRow key={artist.id} artist={artist} {...rowCommonProps} />
))}
</div>
</div>
))}
</>
);
}
return (
<div ref={artistListWrapRef} style={{ position: 'relative', width: '100%' }}>
<div
style={{
height: artistListFlatRows.length === 0 ? 0 : artistListVirtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{artistListVirtualizer.getVirtualItems().map(vi => {
const row = artistListFlatRows[vi.index];
if (!row) return null;
if (row.kind === 'letter') {
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start - artistListScrollMargin}px)`,
}}
>
<h3 className="letter-heading">{row.letter}</h3>
</div>
);
}
const artist = row.artist;
return (
<div
key={vi.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start - artistListScrollMargin}px)`,
paddingBottom: row.isLastInLetter ? '1.5rem' : undefined,
}}
>
<ArtistListRow artist={artist} {...rowCommonProps} />
</div>
);
})}
</div>
</div>
);
}