mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
committed by
GitHub
parent
25289bbf31
commit
6552d2a5cb
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user