mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
* feat(composer): Browse by Composer page (issue #465) New library section listing every artist credited as composer on at least one track, with a detail page showing all works they're credited on in that role. Targeted at classical-music libraries where the "recording artist" tag carries the orchestra and the "composer" tag carries Bach / Mozart / Chopin. Hits Navidrome's native /api/artist?_filters={"role":"composer"} for the listing and /api/album?_filters={"role_composer_id":"…"} for the works grid — Subsonic getArtist only follows AlbumArtist relations and returns 0 albums for composer-only credits, so the native API is the only path that works. Requires Navidrome 0.55+ (uses library_artist.stats role aggregation); on older / pure-Subsonic servers the page shows a one-line capability banner. - Two new Tauri commands: nd_list_artists_by_role + nd_list_albums_by_artist_role, generic over participant role so conductor / lyricist / arranger pages are trivial to add later. - Composers grid: text-only compact tiles (name + participation count pulled from stats[role].albumCount). No avatars — composer libraries carry no useful imagery and the listing endpoint exposes no image URLs anyway. - ComposerDetail: hero with Last.fm bio (via getArtistInfo2) plus the full work grid, with a graceful fallback when the artist has no external info synced. - Sidebar entry default off (Feather icon) — opt-in for the niche classical use case. - nd_retry backoffs widened from [500] to [300, 800, 1800] — helps every nd_* call survive intermittent TLS-handshake-EOF errors that some reverse-proxy setups produce when keep-alive pools churn. - Distinguishes "server can't do this" (HTTP 400/404/422/501) from transient errors so the capability banner only fires when the server actually rejects the request shape; everything else gets a retry button. - i18n in all 8 supported locales. * fix(composer): address review feedback on detail page + role queries - Re-fetch ComposerDetail when music-library scope changes; previously the album grid stayed stale until navigation while the list refreshed. - Thread library_id through nd_list_artists_by_role and nd_list_albums_by_artist_role so role queries respect the active Navidrome library, matching the Subsonic musicFolderId already piped through libraryFilterParams(). - Fix CachedImage cache-key mismatch on ComposerDetail: a Last.fm header image was stored under the Subsonic cover-art key, aliasing cache entries and risking cross-source pollution. - Consolidate the two contradictory composer-imagery comments in Composers.tsx into a single accurate one (the older one referenced an Images toggle that was never implemented). - Align openLink toast duration with ArtistDetail (1500ms -> 2500ms). * fix(composer): keep bio across scope changes, add share, degrade gracefully Three remaining items from the latest review pass on the composer flow. 1. Bio survives a music-library scope change. The previous fix added musicLibraryFilterVersion to the load effect, but that effect also did setInfo(null) while the getArtistInfo effect still depended on [id] alone — so a scope bump on the open page wiped the bio without re-fetching it. Move the info reset into the bio effect (keyed on id) and out of the load effect: the album grid still refreshes on scope change; the Last.fm header image and biography survive untouched, since both are library-independent. 2. Composers join the share pipeline as a first-class entity kind. Extend EntityShareKind with 'composer' (and isEntityKind), branch applySharePastePayload to validate via getArtist (same id pool) and navigate to /composer/:id, and wire a Share button into ComposerDetail. A pasted composer link now opens the composer view instead of the artist view, matching what was copied. i18n added in all 8 locales (sharePaste.composerUnavailable, openedComposer; composerDetail.shareComposer, unknownComposer). 3. Partial server failure no longer hides the works. If getArtist rejects but ndListAlbumsByArtistRole succeeds, the page used to show full "not found" despite having data to display. Switch the not-found gate to require both empty (`!artist && !albums`) and render a degraded header (placeholder name, no Wikipedia / favourite / share / Last.fm image) when only metadata is missing. * fix(composer): right-click share copies a composer link, not an artist link The context menu opened from a composer card / row uses type='artist' because every composer-action (radio, favourite, rating, add-to-playlist) is identical to the artist counterpart — they share an id space and a backend representation. Sharing was the one exception: the "Share Link" entry produced a 'psysonic2-' string with k='artist', so a paste opened /artist/:id even though the user came from /composers. Add an optional shareKindOverride to openContextMenu (default: undefined, preserves existing behaviour) and have the artist-typed branch consult it when calling copyShareLink. Composers.tsx now passes 'composer' on both right-click sites; nothing else changes downstream because the override only affects the share kind. * polish(composer): show Last.fm avatar even without server metadata Two minor follow-ups from the latest review. - ComposerDetail: drop the `&& artist` guard on the header-avatar render path. info?.largeImageUrl can resolve through getArtistInfo(id) without ever needing the SubsonicArtist record, so the previous gate hid a perfectly good Last.fm portrait whenever getArtist failed but the bio fetch succeeded. Replace artist.name with displayName so the alt / aria-label degrade to the localised "Composer" placeholder instead of empty strings. - copyEntityShareLink: doc comment now mentions composer alongside track / album / artist. * fix(composer): derive Last.fm cache key from route id, not from artist record Follow-up to the previous polish: the avatar render path no longer requires `artist` to be populated, but the cache-key gate still did. So when getArtist failed but getArtistInfo returned a Last.fm portrait, the key fell through to coverKey — which is empty without an artist record, re-creating the very aliasing bug the earlier Subsonic-vs-Last.fm fix was meant to close. Switch the Last.fm branch to the route id (same id namespace as the SubsonicArtist record), so the key stays stable whenever Last.fm art is shown, independent of getArtist succeeding. * docs: CHANGELOG + Contributors entry for composer browsing (PR #487)
This commit is contained in:
committed by
GitHub
parent
c83447ebd2
commit
59744601d4
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
getArtist, getArtistInfo, star, unstar,
|
||||
SubsonicArtist, SubsonicAlbum, SubsonicArtistInfo,
|
||||
buildCoverArtUrl, coverArtCacheKey,
|
||||
} from '../api/subsonic';
|
||||
import { ndListAlbumsByArtistRole } from '../api/navidromeBrowse';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Feather, Share2 } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { copyEntityShareLink } from '../utils/copyEntityShareLink';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
/** Strip dangerous tags/attributes from server-provided HTML. Mirrors the
|
||||
* ArtistDetail sanitiser — kept inline because it's a 10-liner not worth a
|
||||
* separate util module. */
|
||||
function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value.toLowerCase().trim();
|
||||
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
export default function ComposerDetail() {
|
||||
const { t } = useTranslation();
|
||||
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 [lightboxOpen, setLightboxOpen] = 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;
|
||||
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;
|
||||
setInfo(null);
|
||||
getArtistInfo(id, { similarArtistCount: 0 })
|
||||
.then(i => { if (!cancelled) setInfo(i ?? null); })
|
||||
.catch(() => { if (!cancelled) setInfo(null); });
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderCoverFailed(false);
|
||||
}, [id]);
|
||||
|
||||
const coverId = artist?.coverArt || artist?.id || '';
|
||||
const coverSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 300) : '', [coverId]);
|
||||
const coverKey = useMemo(() => coverId ? coverArtCacheKey(coverId, 300) : '', [coverId]);
|
||||
const coverLargeSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 2000) : '', [coverId]);
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!artist) return;
|
||||
const next = !isStarred;
|
||||
setIsStarred(next);
|
||||
setStarredOverride(artist.id, next);
|
||||
try {
|
||||
if (next) await star(artist.id, 'artist');
|
||||
else await unstar(artist.id, 'artist');
|
||||
} 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)}`
|
||||
: '';
|
||||
// Header image source can be either Last.fm (artist-info path) or the Subsonic
|
||||
// cover-art endpoint. Cache key must mirror the actual URL or we'd alias both
|
||||
// entries under a single Subsonic key, polluting the cache between servers.
|
||||
// The Last.fm key is derived from the route id (same id namespace as the
|
||||
// SubsonicArtist record) so it stays stable even when getArtist failed and
|
||||
// we still render a Last.fm avatar from the bio fetch alone.
|
||||
const headerImageSrc = info?.largeImageUrl || coverSrc;
|
||||
const headerImageCacheKey = info?.largeImageUrl
|
||||
? `lastfm:artist:${id}:large`
|
||||
: coverKey;
|
||||
|
||||
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>
|
||||
|
||||
{lightboxOpen && headerImageSrc && (
|
||||
<CoverLightbox
|
||||
src={info?.largeImageUrl || coverLargeSrc}
|
||||
alt={displayName}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
||||
{headerImageSrc && !headerCoverFailed ? (
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
aria-label={displayName}
|
||||
>
|
||||
<CachedImage
|
||||
src={headerImageSrc}
|
||||
cacheKey={headerImageCacheKey}
|
||||
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 style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{wikiUrl && (
|
||||
<div className="artist-detail-links">
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')}>
|
||||
<ExternalLink size={14} />
|
||||
{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{artist && (
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
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'} />
|
||||
{t('artistDetail.favorite')}
|
||||
</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>
|
||||
) : (
|
||||
<div className="album-grid-wrap">
|
||||
{albums.map((a, i) => <AlbumCard key={`${a.id}-${i}`} album={a} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SubsonicArtist } from '../api/subsonic';
|
||||
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 } from '../constants/appScroll';
|
||||
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
||||
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="artist-avatar artist-avatar-initial"
|
||||
style={{ background: color, border: 0 }}
|
||||
>
|
||||
<span style={{ color: 'var(--ctp-crust)', 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 [filter, setFilter] = useState('');
|
||||
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
// Compact tiles + initial-letter only → 200 per page is comfortable.
|
||||
const PAGE_SIZE = 200;
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
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(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 loadMore = useCallback(() => {
|
||||
if (loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
setVisibleCount(prev => prev + PAGE_SIZE);
|
||||
setTimeout(() => setLoadingMore(false), 100);
|
||||
}, [loadingMore, PAGE_SIZE]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}, [filter, letterFilter, starredOnly, viewMode, PAGE_SIZE]);
|
||||
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const filtered = useMemo(() => {
|
||||
let out = composers;
|
||||
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;
|
||||
}, [composers, letterFilter, filter, starredOnly, starredOverrides]);
|
||||
|
||||
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
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]);
|
||||
|
||||
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 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 composerListOverscan = Math.max(
|
||||
12,
|
||||
Math.ceil(mainScrollViewportHeight / COMPOSER_LIST_ROW_EST),
|
||||
);
|
||||
|
||||
const composerListVirtualizer = useVirtualizer({
|
||||
count:
|
||||
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : composerListFlatRows.length,
|
||||
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
|
||||
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,
|
||||
});
|
||||
|
||||
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">
|
||||
<div className="page-sticky-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('composers.title')}</h1>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 220 }}
|
||||
placeholder={t('composers.search')}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
id="composer-filter-input"
|
||||
/>
|
||||
</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(--ctp-crust)', 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(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.listView')}
|
||||
>
|
||||
<List size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: 'var(--space-4)' }}>
|
||||
{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>
|
||||
|
||||
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
|
||||
|
||||
{!loading && viewMode === 'grid' && (
|
||||
<div className="composer-grid-wrap">
|
||||
{visible.map(artist => (
|
||||
<div
|
||||
key={artist.id}
|
||||
className="composer-card"
|
||||
onClick={() => navigate(`/composer/${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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && viewMode === 'list' && (
|
||||
perfFlags.disableMainstageVirtualLists ? (
|
||||
<>
|
||||
{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={() => navigate(`/composer/${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 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}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"
|
||||
onClick={() => navigate(`/composer/${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 && (
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
||||
{t('composers.notFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -368,6 +368,7 @@ const CONTRIBUTORS = [
|
||||
'Library: "favorites only" filter on Albums, Artists and Advanced Search — toolbar toggle reading star overrides live (PR #466)',
|
||||
'Settings: keep current active server when adding a new one — no more auto-switch interrupting playback or library context (PR #475)',
|
||||
'Help page: full rewrite with 45 focused entries across 10 themed sections (Getting Started / Playback & Queue / Audio Tools / Library & Discovery / Lyrics / Sharing & Social / Personalization / Power User / Offline & Sync / Integrations & Troubleshooting), in-page live search with case-insensitive substring matching and auto-expand on hits, translated to all 8 locales (PR #485)',
|
||||
'Library: Browse by Composer — native-API role listing for classical libraries, library-scoped queries, composer as a first-class share entity (PR #487)',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user