feat(composer): Browse by Composer page (issue #465) (#487)

* 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:
Frank Stellmacher
2026-05-07 00:36:09 +02:00
committed by GitHub
parent c83447ebd2
commit 59744601d4
24 changed files with 1249 additions and 11 deletions
+10
View File
@@ -56,6 +56,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Falls back to the previous flat list when the server doesn't return `releaseTypes` or all albums share the default Album type — no behaviour change for non-OpenSubsonic servers.
* Section headers are localised in all 8 supported languages.
### Library — Browse by Composer
**By [@Psychotoxical](https://github.com/Psychotoxical), suggested by mmourez ([issue #465](https://github.com/Psychotoxical/psysonic/issues/465)), PR [#487](https://github.com/Psychotoxical/psysonic/pull/487)**
* New **Composers** library section listing every artist credited as composer on at least one track, with a detail page showing all works they hold in that role. Aimed at classical-music libraries where the recording artist is the orchestra and the composer tag carries Bach / Mozart / Chopin.
* Uses Navidrome's native API (`/api/artist?_filters={"role":"composer"}` for the listing, `/api/album?_filters={"role_composer_id":"…"}` for the works) — Subsonic `getArtist` only walks AlbumArtist relations and returns zero albums for composer-only credits, so the native path is the only one that works. Requires **Navidrome 0.55+**; older / pure-Subsonic servers see a one-line capability banner.
* Music-folder scope is honoured: role queries pass Navidrome's `library_id` filter so per-folder browsing matches the Albums / Artists pages. Bio + Last.fm portrait stay stable across scope changes.
* **Composers are a first-class share entity.** `psysonic2-` links with `k=composer` paste to `/composer/:id`; the Share button on the detail page and the right-click menu both copy a `composer` link.
* Sidebar entry is **off by default** (classical-music use case is a niche) — toggle in Settings → Sidebar.
## Changed
### Dependencies — npm / Cargo refresh and rodio 0.22
+2
View File
@@ -930,6 +930,8 @@ pub fn run() {
nd_delete_user,
nd_list_libraries,
nd_list_songs,
nd_list_artists_by_role,
nd_list_albums_by_artist_role,
nd_set_user_libraries,
nd_list_playlists,
nd_create_playlist,
+104 -1
View File
@@ -154,7 +154,12 @@ where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
{
const BACKOFFS_MS: [u64; 1] = [500];
// Reverse-proxies in front of Navidrome (Caddy/nginx + Cloudflare etc.)
// sometimes drop a TLS handshake mid-stream when their keep-alive pool
// churns. One 500 ms retry isn't always enough — exponential backoff
// across 4 attempts gives the upstream pool time to settle without
// making the user-visible wait worse for the common single-failure case.
const BACKOFFS_MS: [u64; 3] = [300, 800, 1800];
let mut last: Option<reqwest::Error> = None;
for attempt in 0..=BACKOFFS_MS.len() {
if attempt > 0 {
@@ -359,6 +364,104 @@ pub(crate) async fn nd_list_songs(
resp.json::<serde_json::Value>().await.map_err(nd_err)
}
/// Build the `_filters` JSON for native-API list calls. Optionally narrows the
/// query to a single library — `library_id` is the same scope key the Navidrome
/// web UI sends, and it matches the Subsonic `musicFolderId` we store per server.
fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id: Option<&str>) -> String {
let mut obj = seed;
if let Some(lib) = library_id {
// Navidrome stores library ids as i64; our state holds them as strings
// (Subsonic musicFolderId). Send numeric when parseable, fall back to
// string for safety against future non-numeric ids.
let val = lib.parse::<i64>()
.map(|n| serde_json::Value::Number(n.into()))
.unwrap_or_else(|_| serde_json::Value::String(lib.to_string()));
obj.insert("library_id".to_string(), val);
}
serde_json::Value::Object(obj).to_string()
}
/// GET `/api/artist?_filters={"role":"<role>"}&_sort=...&_order=...&_start=...&_end=...`
/// — paginated list of artists that have at least one credit in the given role.
/// Navidrome 0.55.0+ (uses `library_artist.stats` JSON aggregate). Available to any
/// authenticated user. Returns raw JSON array.
#[tauri::command]
pub(crate) async fn nd_list_artists_by_role(
server_url: String,
token: String,
role: String,
sort: String,
order: String,
start: u32,
end: u32,
library_id: Option<String>,
) -> Result<serde_json::Value, String> {
let mut seed = serde_json::Map::new();
seed.insert("role".to_string(), serde_json::Value::String(role.clone()));
let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string();
let end_s = end.to_string();
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/artist", server_url))
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
}).await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
resp.json::<serde_json::Value>().await.map_err(nd_err)
}
/// GET `/api/album?_filters={"role_<role>_id":"<artistId>"}&_sort=...&_order=...&_start=...&_end=...`
/// — paginated list of albums in which `artist_id` holds the given participant role.
/// Subsonic `getArtist.view` only walks AlbumArtist relations, so composer-only
/// (or conductor-only, lyricist-only, …) credits are unreachable there. Navidrome
/// generates `role_<role>_id` filters dynamically from `model.AllRoles`.
#[tauri::command]
pub(crate) async fn nd_list_albums_by_artist_role(
server_url: String,
token: String,
artist_id: String,
role: String,
sort: String,
order: String,
start: u32,
end: u32,
library_id: Option<String>,
) -> Result<serde_json::Value, String> {
let filter_key = format!("role_{}_id", role);
let mut seed = serde_json::Map::new();
seed.insert(filter_key, serde_json::Value::String(artist_id.clone()));
let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string();
let end_s = end.to_string();
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/album", server_url))
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
}).await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
resp.json::<serde_json::Value>().await.map_err(nd_err)
}
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
#[tauri::command]
pub(crate) async fn nd_list_libraries(
+4
View File
@@ -22,6 +22,8 @@ const Home = lazy(() => import('./pages/Home'));
const Albums = lazy(() => import('./pages/Albums'));
const Artists = lazy(() => import('./pages/Artists'));
const ArtistDetail = lazy(() => import('./pages/ArtistDetail'));
const Composers = lazy(() => import('./pages/Composers'));
const ComposerDetail = lazy(() => import('./pages/ComposerDetail'));
const NewReleases = lazy(() => import('./pages/NewReleases'));
const Favorites = lazy(() => import('./pages/Favorites'));
const RandomMix = lazy(() => import('./pages/RandomMix'));
@@ -672,6 +674,8 @@ function AppShell() {
<Route path="/album/:id" element={<AlbumDetail />} />
<Route path="/artists" element={<Artists />} />
<Route path="/artist/:id" element={<ArtistDetail />} />
<Route path="/composers" element={<Composers />} />
<Route path="/composer/:id" element={<ComposerDetail />} />
<Route path="/new-releases" element={<NewReleases />} />
<Route path="/favorites" element={<Favorites />} />
<Route path="/random/mix" element={<RandomMix />} />
+139 -1
View File
@@ -1,7 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { ndLogin } from './navidromeAdmin';
import type { SubsonicSong } from './subsonic';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonic';
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
let cachedToken: { serverUrl: string; token: string } | null = null;
@@ -21,6 +21,16 @@ function asString(v: unknown, fallback = ''): string {
return typeof v === 'string' ? v : (typeof v === 'number' ? String(v) : fallback);
}
/** Active library scope for the current server, or null when "all libraries" is selected.
* Mirrors the Subsonic `musicFolderId` we pipe through `libraryFilterParams()` Navidrome
* uses the same id space, so the same value is valid for the native API's `library_id` filter. */
function currentLibraryId(): string | null {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return null;
const f = musicLibraryFilterByServer[activeServerId];
return !f || f === 'all' ? null : f;
}
function asNumber(v: unknown): number | undefined {
return typeof v === 'number' && isFinite(v) ? v : undefined;
}
@@ -120,6 +130,134 @@ export async function ndListSongs(
return data;
}
function mapNdArtist(o: Record<string, unknown>, role?: string): SubsonicArtist {
// Top-level `albumCount` aggregates every role the person holds. The
// role-scoped count lives in `stats[role].albumCount` (verified empirically
// 2026-05-06 — Navidrome exposes it as `albumCount`/`songCount`/`size`,
// not the abbreviated `a`/`s`/… some refactor docs claim).
const starredFlag = !!o.starred;
const starredAt = typeof o.starredAt === 'string' ? o.starredAt : undefined;
let albumCount: number | undefined;
if (role && o.stats && typeof o.stats === 'object') {
const roleStats = (o.stats as Record<string, unknown>)[role];
if (roleStats && typeof roleStats === 'object') {
albumCount = asNumber((roleStats as Record<string, unknown>).albumCount);
}
}
return {
id: asString(o.id),
name: asString(o.name),
albumCount,
starred: starredFlag ? (starredAt ?? 'true') : undefined,
userRating: asNumber(o.rating),
};
}
function mapNdAlbum(o: Record<string, unknown>): SubsonicAlbum {
const id = asString(o.id);
const starredFlag = !!o.starred;
const starredAt = typeof o.starredAt === 'string' ? o.starredAt : undefined;
return {
id,
name: asString(o.name),
artist: asString(o.albumArtist) || asString(o.artist),
artistId: asString(o.albumArtistId) || asString(o.artistId),
coverArt: asString(o.coverArtId) || asString(o.embedArtPath) || id || undefined,
songCount: asNumber(o.songCount) ?? 0,
duration: asNumber(o.duration) ?? 0,
year: asNumber(o.maxYear) ?? asNumber(o.year),
genre: typeof o.genre === 'string' ? o.genre : undefined,
starred: starredFlag ? (starredAt ?? 'true') : undefined,
userRating: asNumber(o.rating),
isCompilation: o.compilation === true,
};
}
export type NdArtistRole = 'composer' | 'conductor' | 'lyricist' | 'arranger'
| 'producer' | 'director' | 'engineer' | 'mixer' | 'remixer' | 'djmixer'
| 'performer' | 'maincredit' | 'artist' | 'albumartist';
export type NdArtistSort = 'name' | 'album_count' | 'song_count' | 'size';
/**
* Paginated list of artists holding the given participant role on at least one
* track the canonical Navidrome path for "Browse by Composer/Conductor/etc."
* Requires Navidrome 0.55.0+ (uses `library_artist.stats`). Throws on auth or
* unsupported-server errors; caller should treat that as a capability miss.
*/
export async function ndListArtistsByRole(
role: NdArtistRole,
start: number,
end: number,
sort: NdArtistSort = 'name',
order: 'ASC' | 'DESC' = 'ASC',
): Promise<SubsonicArtist[]> {
const baseUrl = useAuthStore.getState().getBaseUrl();
if (!baseUrl) throw new Error('No server configured');
const libraryId = currentLibraryId();
const callOnce = async (token: string): Promise<unknown> =>
invoke<unknown>('nd_list_artists_by_role', {
serverUrl: baseUrl, token, role, sort, order, start, end, libraryId,
});
let token = await getToken();
let raw: unknown;
try {
raw = await callOnce(token);
} catch (err) {
const msg = String(err);
if (msg.includes('401') || msg.includes('403')) {
token = await getToken(true);
raw = await callOnce(token);
} else {
throw err;
}
}
if (!Array.isArray(raw)) return [];
return raw.map(a => mapNdArtist(a as Record<string, unknown>, role));
}
/**
* Paginated list of albums in which `artistId` holds the given participant role.
* Subsonic `getArtist.view` only walks AlbumArtist relations, so composer-only
* (or conductor-only, ) credits are unreachable through it. Navidrome's native
* filter `role_<role>_id` covers every role from `model.AllRoles`.
*/
export async function ndListAlbumsByArtistRole(
artistId: string,
role: NdArtistRole,
start: number,
end: number,
sort: 'name' | 'max_year' | 'recently_added' | 'play_count' = 'name',
order: 'ASC' | 'DESC' = 'ASC',
): Promise<SubsonicAlbum[]> {
const baseUrl = useAuthStore.getState().getBaseUrl();
if (!baseUrl) throw new Error('No server configured');
const libraryId = currentLibraryId();
const callOnce = async (token: string): Promise<unknown> =>
invoke<unknown>('nd_list_albums_by_artist_role', {
serverUrl: baseUrl, token, artistId, role, sort, order, start, end, libraryId,
});
let token = await getToken();
let raw: unknown;
try {
raw = await callOnce(token);
} catch (err) {
const msg = String(err);
if (msg.includes('401') || msg.includes('403')) {
token = await getToken(true);
raw = await callOnce(token);
} else {
throw err;
}
}
if (!Array.isArray(raw)) return [];
return raw.map(a => mapNdAlbum(a as Record<string, unknown>));
}
/** Drop the cached token AND the songs cache — call when the active server changes. */
export function ndClearTokenCache(): void {
cachedToken = null;
+2 -2
View File
@@ -1178,7 +1178,7 @@ export default function ContextMenu() {
};
}, [pendingSubmenuKeyboardFocus, playlistSubmenuOpen, getMenuNavItems, focusMenuItemAt]);
const { type, item, queueIndex, playlistId, playlistSongIndex } = contextMenu;
const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride } = contextMenu;
const isStarred = (id: string, itemStarred?: string) =>
id in starredOverrides ? starredOverrides[id] : !!itemStarred;
@@ -2001,7 +2001,7 @@ export default function ContextMenu() {
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('artist', artist.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-divider" />
+2 -1
View File
@@ -3,7 +3,7 @@ import {
Disc3, Users, Music4, Radio, Heart, BarChart3,
HelpCircle, Tags, ListMusic, Cast, TrendingUp,
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles,
AudioLines,
AudioLines, Feather,
} from 'lucide-react';
export interface NavItemMeta {
@@ -24,6 +24,7 @@ export const ALL_NAV_ITEMS: Record<string, NavItemMeta> = {
randomAlbums: { icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random/albums', section: 'library' },
luckyMix: { icon: Sparkles, labelKey: 'sidebar.feelingLucky', to: '/lucky-mix', section: 'library' },
artists: { icon: Users, labelKey: 'sidebar.artists', to: '/artists', section: 'library' },
composers: { icon: Feather, labelKey: 'sidebar.composers', to: '/composers', section: 'library' },
genres: { icon: Tags, labelKey: 'sidebar.genres', to: '/genres', section: 'library' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
+24
View File
@@ -7,6 +7,7 @@ export const deTranslation = {
randomAlbums: 'Zufallsalben',
randomPicker: 'Mix erstellen',
artists: 'Künstler',
composers: 'Komponist*innen',
randomMix: 'Zufallsmix',
favorites: 'Favoriten',
nowPlaying: 'Now Playing',
@@ -168,9 +169,11 @@ export const deTranslation = {
trackUnavailable: 'Dieser Titel wurde auf dem Server nicht gefunden.',
albumUnavailable: 'Dieses Album wurde auf dem Server nicht gefunden.',
artistUnavailable: 'Dieser Künstler wurde auf dem Server nicht gefunden.',
composerUnavailable: 'Diese*r Komponist*in wurde auf dem Server nicht gefunden.',
openedTrack: 'Geteilter Titel wird abgespielt.',
openedAlbum: 'Geteiltes Album wird geöffnet.',
openedArtist: 'Geteilter Künstler wird geöffnet.',
openedComposer: 'Geteilte*r Komponist*in wird geöffnet.',
openedQueue_one: '{{count}} Titel aus Freigabe-Link wird abgespielt.',
openedQueue_other: '{{count}} Titel aus Freigabe-Link werden abgespielt.',
openedQueuePartial:
@@ -429,6 +432,27 @@ export const deTranslation = {
cancelSelect: 'Abbrechen',
addToPlaylist: 'Zur Playlist hinzufügen',
},
composers: {
title: 'Komponist*innen',
search: 'Suchen…',
notFound: 'Keine Komponist*innen gefunden.',
unsupported: 'Komponist-Ansicht benötigt Navidrome 0.55 oder neuer.',
loadFailed: 'Komponist*innen konnten nicht geladen werden.',
retry: 'Erneut versuchen',
involvedIn_one: 'Beteiligt an {{count}} Album',
involvedIn_other: 'Beteiligt an {{count}} Alben',
},
composerDetail: {
back: 'Zurück',
notFound: 'Komponist*in nicht gefunden.',
about: 'Über diese*n Komponist*in',
works: 'Werke',
noWorks: 'Keine Werke gefunden.',
workCount_one: '{{count}} Werk',
workCount_other: '{{count}} Werke',
shareComposer: 'Komponist*in teilen',
unknownComposer: 'Komponist*in',
},
login: {
subtitle: 'Dein Navidrome Desktop Player',
serverName: 'Server-Name (optional)',
+24
View File
@@ -7,6 +7,7 @@ export const enTranslation = {
randomAlbums: 'Random Albums',
randomPicker: 'Build a Mix',
artists: 'Artists',
composers: 'Composers',
randomMix: 'Random Mix',
favorites: 'Favorites',
nowPlaying: 'Now Playing',
@@ -170,9 +171,11 @@ export const enTranslation = {
trackUnavailable: 'This track was not found on the server.',
albumUnavailable: 'This album was not found on the server.',
artistUnavailable: 'This artist was not found on the server.',
composerUnavailable: 'This composer was not found on the server.',
openedTrack: 'Playing shared track.',
openedAlbum: 'Opening shared album.',
openedArtist: 'Opening shared artist.',
openedComposer: 'Opening shared composer.',
openedQueue_one: 'Playing {{count}} track from the share link.',
openedQueue_other: 'Playing {{count}} tracks from the share link.',
openedQueuePartial:
@@ -431,6 +434,27 @@ export const enTranslation = {
cancelSelect: 'Cancel',
addToPlaylist: 'Add to Playlist',
},
composers: {
title: 'Composers',
search: 'Search…',
notFound: 'No composers found.',
unsupported: 'Browse by Composer requires Navidrome 0.55 or newer.',
loadFailed: 'Could not load composers.',
retry: 'Retry',
involvedIn_one: 'Involved in {{count}} album',
involvedIn_other: 'Involved in {{count}} albums',
},
composerDetail: {
back: 'Back',
notFound: 'Composer not found.',
about: 'About this composer',
works: 'Works',
noWorks: 'No works found.',
workCount_one: '{{count}} work',
workCount_other: '{{count}} works',
shareComposer: 'Share composer',
unknownComposer: 'Composer',
},
login: {
subtitle: 'Your Navidrome Desktop Player',
serverName: 'Server Name (optional)',
+24
View File
@@ -6,6 +6,7 @@ export const esTranslation = {
allAlbums: 'Todos los Álbumes',
randomAlbums: 'Álbumes Aleatorios',
artists: 'Artistas',
composers: 'Compositores',
randomMix: 'Mezcla Aleatoria',
randomPicker: 'Crear Mezcla',
favorites: 'Favoritos',
@@ -169,9 +170,11 @@ export const esTranslation = {
trackUnavailable: 'No se encontró esta canción en el servidor.',
albumUnavailable: 'No se encontró este álbum en el servidor.',
artistUnavailable: 'No se encontró este artista en el servidor.',
composerUnavailable: 'No se encontró este compositor en el servidor.',
openedTrack: 'Reproduciendo la canción compartida.',
openedAlbum: 'Abriendo el álbum compartido.',
openedArtist: 'Abriendo el artista compartido.',
openedComposer: 'Abriendo el compositor compartido.',
openedQueue_one: 'Reproduciendo {{count}} pista del enlace para compartir.',
openedQueue_other: 'Reproduciendo {{count}} pistas del enlace para compartir.',
openedQueuePartial:
@@ -431,6 +434,27 @@ export const esTranslation = {
cancelSelect: 'Cancelar',
addToPlaylist: 'Agregar a Lista',
},
composers: {
title: 'Compositores',
search: 'Buscar…',
notFound: 'No se encontraron compositores.',
unsupported: 'La navegación por compositor requiere Navidrome 0.55 o más reciente.',
loadFailed: 'No se pudieron cargar los compositores.',
retry: 'Reintentar',
involvedIn_one: 'Participa en {{count}} álbum',
involvedIn_other: 'Participa en {{count}} álbumes',
},
composerDetail: {
back: 'Atrás',
notFound: 'Compositor no encontrado.',
about: 'Sobre este compositor',
works: 'Obras',
noWorks: 'No se encontraron obras.',
workCount_one: '{{count}} obra',
workCount_other: '{{count}} obras',
shareComposer: 'Compartir compositor',
unknownComposer: 'Compositor',
},
login: {
subtitle: 'Tu Reproductor Navidrome para Escritorio',
serverName: 'Nombre del Servidor (opcional)',
+24
View File
@@ -7,6 +7,7 @@ export const frTranslation = {
randomAlbums: 'Albums aléatoires',
randomPicker: 'Créer un mix',
artists: 'Artistes',
composers: 'Compositeurs',
randomMix: 'Mix aléatoire',
favorites: 'Favoris',
nowPlaying: 'En cours',
@@ -168,9 +169,11 @@ export const frTranslation = {
trackUnavailable: 'Ce morceau est introuvable sur le serveur.',
albumUnavailable: 'Cet album est introuvable sur le serveur.',
artistUnavailable: 'Cet artiste est introuvable sur le serveur.',
composerUnavailable: 'Ce compositeur est introuvable sur le serveur.',
openedTrack: 'Lecture du morceau partagé.',
openedAlbum: 'Ouverture de lalbum partagé.',
openedArtist: 'Ouverture de lartiste partagé.',
openedComposer: 'Ouverture du compositeur partagé.',
openedQueue_one: 'Lecture de {{count}} morceau depuis le lien de partage.',
openedQueue_other: 'Lecture de {{count}} morceaux depuis le lien de partage.',
openedQueuePartial:
@@ -429,6 +432,27 @@ export const frTranslation = {
cancelSelect: 'Annuler',
addToPlaylist: 'Ajouter à la playlist',
},
composers: {
title: 'Compositeurs',
search: 'Rechercher…',
notFound: 'Aucun compositeur trouvé.',
unsupported: 'La navigation par compositeur nécessite Navidrome 0.55 ou plus récent.',
loadFailed: 'Impossible de charger les compositeurs.',
retry: 'Réessayer',
involvedIn_one: 'Présent sur {{count}} album',
involvedIn_other: 'Présent sur {{count}} albums',
},
composerDetail: {
back: 'Retour',
notFound: 'Compositeur introuvable.',
about: 'À propos de ce compositeur',
works: 'Œuvres',
noWorks: 'Aucune œuvre trouvée.',
workCount_one: '{{count}} œuvre',
workCount_other: '{{count}} œuvres',
shareComposer: 'Partager le compositeur',
unknownComposer: 'Compositeur',
},
login: {
subtitle: 'Votre lecteur de bureau Navidrome',
serverName: 'Nom du serveur (facultatif)',
+24
View File
@@ -7,6 +7,7 @@ export const nbTranslation = {
randomAlbums: 'Tilfeldige album',
randomPicker: 'Lag en miks',
artists: 'Artister',
composers: 'Komponister',
randomMix: 'Tilfeldig miks',
favorites: 'Favoritter',
nowPlaying: 'Spilles nå',
@@ -168,9 +169,11 @@ export const nbTranslation = {
trackUnavailable: 'Fant ikke dette sporet på serveren.',
albumUnavailable: 'Fant ikke dette albumet på serveren.',
artistUnavailable: 'Fant ikke denne artisten på serveren.',
composerUnavailable: 'Fant ikke denne komponisten på serveren.',
openedTrack: 'Spiller delt spor.',
openedAlbum: 'Åpner delt album.',
openedArtist: 'Åpner delt artist.',
openedComposer: 'Åpner delt komponist.',
openedQueue_one: 'Spiller {{count}} spor fra delingslenken.',
openedQueue_other: 'Spiller {{count}} spor fra delingslenken.',
openedQueuePartial:
@@ -429,6 +432,27 @@ export const nbTranslation = {
cancelSelect: 'Avbryt',
addToPlaylist: 'Legg til i spilleliste',
},
composers: {
title: 'Komponister',
search: 'Søk…',
notFound: 'Ingen komponister funnet.',
unsupported: 'Bla etter komponist krever Navidrome 0.55 eller nyere.',
loadFailed: 'Kunne ikke laste komponister.',
retry: 'Prøv igjen',
involvedIn_one: 'Bidratt på {{count}} album',
involvedIn_other: 'Bidratt på {{count}} album',
},
composerDetail: {
back: 'Tilbake',
notFound: 'Komponist ikke funnet.',
about: 'Om denne komponisten',
works: 'Verker',
noWorks: 'Ingen verker funnet.',
workCount_one: '{{count}} verk',
workCount_other: '{{count}} verker',
shareComposer: 'Del komponist',
unknownComposer: 'Komponist',
},
login: {
subtitle: 'Din Navidrome-mediaspiller',
serverName: 'Tjenernavn (valgfritt)',
+24
View File
@@ -7,6 +7,7 @@ export const nlTranslation = {
randomAlbums: 'Willekeurige albums',
randomPicker: 'Mix samenstellen',
artists: 'Artiesten',
composers: 'Componisten',
randomMix: 'Willekeurige mix',
favorites: 'Favorieten',
nowPlaying: 'Nu bezig',
@@ -167,9 +168,11 @@ export const nlTranslation = {
trackUnavailable: 'Dit nummer is niet op de server gevonden.',
albumUnavailable: 'Dit album is niet op de server gevonden.',
artistUnavailable: 'Deze artiest is niet op de server gevonden.',
composerUnavailable: 'Deze componist is niet op de server gevonden.',
openedTrack: 'Gedeeld nummer wordt afgespeeld.',
openedAlbum: 'Gedeeld album wordt geopend.',
openedArtist: 'Gedeelde artiest wordt geopend.',
openedComposer: 'Gedeelde componist wordt geopend.',
openedQueue_one: '{{count}} nummer van deellink wordt afgespeeld.',
openedQueue_other: '{{count}} nummers van deellink worden afgespeeld.',
openedQueuePartial:
@@ -428,6 +431,27 @@ export const nlTranslation = {
cancelSelect: 'Annuleren',
addToPlaylist: 'Toevoegen aan playlist',
},
composers: {
title: 'Componisten',
search: 'Zoeken…',
notFound: 'Geen componisten gevonden.',
unsupported: 'Bladeren op componist vereist Navidrome 0.55 of nieuwer.',
loadFailed: 'Kan componisten niet laden.',
retry: 'Opnieuw proberen',
involvedIn_one: 'Betrokken bij {{count}} album',
involvedIn_other: 'Betrokken bij {{count}} albums',
},
composerDetail: {
back: 'Terug',
notFound: 'Componist niet gevonden.',
about: 'Over deze componist',
works: 'Werken',
noWorks: 'Geen werken gevonden.',
workCount_one: '{{count}} werk',
workCount_other: '{{count}} werken',
shareComposer: 'Componist delen',
unknownComposer: 'Componist',
},
login: {
subtitle: 'Jouw Navidrome-desktopspeler',
serverName: 'Servernaam (optioneel)',
+24
View File
@@ -8,6 +8,7 @@ export const ruTranslation = {
randomAlbums: 'Случайные альбомы',
randomPicker: 'Собрать микс',
artists: 'Исполнители',
composers: 'Композиторы',
randomMix: 'Случайный микс',
favorites: 'Избранное',
nowPlaying: 'Сейчас играет',
@@ -176,9 +177,11 @@ export const ruTranslation = {
trackUnavailable: 'Трек на сервере не найден.',
albumUnavailable: 'Альбом на сервере не найден.',
artistUnavailable: 'Исполнитель на сервере не найден.',
composerUnavailable: 'Композитор на сервере не найден.',
openedTrack: 'Воспроизводится присланный трек.',
openedAlbum: 'Открывается присланный альбом.',
openedArtist: 'Открывается присланный исполнитель.',
openedComposer: 'Открывается присланный композитор.',
openedQueue_one: 'Воспроизводится присланная очередь: {{count}} трек.',
openedQueue_few: 'Воспроизводится присланная очередь: {{count}} трека.',
openedQueue_many: 'Воспроизводится присланная очередь: {{count}} треков.',
@@ -457,6 +460,27 @@ export const ruTranslation = {
cancelSelect: 'Отмена',
addToPlaylist: 'В плейлист',
},
composers: {
title: 'Композиторы',
search: 'Поиск…',
notFound: 'Композиторы не найдены.',
unsupported: 'Просмотр по композиторам требует Navidrome 0.55 или новее.',
loadFailed: 'Не удалось загрузить композиторов.',
retry: 'Повторить',
involvedIn_one: 'Участие в {{count}} альбоме',
involvedIn_other: 'Участие в {{count}} альбомах',
},
composerDetail: {
back: 'Назад',
notFound: 'Композитор не найден.',
about: 'Об этом композиторе',
works: 'Произведения',
noWorks: 'Произведения не найдены.',
workCount_one: '{{count}} произведение',
workCount_other: '{{count}} произведений',
shareComposer: 'Поделиться композитором',
unknownComposer: 'Композитор',
},
login: {
subtitle: 'Десктопный клиент для Navidrome',
serverName: 'Название сервера (необязательно)',
+24
View File
@@ -7,6 +7,7 @@ export const zhTranslation = {
randomAlbums: '随机专辑',
randomPicker: '创建混音',
artists: '艺术家',
composers: '作曲家',
randomMix: '随机混音',
favorites: '收藏夹',
nowPlaying: '正在播放',
@@ -167,9 +168,11 @@ export const zhTranslation = {
trackUnavailable: '在服务器上找不到此歌曲。',
albumUnavailable: '在服务器上找不到此专辑。',
artistUnavailable: '在服务器上找不到此艺术家。',
composerUnavailable: '在服务器上找不到此作曲家。',
openedTrack: '正在播放分享的歌曲。',
openedAlbum: '正在打开分享的专辑。',
openedArtist: '正在打开分享的艺术家。',
openedComposer: '正在打开分享的作曲家。',
openedQueue_one: '正在播放分享队列中的 {{count}} 首曲目。',
openedQueue_other: '正在播放分享队列中的 {{count}} 首曲目。',
openedQueuePartial: '正在播放链接中的 {{played}} / {{total}} 首曲目({{skipped}} 首在此服务器上未找到)。',
@@ -427,6 +430,27 @@ export const zhTranslation = {
cancelSelect: '取消',
addToPlaylist: '添加到播放列表',
},
composers: {
title: '作曲家',
search: '搜索…',
notFound: '未找到作曲家。',
unsupported: '按作曲家浏览需要 Navidrome 0.55 或更新版本。',
loadFailed: '无法加载作曲家。',
retry: '重试',
involvedIn_one: '参与 {{count}} 张专辑',
involvedIn_other: '参与 {{count}} 张专辑',
},
composerDetail: {
back: '返回',
notFound: '未找到作曲家。',
about: '关于这位作曲家',
works: '作品',
noWorks: '未找到作品。',
workCount_one: '{{count}} 部作品',
workCount_other: '{{count}} 部作品',
shareComposer: '分享作曲家',
unknownComposer: '作曲家',
},
login: {
subtitle: '您的 Navidrome 桌面播放器',
serverName: '服务器名称(可选)',
+294
View File
@@ -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>
);
}
+412
View File
@@ -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>
);
}
+1
View File
@@ -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;
+7 -3
View File
@@ -324,8 +324,12 @@ interface PlayerState {
queueIndex?: number;
playlistId?: string;
playlistSongIndex?: number;
/** Overrides the EntityShareKind for the "Share" action used by Composers
* list/grid to copy a `composer` link from the otherwise artist-typed
* context menu, so paste lands on /composer/:id instead of /artist/:id. */
shareKindOverride?: 'track' | 'album' | 'artist' | 'composer';
};
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number) => void;
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number, shareKindOverride?: 'track' | 'album' | 'artist' | 'composer') => void;
closeContextMenu: () => void;
songInfoModal: { isOpen: boolean; songId: string | null };
@@ -2305,8 +2309,8 @@ export const usePlayerStore = create<PlayerState>()(
repeatMode: 'off',
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
openContextMenu: (x, y, item, type, queueIndex, playlistId, playlistSongIndex) => set({
contextMenu: { isOpen: true, x, y, item, type, queueIndex, playlistId, playlistSongIndex },
openContextMenu: (x, y, item, type, queueIndex, playlistId, playlistSongIndex, shareKindOverride) => set({
contextMenu: { isOpen: true, x, y, item, type, queueIndex, playlistId, playlistSongIndex, shareKindOverride },
}),
closeContextMenu: () => set(state => ({
contextMenu: { ...state.contextMenu, isOpen: false },
+1
View File
@@ -18,6 +18,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'randomAlbums', visible: true },
{ id: 'luckyMix', visible: true },
{ id: 'artists', visible: true },
{ id: 'composers', visible: false },
{ id: 'genres', visible: true },
{ id: 'favorites', visible: true },
{ id: 'playlists', visible: true },
+61
View File
@@ -322,6 +322,67 @@
color: var(--text-secondary);
}
/* Composer Grid (text-only, compact)
* Composers carry no useful imagery visual is just noise. Strip the avatar,
* keep the name as the visual anchor with the participation count below. */
.composer-grid-wrap {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-2);
align-items: stretch;
}
.composer-grid-wrap > .composer-card {
content-visibility: auto;
contain-intrinsic-size: 0 78px;
}
.composer-card {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
background: var(--bg-card);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
padding: var(--space-3) var(--space-3);
cursor: pointer;
text-align: left;
transition:
transform 200ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 200ms ease,
background 200ms ease,
box-shadow 200ms ease;
}
.composer-card:hover {
border-color: var(--accent);
background: var(--bg-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px -6px rgba(0, 0, 0, 0.4);
}
.composer-card-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.25;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
/* Reserve two name lines so the participation row stays vertically aligned
* across the grid, regardless of whether the name wraps or not. */
min-height: calc(14px * 1.25 * 2);
}
.composer-card-meta {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.3;
}
/* ─ Album Row Container ─ */
.album-row-section {
display: flex;
+15
View File
@@ -72,6 +72,21 @@ export async function applySharePastePayload(
return;
}
if (payload.k === 'composer') {
// Same id space as artists (Subsonic / Navidrome use one id pool for
// every participant role), so getArtist still validates the entity —
// the difference is which view we navigate to.
try {
await getArtist(payload.id);
} catch {
showToast(t('sharePaste.composerUnavailable'), 5000, 'error');
return;
}
navigate(`/composer/${payload.id}`);
showToast(t('sharePaste.openedComposer'), 3000, 'info');
return;
}
if (payload.k === 'queue') {
const { ids } = payload;
if (ids.length === 0) {
+1 -1
View File
@@ -2,7 +2,7 @@ import { useAuthStore } from '../store/authStore';
import { encodeSharePayload, type EntityShareKind } from './shareLink';
import { copyTextToClipboard } from './serverMagicString';
/** Copies a track / album / artist share link (`psysonic2-`) to the clipboard. */
/** Copies a track / album / artist / composer share link (`psysonic2-`) to the clipboard. */
export async function copyEntityShareLink(kind: EntityShareKind, id: string): Promise<boolean> {
const srv = useAuthStore.getState().getBaseUrl();
if (!srv || !id.trim()) return false;
+2 -2
View File
@@ -3,7 +3,7 @@ import type { ServerProfile } from '../store/authStore';
/** Library share (track / album / artist / queue). Same naming family as `psysonic1-` server invites. */
export const PSYSONIC_SHARE_PREFIX = 'psysonic2-';
export type EntityShareKind = 'track' | 'album' | 'artist';
export type EntityShareKind = 'track' | 'album' | 'artist' | 'composer';
/** Entity / queue shares — what {@link applySharePastePayload} dispatches on. */
export type EntitySharePayloadV1 =
@@ -40,7 +40,7 @@ function base64UrlToUtf8(s: string): string {
}
function isEntityKind(k: unknown): k is EntityShareKind {
return k === 'track' || k === 'album' || k === 'artist';
return k === 'track' || k === 'album' || k === 'artist' || k === 'composer';
}
export function encodeSharePayload(payload: SharePayloadV1): string {