refactor(artists): I.4 — split Artists.tsx 520 → 233 LOC across 6 files (#676)

* refactor(artists): extract helpers + constants

Pull ALL_SENTINEL / ALPHABET / ARTIST_LIST_* row-height estimates,
the ArtistListFlatRow union, CTP_COLORS palette, and the deterministic
nameColor / nameInitial helpers into utils/artistsHelpers.ts.

Artists.tsx: 520 → 496 LOC.

* refactor(artists): extract ArtistAvatars subcomponents

Pull ArtistCardAvatar (300px, grid view) and ArtistRowAvatar (64px, list
view) into components/artists/ArtistAvatars.tsx. Both fall back to a
hashed-Catppuccin monogram when artist images are off or no cover art
is available.

Artists.tsx: 496 → 436 LOC.

* refactor(artists): extract useArtistsFiltering hook

Bundle the letter/text/star filter pipeline, visible-slice memo,
group-by-letter, and the virtualizer flat-rows list into
hooks/useArtistsFiltering.ts. List-view-only outputs short-circuit when
grid view is active.

Artists.tsx: 436 → 386 LOC.

* refactor(artists): extract useArtistsInfiniteScroll hook

Bundle visibleCount + loadingMore state, the sentinel
IntersectionObserver, loadMore callback, and the filter-change reset
into hooks/useArtistsInfiniteScroll.ts. The observer no longer takes
hasMore — the sentinel element only mounts while there is more data,
so the observer attaches/detaches naturally with it.

Artists.tsx: 386 → 370 LOC.

* refactor(artists): extract ArtistsGridView + ArtistsListView

Move the grid card layout to components/artists/ArtistsGridView.tsx and
the dual-path list layout (non-virtualized fallback + virtualized stream)
to components/artists/ArtistsListView.tsx. Both paths now share an
internal ArtistListRow component so click + context-menu behaviour is
identical regardless of which renderer is active.

Artists.tsx: 370 → 233 LOC.
This commit is contained in:
Frank Stellmacher
2026-05-14 00:33:27 +02:00
committed by GitHub
parent 25289bbf31
commit 6552d2a5cb
7 changed files with 584 additions and 337 deletions
+97
View File
@@ -0,0 +1,97 @@
import { useMemo } from 'react';
import type { SubsonicArtist } from '../api/subsonicTypes';
import { usePlayerStore } from '../store/playerStore';
import { ALL_SENTINEL, type ArtistListFlatRow } from '../utils/artistsHelpers';
interface UseArtistsFilteringArgs {
artists: SubsonicArtist[];
filter: string;
letterFilter: string;
starredOnly: boolean;
visibleCount: number;
viewMode: 'grid' | 'list';
}
interface UseArtistsFilteringResult {
filtered: SubsonicArtist[];
visible: SubsonicArtist[];
hasMore: boolean;
groups: Record<string, SubsonicArtist[]>;
letters: string[];
artistListFlatRows: ArtistListFlatRow[];
}
/**
* Memoised filter + group pipeline for the artists page. Reading
* `starredOverrides` here keeps the star-toggle reactive without
* dragging the full player store through Artists.tsx props.
*
* Walking 5000+ artists per render was measurable — every cheap state
* update (selection mode, view mode, page size) used to re-filter the
* whole list. With this hook the three artist arrays
* (filtered → visible → flat-rows) only recompute when their explicit
* deps change.
*
* Group-by-letter and flat-row construction short-circuit when the user
* is on the grid view, since neither output is needed there.
*/
export function useArtistsFiltering({
artists,
filter,
letterFilter,
starredOnly,
visibleCount,
viewMode,
}: UseArtistsFilteringArgs): UseArtistsFilteringResult {
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const filtered = useMemo(() => {
let out = artists;
if (letterFilter !== ALL_SENTINEL) {
out = out.filter(a => {
const first = a.name[0]?.toUpperCase() ?? '#';
const isAlpha = /^[A-Z]$/.test(first);
if (letterFilter === '#') return !isAlpha;
return first === letterFilter;
});
}
if (filter) {
const needle = filter.toLowerCase();
out = out.filter(a => a.name.toLowerCase().includes(needle));
}
if (starredOnly) {
out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred);
}
return out;
}, [artists, letterFilter, filter, starredOnly, starredOverrides]);
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
const hasMore = visibleCount < filtered.length;
const { groups, letters } = useMemo(() => {
if (viewMode !== 'list') return { groups: {} as Record<string, SubsonicArtist[]>, letters: [] as string[] };
const g: Record<string, SubsonicArtist[]> = {};
for (const a of visible) {
const letter = a.name[0]?.toUpperCase() ?? '#';
const key = /^[A-Z]$/.test(letter) ? letter : '#';
if (!g[key]) g[key] = [];
g[key].push(a);
}
return { groups: g, letters: Object.keys(g).sort() };
}, [visible, viewMode]);
const artistListFlatRows = useMemo((): ArtistListFlatRow[] => {
if (viewMode !== 'list') return [];
const out: ArtistListFlatRow[] = [];
for (const letter of letters) {
out.push({ kind: 'letter', letter });
const group = groups[letter];
for (let i = 0; i < group.length; i++) {
out.push({ kind: 'artist', artist: group[i], isLastInLetter: i === group.length - 1 });
}
}
return out;
}, [viewMode, letters, groups]);
return { filtered, visible, hasMore, groups, letters, artistListFlatRows };
}
+61
View File
@@ -0,0 +1,61 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
interface UseArtistsInfiniteScrollArgs {
pageSize: number;
resetDeps: ReadonlyArray<unknown>;
}
interface UseArtistsInfiniteScrollResult {
visibleCount: number;
loadingMore: boolean;
observerTarget: React.RefObject<HTMLDivElement | null>;
loadMore: () => void;
}
/**
* Page through the artists list with a sentinel-driven
* IntersectionObserver. `pageSize` is dynamic because artist-images
* mode wants smaller batches to keep disk I/O sane on big libraries
* (5000+ artists).
*
* `resetDeps` is the list of values that should snap `visibleCount`
* back to one page — filter text, letter pick, starred-only,
* view-mode, page-size itself.
*
* The observer doesn't take a `hasMore` flag — the page only renders
* the sentinel `<div ref={observerTarget}>` while there is more data,
* so the observer naturally disconnects when the last page is reached
* (the cleanup runs as the sentinel unmounts).
*/
export function useArtistsInfiniteScroll({
pageSize,
resetDeps,
}: UseArtistsInfiniteScrollArgs): UseArtistsInfiniteScrollResult {
const [visibleCount, setVisibleCount] = useState(pageSize);
const [loadingMore, setLoadingMore] = useState(false);
const observerTarget = useRef<HTMLDivElement>(null);
const loadMore = useCallback(() => {
if (loadingMore) return;
setLoadingMore(true);
setVisibleCount(prev => prev + pageSize);
setTimeout(() => setLoadingMore(false), 100);
}, [loadingMore, pageSize]);
useEffect(() => {
setVisibleCount(pageSize);
// resetDeps is intentionally spread into the dep array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, ...resetDeps]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: '200px' },
);
if (observerTarget.current) observer.observe(observerTarget.current);
return () => observer.disconnect();
}, [loadMore]);
return { visibleCount, loadingMore, observerTarget, loadMore };
}