mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(composers): co-locate composers feature into features/composers
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
|
||||
import { isComposerDetailPath } from '@/features/album';
|
||||
import {
|
||||
DEFAULT_COMPOSER_BROWSE_RETURN_STATE,
|
||||
type ComposerBrowseReturnState,
|
||||
type ComposerBrowseViewMode,
|
||||
isComposersBrowsePath,
|
||||
useComposerBrowseSessionStore,
|
||||
} from '@/features/composers/store/composerBrowseSessionStore';
|
||||
import { shouldRestoreComposerBrowseSession } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
|
||||
|
||||
export type ComposerBrowseScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
|
||||
function returnStateForNavigation(
|
||||
serverId: string,
|
||||
navigationType: NavigationType,
|
||||
locationState: unknown,
|
||||
): ComposerBrowseReturnState {
|
||||
if (!shouldRestoreComposerBrowseSession(navigationType, locationState) || !serverId) {
|
||||
return DEFAULT_COMPOSER_BROWSE_RETURN_STATE;
|
||||
}
|
||||
return (
|
||||
useComposerBrowseSessionStore.getState().peekReturnStash(serverId)
|
||||
?? DEFAULT_COMPOSER_BROWSE_RETURN_STATE
|
||||
);
|
||||
}
|
||||
|
||||
export function useComposersBrowseFilters(
|
||||
serverId: string,
|
||||
scrollSnapshotRef?: RefObject<ComposerBrowseScrollSnapshot>,
|
||||
) {
|
||||
const navigationType = useNavigationType();
|
||||
const location = useLocation();
|
||||
|
||||
const [letterFilter, setLetterFilter] = useState(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
|
||||
);
|
||||
const [starredOnly, setStarredOnly] = useState(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).starredOnly,
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<ComposerBrowseViewMode>(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).viewMode,
|
||||
);
|
||||
|
||||
const browseStateRef = useRef<ComposerBrowseReturnState>(DEFAULT_COMPOSER_BROWSE_RETURN_STATE);
|
||||
const restoredFromStashRef = useRef(false);
|
||||
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
browseStateRef.current = {
|
||||
filter: useLiveSearchScopeStore.getState().query,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
restoredFromStashRef.current = false;
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
|
||||
if (shouldRestoreComposerBrowseSession(navigationType, location.state)) {
|
||||
restoredFromStashRef.current = true;
|
||||
const restored = useComposerBrowseSessionStore.getState().peekReturnStash(serverId);
|
||||
if (restored) {
|
||||
useLiveSearchScopeStore.getState().setQuery(restored.filter);
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLetterFilter(restored.letterFilter);
|
||||
setStarredOnly(restored.starredOnly);
|
||||
setViewMode(restored.viewMode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredFromStashRef.current) return;
|
||||
|
||||
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
useLiveSearchScopeStore.getState().setQuery('');
|
||||
setLetterFilter(DEFAULT_COMPOSER_BROWSE_RETURN_STATE.letterFilter);
|
||||
setStarredOnly(false);
|
||||
setViewMode('grid');
|
||||
}, [serverId, navigationType, location.state]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!serverId) return;
|
||||
const path = window.location.pathname;
|
||||
if (isComposerDetailPath(path)) {
|
||||
// Read at cleanup time on purpose: we want the scroll snapshot as it is
|
||||
// at navigation-away. Copying it at effect setup would stash a stale value.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const snapshot = scrollSnapshotRef?.current;
|
||||
useComposerBrowseSessionStore.getState().stashReturnState(serverId, {
|
||||
...browseStateRef.current,
|
||||
scrollTop: snapshot?.scrollTop,
|
||||
visibleCount: snapshot?.visibleCount,
|
||||
});
|
||||
} else if (!isComposersBrowsePath(path)) {
|
||||
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
}
|
||||
};
|
||||
}, [serverId, scrollSnapshotRef]);
|
||||
|
||||
return {
|
||||
letterFilter,
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
setStarredOnly,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
|
||||
import {
|
||||
peekComposerBrowseScrollRestore,
|
||||
useComposerBrowseSessionStore,
|
||||
} from '@/features/composers/store/composerBrowseSessionStore';
|
||||
import { shouldRestoreComposerBrowseSession } from '@/utils/navigation/albumDetailNavigation';
|
||||
|
||||
type PendingScroll = {
|
||||
scrollTop: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
|
||||
export type UseComposersBrowseScrollRestoreArgs = {
|
||||
serverId: string;
|
||||
scrollBodyEl: HTMLElement | null;
|
||||
visibleCount: number;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
};
|
||||
|
||||
export type UseComposersBrowseScrollRestoreResult = {
|
||||
isScrollRestorePending: boolean;
|
||||
};
|
||||
|
||||
function readPendingScrollRestore(
|
||||
serverId: string,
|
||||
navigationType: NavigationType,
|
||||
locationState: unknown,
|
||||
): PendingScroll | null {
|
||||
if (!shouldRestoreComposerBrowseSession(navigationType, locationState) || !serverId) return null;
|
||||
return peekComposerBrowseScrollRestore(serverId);
|
||||
}
|
||||
|
||||
/** Restore Composers in-page scroll after returning from composer detail. */
|
||||
export function useComposersBrowseScrollRestore({
|
||||
serverId,
|
||||
scrollBodyEl,
|
||||
visibleCount,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
}: UseComposersBrowseScrollRestoreArgs): UseComposersBrowseScrollRestoreResult {
|
||||
const navigationType = useNavigationType();
|
||||
const location = useLocation();
|
||||
const initRef = useRef(false);
|
||||
const pendingRef = useRef<PendingScroll | null>(null);
|
||||
const doneRef = useRef(false);
|
||||
|
||||
// React Compiler refs rule: ref used as a once-only init guard (checked before first assignment); not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
if (!initRef.current) {
|
||||
initRef.current = true;
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
pendingRef.current = readPendingScrollRestore(serverId, navigationType, location.state);
|
||||
}
|
||||
|
||||
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
|
||||
() => readPendingScrollRestore(serverId, navigationType, location.state) !== null,
|
||||
);
|
||||
|
||||
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
useLayoutEffect(() => {
|
||||
const pending = pendingRef.current;
|
||||
if (doneRef.current || !pending) return;
|
||||
if (!scrollBodyEl || loading) return;
|
||||
|
||||
const needsMore = visibleCount < pending.visibleCount && hasMore;
|
||||
if (needsMore) {
|
||||
if (!loadingMore) loadMore();
|
||||
return;
|
||||
}
|
||||
if (loadingMore) return;
|
||||
|
||||
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
scrollBodyEl.scrollTop = pending.scrollTop;
|
||||
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
pendingRef.current = null;
|
||||
doneRef.current = true;
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsScrollRestorePending(false);
|
||||
useComposerBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
}, [
|
||||
scrollBodyEl,
|
||||
visibleCount,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
return { isScrollRestorePending };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { navigateToComposerDetail } from '@/utils/navigation/albumDetailNavigation';
|
||||
|
||||
/** Navigate to composer detail, remembering the current page for the back button. */
|
||||
export function useNavigateToComposer() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return useCallback(
|
||||
(composerId: string, opts?: { search?: string }) => {
|
||||
navigateToComposerDetail(navigate, location, composerId, opts);
|
||||
},
|
||||
[navigate, location],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Composers feature — the composer overview (`Composers`) and per-composer
|
||||
* works (`ComposerDetail`) browse pages, their browse-filter / scroll-restore /
|
||||
* navigation hooks, and the composer browse-session store. The pages are
|
||||
* lazy-loaded by the router via their deep paths, so they are not re-exported
|
||||
* here. Only the live-search scope predicate is consumed cross-feature.
|
||||
*/
|
||||
export { isComposersBrowsePath } from './store/composerBrowseSessionStore';
|
||||
@@ -0,0 +1,292 @@
|
||||
import { star, unstar } from '@/lib/api/subsonicStarRating';
|
||||
import { getArtist, getArtistInfo } from '@/lib/api/subsonicArtists';
|
||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ndListAlbumsByArtistRole } from '@/lib/api/navidromeBrowse';
|
||||
import { AlbumCard } from '@/features/album';
|
||||
import { ArtistHeroCover } from '@/cover/artistHero';
|
||||
import { coverArtRef } from '@/cover/ref';
|
||||
import { useCoverLightboxSrc } from '@/cover/lightbox';
|
||||
import { ArrowLeft, Users, Heart, Feather, Share2 } from 'lucide-react';
|
||||
import WikipediaIcon from '@/components/WikipediaIcon';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { sanitizeHtml } from '@/lib/util/sanitizeHtml';
|
||||
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '@/cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
|
||||
|
||||
export default function ComposerDetail() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [info, setInfo] = useState<SubsonicArtistInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [bioExpanded, setBioExpanded] = useState(false);
|
||||
const [headerCoverFailed, setHeaderCoverFailed] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
// Subsonic `getArtist.view` only follows AlbumArtist relations, so for a
|
||||
// composer-only credit it returns the right name + bio but zero albums.
|
||||
// Native API `/api/album?_filters={"role_composer_id":"<id>"}` is the only
|
||||
// endpoint that walks the participants graph for non-AlbumArtist roles.
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
getArtist(id).catch(() => null),
|
||||
ndListAlbumsByArtistRole(id, 'composer', 0, 500).catch(err => {
|
||||
console.warn('[psysonic] composer albums load failed:', err);
|
||||
return [] as SubsonicAlbum[];
|
||||
}),
|
||||
]).then(([artistData, composerAlbums]) => {
|
||||
if (cancelled) return;
|
||||
if (artistData) {
|
||||
setArtist(artistData.artist);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
}
|
||||
setAlbums(composerAlbums);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [id, musicLibraryFilterVersion]);
|
||||
|
||||
// Bio + Last.fm image — Last.fm matches by name, so well-known composers
|
||||
// (Bach, Mozart, Chopin) hit; obscure ones get an empty bio. Failure is
|
||||
// silent — we just show the initial-letter avatar instead.
|
||||
// Bio is library-independent (Last.fm is global), so this effect tracks
|
||||
// [id] only — keeping the bio visible across music-library scope changes.
|
||||
// The info reset lives here, not in the load effect, or a scope bump would
|
||||
// wipe the bio without re-fetching it.
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setInfo(null);
|
||||
getArtistInfo(id, { similarArtistCount: 0 })
|
||||
.then(i => { if (!cancelled) setInfo(i ?? null); })
|
||||
.catch(() => { if (!cancelled) setInfo(null); });
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setHeaderCoverFailed(false);
|
||||
}, [id]);
|
||||
|
||||
const coverId = artist?.coverArt || artist?.id || '';
|
||||
const coverFallbackRef = useMemo(
|
||||
() => (coverId ? coverArtRef(coverId) : null),
|
||||
[coverId],
|
||||
);
|
||||
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverFallbackRef, {
|
||||
alt: artist?.name ?? t('composerDetail.unknownComposer'),
|
||||
});
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!artist) return;
|
||||
const next = !isStarred;
|
||||
setIsStarred(next);
|
||||
setStarredOverride(artist.id, next);
|
||||
try {
|
||||
const meta = {
|
||||
serverId: artist.serverId,
|
||||
name: artist.name,
|
||||
albumCount: artist.albumCount,
|
||||
};
|
||||
if (next) await star(artist.id, 'artist', meta);
|
||||
else await unstar(artist.id, 'artist', meta);
|
||||
} catch (err) {
|
||||
console.warn('[psysonic] composer star failed:', err);
|
||||
setIsStarred(!next);
|
||||
setStarredOverride(artist.id, !next);
|
||||
}
|
||||
};
|
||||
|
||||
const openLink = (url: string, key: string) => {
|
||||
setOpenedLink(key);
|
||||
open(url).catch(() => {});
|
||||
setTimeout(() => setOpenedLink(null), 2500);
|
||||
};
|
||||
|
||||
const handleShareComposer = async () => {
|
||||
if (!id || !artist) return;
|
||||
try {
|
||||
const ok = await copyEntityShareLink('composer', artist.id);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
} catch {
|
||||
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Real not-found only when neither metadata nor works came back. If getArtist
|
||||
// failed but ndListAlbumsByArtistRole succeeded, render a degraded header so
|
||||
// a flaky Subsonic endpoint doesn't hide the works the user came here for.
|
||||
if (!artist && albums.length === 0) {
|
||||
return (
|
||||
<div className="content-body">
|
||||
<div style={{ textAlign: 'center', padding: '4rem', color: 'var(--text-muted)' }}>
|
||||
{t('composerDetail.notFound')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = artist?.name || t('composerDetail.unknownComposer');
|
||||
const wikiUrl = artist?.name
|
||||
? `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`
|
||||
: '';
|
||||
|
||||
const hasHeroImage = Boolean(
|
||||
info?.largeImageUrl || info?.mediumImageUrl || coverId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<ArrowLeft size={16} /> <span>{t('composerDetail.back')}</span>
|
||||
</button>
|
||||
|
||||
{lightbox}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
||||
{hasHeroImage && !headerCoverFailed && id ? (
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
onClick={openLightbox}
|
||||
aria-label={displayName}
|
||||
>
|
||||
<ArtistHeroCover
|
||||
artistId={id}
|
||||
artistInfo={info}
|
||||
coverFallback={coverFallbackRef}
|
||||
displayCssPx={300}
|
||||
surface="sparse"
|
||||
alt={displayName}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setHeaderCoverFailed(true)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Feather size={64} color="var(--text-muted)" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-meta">
|
||||
<h1 className="page-title" style={{ fontSize: '3rem', marginBottom: '0.25rem' }}>
|
||||
{displayName}
|
||||
</h1>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Users size={14} />
|
||||
<span>{t('composerDetail.workCount', { count: albums.length })}</span>
|
||||
</div>
|
||||
|
||||
<div className="compact-action-bar" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{wikiUrl && (
|
||||
<div className="artist-detail-links">
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')} aria-label={t('artistDetail.wikipediaTooltip')} data-tooltip={t('artistDetail.wikipediaTooltip')}>
|
||||
<WikipediaIcon size={14} />
|
||||
<span className="compact-btn-label">{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{artist && (
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
<span className="compact-btn-label">{t('artistDetail.favorite')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{artist && (
|
||||
<button
|
||||
type="button"
|
||||
className="artist-ext-link"
|
||||
onClick={handleShareComposer}
|
||||
aria-label={t('composerDetail.shareComposer')}
|
||||
data-tooltip={t('composerDetail.shareComposer')}
|
||||
>
|
||||
<Share2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{info?.biography && (
|
||||
<div className="np-info-card artist-bio-card" style={{ marginTop: '2rem' }}>
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('composerDetail.about')}</h3>
|
||||
</div>
|
||||
<div className="np-artist-bio-row">
|
||||
<div className="np-bio-wrap">
|
||||
<div
|
||||
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(info.biography) }}
|
||||
/>
|
||||
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
|
||||
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
{t('composerDetail.works')}
|
||||
</h2>
|
||||
{albums.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
||||
{t('composerDetail.noWorks')}
|
||||
</div>
|
||||
) : (
|
||||
<VirtualCardGrid
|
||||
items={albums}
|
||||
itemKey={(a, i) => `${a.id}-${i}`}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ndListArtistsByRole } from '@/lib/api/navidromeBrowse';
|
||||
import { LayoutGrid, List } from 'lucide-react';
|
||||
import StarFilterButton from '@/components/StarFilterButton';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID, COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
|
||||
import { useElementClientHeightById, useElementClientHeightForElement } from '@/lib/hooks/useResizeClientHeight';
|
||||
import { useMainstageInpageHeaderTight } from '@/hooks/useMainstageInpageHeaderTight';
|
||||
import { useBrowseArtistTextSearch } from '@/features/artist';
|
||||
import { useComposersBrowseFilters, type ComposerBrowseScrollSnapshot } from '@/features/composers/hooks/useComposersBrowseFilters';
|
||||
import { useComposersBrowseScrollRestore } from '@/features/composers/hooks/useComposersBrowseScrollRestore';
|
||||
import { useArtistsBrowseScrollReset } from '@/features/artist';
|
||||
import { useNavigateToComposer } from '@/features/composers/hooks/useNavigateToComposer';
|
||||
import { peekComposerBrowseScrollRestore } from '@/features/composers/store/composerBrowseSessionStore';
|
||||
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
|
||||
import { readComposerBrowseRestore } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { filterArtistsWithRoleAlbumCredits } from '@/lib/library/composerBrowse';
|
||||
import { ALL_SENTINEL, artistLetterBucket } from '@/features/artist';
|
||||
import { useLibraryIgnoredArticles } from '@/hooks/useLibraryIgnoredArticles';
|
||||
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
|
||||
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
|
||||
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||
import { useVirtualizerScrollMargin } from '@/lib/hooks/useVirtualizerScrollMargin';
|
||||
import { useClientSliceInfiniteScroll } from '@/hooks/useClientSliceInfiniteScroll';
|
||||
import { useInpageScrollViewport } from '@/hooks/useInpageScrollViewport';
|
||||
import InpageScrollSentinel from '@/components/InpageScrollSentinel';
|
||||
|
||||
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
|
||||
|
||||
const COMPOSER_LIST_LETTER_ROW_EST = 48;
|
||||
const COMPOSER_LIST_ROW_EST = 64;
|
||||
const COMPOSER_LIST_LAST_IN_LETTER_EST = 88;
|
||||
|
||||
type ComposerListFlatRow =
|
||||
| { kind: 'letter'; letter: string }
|
||||
| { kind: 'artist'; artist: SubsonicArtist; isLastInLetter: boolean };
|
||||
|
||||
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 {
|
||||
const letter = name.match(/\p{L}/u)?.[0];
|
||||
if (letter) return letter.toUpperCase();
|
||||
const alnum = name.match(/[0-9]/)?.[0];
|
||||
return alnum ?? '?';
|
||||
}
|
||||
|
||||
// Composer libraries don't carry useful imagery (classical tagging conventions
|
||||
// rarely populate cover/photo fields, and Navidrome's role-listing endpoint
|
||||
// returns no image URLs anyway). The grid is text-only — large name plus
|
||||
// participation count. The list view still draws a coloured initial circle so
|
||||
// it doesn't collapse to a row of bare names.
|
||||
function ComposerRowAvatar({ artist }: { artist: SubsonicArtist }) {
|
||||
const color = nameColor(artist.name);
|
||||
return (
|
||||
<div
|
||||
className="artist-avatar artist-avatar-initial"
|
||||
style={{ background: color, border: 0 }}
|
||||
>
|
||||
<span style={{ color: 'var(--text-on-accent)', fontWeight: 800 }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Composers() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { t } = useTranslation();
|
||||
const [composers, setComposers] = useState<SubsonicArtist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<'unsupported' | 'transient' | null>(null);
|
||||
const [reloadTick, setReloadTick] = useState(0);
|
||||
|
||||
const scrollSnapshotRef = useRef<ComposerBrowseScrollSnapshot>({ scrollTop: 0, visibleCount: 0 });
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const restoreVisibleCountRef = useRef<number | undefined>(
|
||||
peekComposerBrowseScrollRestore(serverId)?.visibleCount,
|
||||
);
|
||||
|
||||
const {
|
||||
letterFilter,
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
setStarredOnly,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
} = useComposersBrowseFilters(serverId, scrollSnapshotRef);
|
||||
|
||||
const composersSearchQuery = useScopedBrowseSearchQuery('composers');
|
||||
|
||||
// Full composer catalog is loaded via ndListArtistsByRole (Navidrome role stats,
|
||||
// correctly split multi-name credits). Generic artist index/search3 returns joined
|
||||
// performer strings — do not race that path on this page (report: zunoz, v1.47 RC3).
|
||||
const { textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
|
||||
composersSearchQuery,
|
||||
false,
|
||||
serverId,
|
||||
'composers_browse',
|
||||
);
|
||||
const composerSource = composers;
|
||||
const textSearchActive = composersSearchQuery.trim().length > 0;
|
||||
const composerBrowsePlainLayout =
|
||||
perfFlags.disableMainstageVirtualLists
|
||||
|| textSearchActive;
|
||||
|
||||
// Compact tiles + initial-letter only → 200 per page is comfortable.
|
||||
const PAGE_SIZE = 200;
|
||||
const {
|
||||
scrollBodyRef,
|
||||
scrollBodyEl,
|
||||
bindScrollBody: bindComposersScrollBody,
|
||||
getScrollRoot,
|
||||
} = useInpageScrollViewport();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const navigateToComposer = useNavigateToComposer();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
|
||||
const {
|
||||
visibleCount,
|
||||
loadingMore,
|
||||
bindSentinel,
|
||||
loadMore: sliceLoadMore,
|
||||
} = useClientSliceInfiniteScroll({
|
||||
pageSize: PAGE_SIZE,
|
||||
resetDeps: [composersSearchQuery, letterFilter, starredOnly, viewMode, composerSource, serverId],
|
||||
getScrollRoot,
|
||||
scrollRootEl: scrollBodyEl,
|
||||
restoreDisplayCount: restoreVisibleCountRef.current,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
// One large fetch — same shape as `getArtists()`. Server-side pagination is
|
||||
// an option but Symfonium-style classical libs rarely exceed a few thousand
|
||||
// composers, and a single round-trip beats N infinite-scroll calls when the
|
||||
// list is alphabetised + filtered locally.
|
||||
ndListArtistsByRole('composer', 0, 10000)
|
||||
.then(data => {
|
||||
if (cancelled) return;
|
||||
setComposers(filterArtistsWithRoleAlbumCredits(data));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
const msg = String(err);
|
||||
console.warn('[psysonic] composers list failed:', err);
|
||||
// "Unsupported" only when the server explicitly rejects the request
|
||||
// shape. Network-layer errors (TLS handshake EOF, timeouts, 5xx) get
|
||||
// a retry button instead of a misleading "needs Navidrome 0.55+".
|
||||
const looksUnsupported = /\b(400|404|422|501)\b/.test(msg);
|
||||
setLoadError(looksUnsupported ? 'unsupported' : 'transient');
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [musicLibraryFilterVersion, reloadTick]);
|
||||
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const ignoredArticles = useLibraryIgnoredArticles(serverId);
|
||||
const filtered = useMemo(() => {
|
||||
let out = composerSource;
|
||||
if (letterFilter !== ALL_SENTINEL) {
|
||||
out = out.filter(a => artistLetterBucket(a, ignoredArticles) === letterFilter);
|
||||
}
|
||||
if (effectiveFilter) {
|
||||
const needle = effectiveFilter.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;
|
||||
}, [composerSource, letterFilter, effectiveFilter, starredOnly, starredOverrides, ignoredArticles]);
|
||||
|
||||
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
scrollSnapshotRef.current = {
|
||||
scrollTop: scrollBodyEl?.scrollTop ?? 0,
|
||||
visibleCount,
|
||||
};
|
||||
|
||||
const { isScrollRestorePending } = useComposersBrowseScrollRestore({
|
||||
serverId,
|
||||
scrollBodyEl,
|
||||
visibleCount,
|
||||
loading: loading || textSearchLoading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore: sliceLoadMore,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrollRestorePending || !readComposerBrowseRestore(location.state)) return;
|
||||
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
|
||||
|
||||
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 key = artistLetterBucket(a, ignoredArticles);
|
||||
if (!g[key]) g[key] = [];
|
||||
g[key].push(a);
|
||||
}
|
||||
return { groups: g, letters: Object.keys(g).sort() };
|
||||
}, [visible, viewMode, ignoredArticles]);
|
||||
|
||||
const composerListFlatRows = useMemo((): ComposerListFlatRow[] => {
|
||||
if (viewMode !== 'list') return [];
|
||||
const out: ComposerListFlatRow[] = [];
|
||||
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 composersInpageScrollHeight = useElementClientHeightForElement(
|
||||
scrollBodyEl,
|
||||
mainScrollViewportHeight,
|
||||
);
|
||||
|
||||
const getInpageScrollElement = useCallback(
|
||||
() =>
|
||||
scrollBodyRef.current
|
||||
?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null),
|
||||
[scrollBodyRef],
|
||||
);
|
||||
|
||||
const composerListOverscan = Math.max(
|
||||
12,
|
||||
Math.ceil(composersInpageScrollHeight / COMPOSER_LIST_ROW_EST),
|
||||
);
|
||||
|
||||
const composerListWrapRef = useRef<HTMLDivElement>(null);
|
||||
const composerListScrollMargin = useVirtualizerScrollMargin(
|
||||
composerListWrapRef,
|
||||
getInpageScrollElement,
|
||||
{
|
||||
active: !composerBrowsePlainLayout && viewMode === 'list',
|
||||
deps: [composerListFlatRows.length],
|
||||
},
|
||||
);
|
||||
|
||||
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const composerListVirtualizer = useVirtualizer({
|
||||
count:
|
||||
composerBrowsePlainLayout || viewMode !== 'list' ? 0 : composerListFlatRows.length,
|
||||
getScrollElement: getInpageScrollElement,
|
||||
estimateSize: index => {
|
||||
const row = composerListFlatRows[index];
|
||||
if (!row) return COMPOSER_LIST_ROW_EST;
|
||||
if (row.kind === 'letter') return COMPOSER_LIST_LETTER_ROW_EST;
|
||||
return row.isLastInLetter ? COMPOSER_LIST_LAST_IN_LETTER_EST : COMPOSER_LIST_ROW_EST;
|
||||
},
|
||||
getItemKey: index => {
|
||||
const row = composerListFlatRows[index];
|
||||
if (!row) return index;
|
||||
if (row.kind === 'letter') return `letter:${row.letter}`;
|
||||
return `composer:${row.artist.id}`;
|
||||
},
|
||||
overscan: composerListOverscan,
|
||||
scrollMargin: composerListScrollMargin,
|
||||
});
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
||||
composersSearchQuery,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const browseScrollResetKey = [
|
||||
composersSearchQuery,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
serverId,
|
||||
musicLibraryFilterVersion,
|
||||
].join('\0');
|
||||
|
||||
useArtistsBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey: browseScrollResetKey,
|
||||
viewMode,
|
||||
listVirtualize: !composerBrowsePlainLayout,
|
||||
listVirtualizer: composerListVirtualizer,
|
||||
});
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div className="page-sticky-header">
|
||||
<h1 className="page-title">{t('composers.title')}</h1>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
||||
{loadError === 'unsupported' ? t('composers.unsupported') : t('composers.loadFailed')}
|
||||
{loadError === 'transient' && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<button className="btn btn-surface" onClick={() => setReloadTick(t => t + 1)}>
|
||||
{t('composers.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
|
||||
<div className="mainstage-inpage-toolbar">
|
||||
<div className="page-sticky-header">
|
||||
<div className="mainstage-inpage-toolbar-row">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('composers.title')}</h1>
|
||||
{textSearchLoading && (
|
||||
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} />
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.gridView')}
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.listView')}
|
||||
>
|
||||
<List size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mainstage-inpage-toolbar-alpha-row">
|
||||
{ALPHABET.map(l => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLetterFilter(l)}
|
||||
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
|
||||
>
|
||||
{l === ALL_SENTINEL ? t('artists.all') : l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OverlayScrollArea
|
||||
className="mainstage-inpage-scroll"
|
||||
viewportClassName="mainstage-inpage-scroll__viewport"
|
||||
viewportId={COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
viewportRef={bindComposersScrollBody}
|
||||
railInset="panel"
|
||||
measureDeps={[
|
||||
loading,
|
||||
viewMode,
|
||||
visible.length,
|
||||
composerListFlatRows.length,
|
||||
filtered.length,
|
||||
hasMore,
|
||||
]}
|
||||
>
|
||||
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
|
||||
|
||||
{!loading && viewMode === 'grid' && (
|
||||
<VirtualCardGrid
|
||||
items={visible}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="composer"
|
||||
disableVirtualization={composerBrowsePlainLayout}
|
||||
layoutSignal={visible.length}
|
||||
wrapClassName="composer-grid-wrap"
|
||||
gridGap="var(--space-2)"
|
||||
scrollRootId={COMPOSERS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
renderItem={artist => (
|
||||
<div
|
||||
className="composer-card"
|
||||
onClick={() => navigateToComposer(artist.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
|
||||
}}
|
||||
>
|
||||
<div className="composer-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="composer-card-meta">
|
||||
{t('composers.involvedIn', { count: artist.albumCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && viewMode === 'list' && (
|
||||
composerBrowsePlainLayout ? (
|
||||
<>
|
||||
{letters.map(letter => (
|
||||
<div key={letter} style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 className="letter-heading">{letter}</h3>
|
||||
<div className="artist-list">
|
||||
{groups[letter].map(artist => (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigateToComposer(artist.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
|
||||
}}
|
||||
id={`composer-${artist.id}`}
|
||||
>
|
||||
<ComposerRowAvatar artist={artist} />
|
||||
<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 ref={composerListWrapRef} style={{ position: 'relative', width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
height: composerListFlatRows.length === 0 ? 0 : composerListVirtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{composerListVirtualizer.getVirtualItems().map(vi => {
|
||||
const row = composerListFlatRows[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 - composerListScrollMargin}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 - composerListScrollMargin}px)`,
|
||||
paddingBottom: row.isLastInLetter ? '1.5rem' : undefined,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="artist-row"
|
||||
onClick={() => navigateToComposer(artist.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
|
||||
}}
|
||||
id={`composer-${artist.id}`}
|
||||
>
|
||||
<ComposerRowAvatar artist={artist} />
|
||||
<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 && (
|
||||
<InpageScrollSentinel bindSentinel={bindSentinel} loading={loadingMore} />
|
||||
)}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
||||
{t('composers.notFound')}
|
||||
</div>
|
||||
)}
|
||||
</OverlayScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { create } from 'zustand';
|
||||
import { ALL_SENTINEL } from '@/features/artist';
|
||||
|
||||
export type ComposerBrowseViewMode = 'grid' | 'list';
|
||||
|
||||
/** Browse state restored when returning to Composers via back from composer detail. */
|
||||
export interface ComposerBrowseReturnState {
|
||||
filter: string;
|
||||
letterFilter: string;
|
||||
starredOnly: boolean;
|
||||
viewMode: ComposerBrowseViewMode;
|
||||
scrollTop?: number;
|
||||
visibleCount?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPOSER_BROWSE_RETURN_STATE: ComposerBrowseReturnState = {
|
||||
filter: '',
|
||||
letterFilter: ALL_SENTINEL,
|
||||
starredOnly: false,
|
||||
viewMode: 'grid',
|
||||
};
|
||||
|
||||
interface ComposerBrowseSessionStore {
|
||||
returnStashByServer: Record<string, ComposerBrowseReturnState>;
|
||||
stashReturnState: (serverId: string, state: ComposerBrowseReturnState) => void;
|
||||
clearReturnStash: (serverId: string) => void;
|
||||
peekReturnStash: (serverId: string) => ComposerBrowseReturnState | null;
|
||||
}
|
||||
|
||||
export const useComposerBrowseSessionStore = create<ComposerBrowseSessionStore>((set, get) => ({
|
||||
returnStashByServer: {},
|
||||
|
||||
stashReturnState: (serverId, state) => {
|
||||
if (!serverId) return;
|
||||
set((s) => ({
|
||||
returnStashByServer: {
|
||||
...s.returnStashByServer,
|
||||
[serverId]: {
|
||||
filter: state.filter,
|
||||
letterFilter: state.letterFilter,
|
||||
starredOnly: state.starredOnly,
|
||||
viewMode: state.viewMode,
|
||||
...(typeof state.scrollTop === 'number' ? { scrollTop: state.scrollTop } : {}),
|
||||
...(typeof state.visibleCount === 'number' ? { visibleCount: state.visibleCount } : {}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
clearReturnStash: (serverId) => {
|
||||
if (!serverId) return;
|
||||
const next = { ...get().returnStashByServer };
|
||||
delete next[serverId];
|
||||
set({ returnStashByServer: next });
|
||||
},
|
||||
|
||||
peekReturnStash: (serverId) => {
|
||||
if (!serverId) return null;
|
||||
const stash = get().returnStashByServer[serverId];
|
||||
if (!stash) return null;
|
||||
return {
|
||||
filter: stash.filter,
|
||||
letterFilter: stash.letterFilter,
|
||||
starredOnly: stash.starredOnly,
|
||||
viewMode: stash.viewMode,
|
||||
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
|
||||
...(typeof stash.visibleCount === 'number' ? { visibleCount: stash.visibleCount } : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
export function peekComposerBrowseScrollRestore(
|
||||
serverId: string,
|
||||
): { scrollTop: number; visibleCount: number } | null {
|
||||
const stash = useComposerBrowseSessionStore.getState().peekReturnStash(serverId);
|
||||
if (!stash) return null;
|
||||
if (typeof stash.scrollTop !== 'number' || typeof stash.visibleCount !== 'number') return null;
|
||||
return {
|
||||
scrollTop: Math.max(0, stash.scrollTop),
|
||||
visibleCount: Math.max(0, stash.visibleCount),
|
||||
};
|
||||
}
|
||||
|
||||
/** True when pathname is the Composers browse route (`/composers`). */
|
||||
export function isComposersBrowsePath(pathname: string): boolean {
|
||||
return pathname === '/composers';
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { LiveSearchScope } from '@/store/liveSearchScopeStore';
|
||||
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album';
|
||||
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
|
||||
import { isArtistsBrowsePath } from '@/features/artist';
|
||||
import { isComposersBrowsePath } from '@/store/composerBrowseSessionStore';
|
||||
import { isComposersBrowsePath } from '@/features/composers';
|
||||
|
||||
export const SCOPE_NAV_ITEM: Record<LiveSearchScope, keyof typeof ALL_NAV_ITEMS> = {
|
||||
artists: 'artists',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom';
|
||||
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album';
|
||||
import { isArtistsBrowsePath } from '@/features/artist';
|
||||
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
|
||||
import { isComposersBrowsePath } from '@/store/composerBrowseSessionStore';
|
||||
import { isComposersBrowsePath } from '@/features/composers';
|
||||
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
|
||||
|
||||
/** Keep scope badge in sync with browse routes; clear field text when leaving browse. */
|
||||
|
||||
Reference in New Issue
Block a user