import type { SubsonicArtist } from '../api/subsonicTypes';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ndListArtistsByRole } from '../api/navidromeBrowse';
import { LayoutGrid, List } from 'lucide-react';
import StarFilterButton from '../components/StarFilterButton';
import { usePlayerStore } from '../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 '../hooks/useResizeClientHeight';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch';
import { useComposersBrowseFilters, type ComposerBrowseScrollSnapshot } from '../hooks/useComposersBrowseFilters';
import { useComposersBrowseScrollRestore } from '../hooks/useComposersBrowseScrollRestore';
import { useArtistsBrowseScrollReset } from '../hooks/useArtistsBrowseScrollReset';
import { useNavigateToComposer } from '../hooks/useNavigateToComposer';
import { peekComposerBrowseScrollRestore } from '../store/composerBrowseSessionStore';
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
import { readComposerBrowseRestore } from '../utils/navigation/albumDetailNavigation';
import { filterArtistsWithRoleAlbumCredits } from '../utils/library/composerBrowse';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
import { useClientSliceInfiniteScroll } from '../hooks/useClientSliceInfiniteScroll';
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
import InpageScrollSentinel from '../components/InpageScrollSentinel';
const ALL_SENTINEL = 'ALL';
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 (
{nameInitial(artist.name)}
);
}
export default function Composers() {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const [composers, setComposers] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<'unsupported' | 'transient' | null>(null);
const [reloadTick, setReloadTick] = useState(0);
const scrollSnapshotRef = useRef({ scrollTop: 0, visibleCount: 0 });
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const restoreVisibleCountRef = useRef(
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 filtered = useMemo(() => {
let out = composerSource;
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 (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]);
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, 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 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),
[],
);
const composerListOverscan = Math.max(
12,
Math.ceil(composersInpageScrollHeight / COMPOSER_LIST_ROW_EST),
);
const composerListWrapRef = useRef(null);
const composerListScrollMargin = useVirtualizerScrollMargin(
composerListWrapRef,
getInpageScrollElement,
{
active: !composerBrowsePlainLayout && viewMode === 'list',
deps: [composerListFlatRows.length],
},
);
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 (
{t('composers.title')}
{loadError === 'unsupported' ? t('composers.unsupported') : t('composers.loadFailed')}
{loadError === 'transient' && (
)}
);
}
return (
{t('composers.title')}
{textSearchLoading && (
)}
{ALPHABET.map(l => (
))}
{loading && }
{!loading && viewMode === 'grid' && (
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 => (
navigateToComposer(artist.id)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer');
}}
>
{artist.name}
{artist.albumCount != null && (
{t('composers.involvedIn', { count: artist.albumCount })}
)}
)}
/>
)}
{!loading && viewMode === 'list' && (
composerBrowsePlainLayout ? (
<>
{letters.map(letter => (
{letter}
{groups[letter].map(artist => (
))}
))}
>
) : (
{composerListVirtualizer.getVirtualItems().map(vi => {
const row = composerListFlatRows[vi.index];
if (!row) return null;
if (row.kind === 'letter') {
return (
{row.letter}
);
}
const artist = row.artist;
return (
);
})}
)
)}
{!loading && hasMore && (
)}
{!loading && filtered.length === 0 && (
{t('composers.notFound')}
)}
);
}