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 ( + + ); +} + +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 => ( - - ))} -
-
- ))} - - ) : ( -
-
- {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 ( -
- -
- ); - })} -
-
- ) + )} {!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 ?? '?'; +}