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,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 (
|
||||||
|
<div className="artist-card-avatar">
|
||||||
|
<CachedImage
|
||||||
|
src={coverSrc}
|
||||||
|
cacheKey={coverKey}
|
||||||
|
alt={artist.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||||
|
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className="artist-avatar">
|
||||||
|
<CachedImage
|
||||||
|
src={coverSrc}
|
||||||
|
cacheKey={coverKey}
|
||||||
|
alt={artist.name}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
|
||||||
|
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string>;
|
||||||
|
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 (
|
||||||
|
<div className="album-grid-wrap">
|
||||||
|
{visible.map(artist => (
|
||||||
|
<div
|
||||||
|
key={artist.id}
|
||||||
|
className={`artist-card${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}${selectionMode ? ' artist-card--selectable' : ''}`}
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={selectionMode && selectedIds.has(artist.id) ? {
|
||||||
|
outline: '2px solid var(--accent)',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
} : {}}
|
||||||
|
>
|
||||||
|
{selectionMode && (
|
||||||
|
<div className={`artist-card-select-check${selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
|
||||||
|
{selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div className="artist-card-name">{artist.name}</div>
|
||||||
|
{artist.albumCount != null && (
|
||||||
|
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<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>;
|
||||||
|
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,
|
||||||
|
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 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}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}px)`,
|
||||||
|
paddingBottom: row.isLastInLetter ? '1.5rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArtistListRow artist={artist} {...rowCommonProps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
+50
-337
@@ -1,109 +1,27 @@
|
|||||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
|
||||||
import { getArtists } from '../api/subsonicArtists';
|
import { getArtists } from '../api/subsonicArtists';
|
||||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
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 { 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 StarFilterButton from '../components/StarFilterButton';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import CachedImage from '../components/CachedImage';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||||
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
||||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||||
|
import {
|
||||||
const ALL_SENTINEL = 'ALL';
|
ALL_SENTINEL,
|
||||||
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
|
ALPHABET,
|
||||||
|
ARTIST_LIST_LAST_IN_LETTER_EST,
|
||||||
/** Virtual row height guesses — letter heading vs dense rows vs last row in section (group gap). */
|
ARTIST_LIST_LETTER_ROW_EST,
|
||||||
const ARTIST_LIST_LETTER_ROW_EST = 48;
|
ARTIST_LIST_ROW_EST,
|
||||||
const ARTIST_LIST_ROW_EST = 64;
|
} from '../utils/artistsHelpers';
|
||||||
const ARTIST_LIST_LAST_IN_LETTER_EST = 88;
|
import { useArtistsFiltering } from '../hooks/useArtistsFiltering';
|
||||||
|
import { useArtistsInfiniteScroll } from '../hooks/useArtistsInfiniteScroll';
|
||||||
type ArtistListFlatRow =
|
import { ArtistsGridView } from '../components/artists/ArtistsGridView';
|
||||||
| { kind: 'letter'; letter: string }
|
import { ArtistsListView } from '../components/artists/ArtistsListView';
|
||||||
| { 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 (
|
|
||||||
<div className="artist-card-avatar">
|
|
||||||
<CachedImage
|
|
||||||
src={coverSrc}
|
|
||||||
cacheKey={coverKey}
|
|
||||||
alt={artist.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
|
||||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="artist-avatar">
|
|
||||||
<CachedImage
|
|
||||||
src={coverSrc}
|
|
||||||
cacheKey={coverKey}
|
|
||||||
alt={artist.name}
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
|
|
||||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Artists() {
|
export default function Artists() {
|
||||||
const perfFlags = usePerfProbeFlags();
|
const perfFlags = usePerfProbeFlags();
|
||||||
@@ -117,9 +35,14 @@ export default function Artists() {
|
|||||||
|
|
||||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||||
const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
|
const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
|
||||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
const {
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
visibleCount,
|
||||||
const observerTarget = useRef<HTMLDivElement>(null);
|
loadingMore,
|
||||||
|
observerTarget,
|
||||||
|
} = useArtistsInfiniteScroll({
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
resetDeps: [filter, letterFilter, starredOnly, viewMode],
|
||||||
|
});
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||||
@@ -153,81 +76,9 @@ export default function Artists() {
|
|||||||
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
|
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [musicLibraryFilterVersion]);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const {
|
||||||
if (loadingMore) return;
|
filtered, visible, hasMore, groups, letters, artistListFlatRows,
|
||||||
setLoadingMore(true);
|
} = useArtistsFiltering({ artists, filter, letterFilter, starredOnly, visibleCount, viewMode });
|
||||||
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<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]);
|
|
||||||
|
|
||||||
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||||||
/** Mixed row heights; smallest typical step ≈ artist row — one viewport of extra indices each side. */
|
/** Mixed row heights; smallest typical step ≈ artist row — one viewport of extra indices each side. */
|
||||||
@@ -335,173 +186,35 @@ export default function Artists() {
|
|||||||
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
|
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
|
||||||
|
|
||||||
{!loading && viewMode === 'grid' && (
|
{!loading && viewMode === 'grid' && (
|
||||||
<div className="album-grid-wrap">
|
<ArtistsGridView
|
||||||
{visible.map(artist => (
|
visible={visible}
|
||||||
<div
|
selectionMode={selectionMode}
|
||||||
key={artist.id}
|
selectedIds={selectedIds}
|
||||||
className={`artist-card${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}${selectionMode ? ' artist-card--selectable' : ''}`}
|
selectedArtists={selectedArtists}
|
||||||
onClick={() => {
|
showArtistImages={showArtistImages}
|
||||||
if (selectionMode) {
|
toggleSelect={toggleSelect}
|
||||||
toggleSelect(artist.id);
|
navigate={navigate}
|
||||||
} else {
|
openContextMenu={openContextMenu}
|
||||||
navigate(`/artist/${artist.id}`);
|
t={t}
|
||||||
}
|
/>
|
||||||
}}
|
|
||||||
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 && (
|
|
||||||
<div className={`artist-card-select-check${selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
|
|
||||||
{selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<div className="artist-card-name">{artist.name}</div>
|
|
||||||
{artist.albumCount != null && (
|
|
||||||
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && viewMode === 'list' && (
|
{!loading && viewMode === 'list' && (
|
||||||
perfFlags.disableMainstageVirtualLists ? (
|
<ArtistsListView
|
||||||
<>
|
virtualized={!perfFlags.disableMainstageVirtualLists}
|
||||||
{letters.map(letter => (
|
groups={groups}
|
||||||
<div key={letter} style={{ marginBottom: '1.5rem' }}>
|
letters={letters}
|
||||||
<h3 className="letter-heading">{letter}</h3>
|
artistListFlatRows={artistListFlatRows}
|
||||||
<div className="artist-list">
|
artistListVirtualizer={artistListVirtualizer}
|
||||||
{groups[letter].map(artist => (
|
selectionMode={selectionMode}
|
||||||
<button
|
selectedIds={selectedIds}
|
||||||
key={artist.id}
|
selectedArtists={selectedArtists}
|
||||||
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
|
showArtistImages={showArtistImages}
|
||||||
onClick={() => {
|
toggleSelect={toggleSelect}
|
||||||
if (selectionMode) {
|
navigate={navigate}
|
||||||
toggleSelect(artist.id);
|
openContextMenu={openContextMenu}
|
||||||
} else {
|
t={t}
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div 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}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}px)`,
|
|
||||||
paddingBottom: row.isLastInLetter ? '1.5rem' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && hasMore && (
|
{!loading && hasMore && (
|
||||||
|
|||||||
@@ -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 ?? '?';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user