From 6552d2a5cb8e819e85b3859bb60d017ec6476bc1 Mon Sep 17 00:00:00 2001
From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com>
Date: Thu, 14 May 2026 00:33:27 +0200
Subject: [PATCH] =?UTF-8?q?refactor(artists):=20I.4=20=E2=80=94=20split=20?=
=?UTF-8?q?Artists.tsx=20520=20=E2=86=92=20233=20LOC=20across=206=20files?=
=?UTF-8?q?=20(#676)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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.
---
src/components/artists/ArtistAvatars.tsx | 77 ++++
src/components/artists/ArtistsGridView.tsx | 81 +++++
src/components/artists/ArtistsListView.tsx | 183 ++++++++++
src/hooks/useArtistsFiltering.ts | 97 ++++++
src/hooks/useArtistsInfiniteScroll.ts | 61 ++++
src/pages/Artists.tsx | 387 +++------------------
src/utils/artistsHelpers.ts | 35 ++
7 files changed, 584 insertions(+), 337 deletions(-)
create mode 100644 src/components/artists/ArtistAvatars.tsx
create mode 100644 src/components/artists/ArtistsGridView.tsx
create mode 100644 src/components/artists/ArtistsListView.tsx
create mode 100644 src/hooks/useArtistsFiltering.ts
create mode 100644 src/hooks/useArtistsInfiniteScroll.ts
create mode 100644 src/utils/artistsHelpers.ts
diff --git a/src/components/artists/ArtistAvatars.tsx b/src/components/artists/ArtistAvatars.tsx
new file mode 100644
index 00000000..166ac759
--- /dev/null
+++ b/src/components/artists/ArtistAvatars.tsx
@@ -0,0 +1,77 @@
+import React, { useMemo } from 'react';
+import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
+import type { SubsonicArtist } from '../../api/subsonicTypes';
+import CachedImage from '../CachedImage';
+import { nameColor, nameInitial } from '../../utils/artistsHelpers';
+
+interface AvatarProps {
+ artist: SubsonicArtist;
+ showImages: boolean;
+}
+
+/**
+ * Card-sized artist avatar for the grid view. Falls back to a coloured
+ * monogram (Catppuccin palette, hashed by name) when artist images are
+ * disabled or the artist has no cover art.
+ */
+export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
+ const color = nameColor(artist.name);
+ const coverId = artist.coverArt || artist.id;
+ const { coverSrc, coverKey } = useMemo(
+ () => ({
+ coverSrc: coverId ? buildCoverArtUrl(coverId, 300) : '',
+ coverKey: coverId ? coverArtCacheKey(coverId, 300) : '',
+ }),
+ [coverId],
+ );
+ if (showImages && coverId) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {nameInitial(artist.name)}
+
+ );
+}
+
+/**
+ * Row-sized artist avatar for the list view. Same fallback rules as the
+ * card variant, but smaller cover-art size (64px vs 300px) so list rows
+ * don't pull oversized images from the server.
+ */
+export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
+ const color = nameColor(artist.name);
+ const coverId = artist.coverArt || artist.id;
+ const { coverSrc, coverKey } = useMemo(
+ () => ({
+ coverSrc: coverId ? buildCoverArtUrl(coverId, 64) : '',
+ coverKey: coverId ? coverArtCacheKey(coverId, 64) : '',
+ }),
+ [coverId],
+ );
+ if (showImages && coverId) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {nameInitial(artist.name)}
+
+ );
+}
diff --git a/src/components/artists/ArtistsGridView.tsx b/src/components/artists/ArtistsGridView.tsx
new file mode 100644
index 00000000..5baa7a1d
--- /dev/null
+++ b/src/components/artists/ArtistsGridView.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import type { NavigateFunction } from 'react-router-dom';
+import { Check } from 'lucide-react';
+import type { TFunction } from 'i18next';
+import type { SubsonicArtist } from '../../api/subsonicTypes';
+import type { PlayerState } from '../../store/playerStoreTypes';
+import { ArtistCardAvatar } from './ArtistAvatars';
+
+interface Props {
+ visible: SubsonicArtist[];
+ selectionMode: boolean;
+ selectedIds: Set;
+ selectedArtists: SubsonicArtist[];
+ showArtistImages: boolean;
+ toggleSelect: (id: string) => void;
+ navigate: NavigateFunction;
+ openContextMenu: PlayerState['openContextMenu'];
+ t: TFunction;
+}
+
+/**
+ * Card grid for the artists page. Click navigates to the artist detail
+ * (or toggles selection while in select-mode); right-click opens the
+ * standard context menu, escalating to `multi-artist` when there is an
+ * active multi-selection.
+ */
+export function ArtistsGridView({
+ visible,
+ selectionMode,
+ selectedIds,
+ selectedArtists,
+ showArtistImages,
+ toggleSelect,
+ navigate,
+ openContextMenu,
+ t,
+}: Props) {
+ return (
+
+ {visible.map(artist => (
+
{
+ 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');
+ }
+ }}
+ style={selectionMode && selectedIds.has(artist.id) ? {
+ outline: '2px solid var(--accent)',
+ outlineOffset: '2px',
+ borderRadius: 'var(--radius-md)',
+ } : {}}
+ >
+ {selectionMode && (
+
+ {selectedIds.has(artist.id) && }
+
+ )}
+
+
+
{artist.name}
+ {artist.albumCount != null && (
+
{t('artists.albumCount', { count: artist.albumCount })}
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/artists/ArtistsListView.tsx b/src/components/artists/ArtistsListView.tsx
new file mode 100644
index 00000000..6999450c
--- /dev/null
+++ b/src/components/artists/ArtistsListView.tsx
@@ -0,0 +1,183 @@
+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/artistsHelpers';
+import { ArtistRowAvatar } from './ArtistAvatars';
+
+interface RowProps {
+ artist: SubsonicArtist;
+ selectionMode: boolean;
+ selectedIds: Set;
+ 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 (
+ {
+ 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)',
+ } : {}}
+ >
+
+
+
{artist.name}
+ {artist.albumCount != null && (
+
{t('artists.albumCount', { count: artist.albumCount })}
+ )}
+
+
+ );
+}
+
+interface Props {
+ virtualized: boolean;
+ groups: Record;
+ letters: string[];
+ artistListFlatRows: ArtistListFlatRow[];
+ artistListVirtualizer: Virtualizer;
+ selectionMode: boolean;
+ selectedIds: Set;
+ 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 `` 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 `
` 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,
+ 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 => (
+
+
{letter}
+
+ {groups[letter].map(artist => (
+
+ ))}
+
+
+ ))}
+ >
+ );
+ }
+
+ return (
+
+
+ {artistListVirtualizer.getVirtualItems().map(vi => {
+ const row = artistListFlatRows[vi.index];
+ if (!row) return null;
+ if (row.kind === 'letter') {
+ return (
+
+
{row.letter}
+
+ );
+ }
+ const artist = row.artist;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/hooks/useArtistsFiltering.ts b/src/hooks/useArtistsFiltering.ts
new file mode 100644
index 00000000..830c86af
--- /dev/null
+++ b/src/hooks/useArtistsFiltering.ts
@@ -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
;
+ 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, letters: [] as string[] };
+ const g: Record = {};
+ 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 };
+}
diff --git a/src/hooks/useArtistsInfiniteScroll.ts b/src/hooks/useArtistsInfiniteScroll.ts
new file mode 100644
index 00000000..07340eba
--- /dev/null
+++ b/src/hooks/useArtistsInfiniteScroll.ts
@@ -0,0 +1,61 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+
+interface UseArtistsInfiniteScrollArgs {
+ pageSize: number;
+ resetDeps: ReadonlyArray;
+}
+
+interface UseArtistsInfiniteScrollResult {
+ visibleCount: number;
+ loadingMore: boolean;
+ observerTarget: React.RefObject;
+ 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 `` 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
(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 };
+}
diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx
index 4d899645..df3b2b68 100644
--- a/src/pages/Artists.tsx
+++ b/src/pages/Artists.tsx
@@ -1,109 +1,27 @@
-import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { getArtists } from '../api/subsonicArtists';
import type { SubsonicArtist } from '../api/subsonicTypes';
-import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
+import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
-import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
+import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react';
import StarFilterButton from '../components/StarFilterButton';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
-import CachedImage from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
import { usePerfProbeFlags } from '../utils/perfFlags';
-
-const ALL_SENTINEL = 'ALL';
-const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
-
-/** Virtual row height guesses — letter heading vs dense rows vs last row in section (group gap). */
-const ARTIST_LIST_LETTER_ROW_EST = 48;
-const ARTIST_LIST_ROW_EST = 64;
-const ARTIST_LIST_LAST_IN_LETTER_EST = 88;
-
-type ArtistListFlatRow =
- | { kind: 'letter'; letter: string }
- | { kind: 'artist'; artist: SubsonicArtist; isLastInLetter: boolean };
-
-// Catppuccin accent colors — one is picked deterministically from the artist name
-const CTP_COLORS = [
- 'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
- 'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
- 'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
- 'var(--ctp-blue)', 'var(--ctp-lavender)',
-];
-
-function nameColor(name: string): string {
- let h = 0;
- for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
- return CTP_COLORS[h % CTP_COLORS.length];
-}
-
-function nameInitial(name: string): string {
- // \p{L} matches any Unicode letter — covers cyrillic, arabic, CJK, etc.
- const letter = name.match(/\p{L}/u)?.[0];
- if (letter) return letter.toUpperCase();
- const alnum = name.match(/[0-9]/)?.[0];
- return alnum ?? '?';
-}
-
-function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
- const color = nameColor(artist.name);
- const coverId = artist.coverArt || artist.id;
- const { coverSrc, coverKey } = useMemo(
- () => ({
- coverSrc: coverId ? buildCoverArtUrl(coverId, 300) : '',
- coverKey: coverId ? coverArtCacheKey(coverId, 300) : '',
- }),
- [coverId],
- );
- if (showImages && coverId) {
- return (
-
-
-
- );
- }
- return (
-
- {nameInitial(artist.name)}
-
- );
-}
-
-function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
- const color = nameColor(artist.name);
- const coverId = artist.coverArt || artist.id;
- const { coverSrc, coverKey } = useMemo(
- () => ({
- coverSrc: coverId ? buildCoverArtUrl(coverId, 64) : '',
- coverKey: coverId ? coverArtCacheKey(coverId, 64) : '',
- }),
- [coverId],
- );
- if (showImages && coverId) {
- return (
-
-
-
- );
- }
- return (
-
- {nameInitial(artist.name)}
-
- );
-}
+import {
+ ALL_SENTINEL,
+ ALPHABET,
+ ARTIST_LIST_LAST_IN_LETTER_EST,
+ ARTIST_LIST_LETTER_ROW_EST,
+ ARTIST_LIST_ROW_EST,
+} from '../utils/artistsHelpers';
+import { useArtistsFiltering } from '../hooks/useArtistsFiltering';
+import { useArtistsInfiniteScroll } from '../hooks/useArtistsInfiniteScroll';
+import { ArtistsGridView } from '../components/artists/ArtistsGridView';
+import { ArtistsListView } from '../components/artists/ArtistsListView';
export default function Artists() {
const perfFlags = usePerfProbeFlags();
@@ -117,9 +35,14 @@ export default function Artists() {
const showArtistImages = useAuthStore(s => s.showArtistImages);
const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
- const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
- const [loadingMore, setLoadingMore] = useState(false);
- const observerTarget = useRef(null);
+ const {
+ visibleCount,
+ loadingMore,
+ observerTarget,
+ } = useArtistsInfiniteScroll({
+ pageSize: PAGE_SIZE,
+ resetDeps: [filter, letterFilter, starredOnly, viewMode],
+ });
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
@@ -153,81 +76,9 @@ export default function Artists() {
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
}, [musicLibraryFilterVersion]);
- const loadMore = useCallback(() => {
- if (loadingMore) return;
- setLoadingMore(true);
- setVisibleCount(prev => prev + PAGE_SIZE);
- setTimeout(() => setLoadingMore(false), 100);
- }, [loadingMore, PAGE_SIZE]);
-
- // Reset infinite scroll when filters or image setting change
- useEffect(() => {
- setVisibleCount(PAGE_SIZE);
- }, [filter, letterFilter, starredOnly, viewMode, PAGE_SIZE]);
-
- const starredOverrides = usePlayerStore(s => s.starredOverrides);
- // Filter pipeline — memoised so unrelated state changes (selection mode,
- // viewMode, etc.) don't re-iterate the full artists array. With 5000+
- // artists each re-render walked the list twice without this.
- 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;
-
- // Intersection Observer for infinite scroll (after hasMore declaration)
- useEffect(() => {
- const observer = new IntersectionObserver(
- entries => { if (entries[0].isIntersecting) loadMore(); },
- { rootMargin: '200px' }
- );
- if (observerTarget.current) observer.observe(observerTarget.current);
- return () => observer.disconnect();
- }, [loadMore, hasMore]);
-
- // Group by first letter (for list view) — only recompute when the visible
- // slice or the view mode actually changes. Skipped entirely in grid view.
- const { groups, letters } = useMemo(() => {
- if (viewMode !== 'list') return { groups: {} as Record, letters: [] as string[] };
- const g: Record = {};
- 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]);
+ const {
+ filtered, visible, hasMore, groups, letters, artistListFlatRows,
+ } = useArtistsFiltering({ artists, filter, letterFilter, starredOnly, visibleCount, viewMode });
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
/** Mixed row heights; smallest typical step ≈ artist row — one viewport of extra indices each side. */
@@ -335,173 +186,35 @@ export default function Artists() {
{loading && }
{!loading && viewMode === 'grid' && (
-
- {visible.map(artist => (
-
{
- 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');
- }
- }}
- style={selectionMode && selectedIds.has(artist.id) ? {
- outline: '2px solid var(--accent)',
- outlineOffset: '2px',
- borderRadius: 'var(--radius-md)'
- } : {}}
- >
- {selectionMode && (
-
- {selectedIds.has(artist.id) && }
-
- )}
-
-
-
{artist.name}
- {artist.albumCount != null && (
-
{t('artists.albumCount', { count: artist.albumCount })}
- )}
-
-
- ))}
-
+
)}
{!loading && viewMode === 'list' && (
- perfFlags.disableMainstageVirtualLists ? (
- <>
- {letters.map(letter => (
-
-
{letter}
-
- {groups[letter].map(artist => (
-
{
- 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)'
- } : {}}
- >
-
-
-
{artist.name}
- {artist.albumCount != null && (
-
{t('artists.albumCount', { count: artist.albumCount })}
- )}
-
-
- ))}
-
-
- ))}
- >
- ) : (
-
-
- {artistListVirtualizer.getVirtualItems().map(vi => {
- const row = artistListFlatRows[vi.index];
- if (!row) return null;
- if (row.kind === 'letter') {
- return (
-
-
{row.letter}
-
- );
- }
- const artist = row.artist;
- return (
-
-
{
- 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)'
- } : {}}
- >
-
-
-
{artist.name}
- {artist.albumCount != null && (
-
{t('artists.albumCount', { count: artist.albumCount })}
- )}
-
-
-
- );
- })}
-
-
- )
+
)}
{!loading && hasMore && (
diff --git a/src/utils/artistsHelpers.ts b/src/utils/artistsHelpers.ts
new file mode 100644
index 00000000..51b0a732
--- /dev/null
+++ b/src/utils/artistsHelpers.ts
@@ -0,0 +1,35 @@
+import type { SubsonicArtist } from '../api/subsonicTypes';
+
+export const ALL_SENTINEL = 'ALL';
+export const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
+
+/** Virtual row height guesses — letter heading vs dense rows vs last row in section (group gap). */
+export const ARTIST_LIST_LETTER_ROW_EST = 48;
+export const ARTIST_LIST_ROW_EST = 64;
+export const ARTIST_LIST_LAST_IN_LETTER_EST = 88;
+
+export type ArtistListFlatRow =
+ | { kind: 'letter'; letter: string }
+ | { kind: 'artist'; artist: SubsonicArtist; isLastInLetter: boolean };
+
+// Catppuccin accent colors — one is picked deterministically from the artist name
+const CTP_COLORS = [
+ 'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
+ 'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
+ 'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
+ 'var(--ctp-blue)', 'var(--ctp-lavender)',
+];
+
+export function nameColor(name: string): string {
+ let h = 0;
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
+ return CTP_COLORS[h % CTP_COLORS.length];
+}
+
+export function nameInitial(name: string): string {
+ // \p{L} matches any Unicode letter — covers cyrillic, arabic, CJK, etc.
+ const letter = name.match(/\p{L}/u)?.[0];
+ if (letter) return letter.toUpperCase();
+ const alnum = name.match(/[0-9]/)?.[0];
+ return alnum ?? '?';
+}