diff --git a/CHANGELOG.md b/CHANGELOG.md index 353f91de..35a1fd37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 376d647a..593ba5fb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/lib_commands/app_api/navidrome.rs b/src-tauri/src/lib_commands/app_api/navidrome.rs index dace9146..67568fdc 100644 --- a/src-tauri/src/lib_commands/app_api/navidrome.rs +++ b/src-tauri/src/lib_commands/app_api/navidrome.rs @@ -154,7 +154,12 @@ where F: FnMut() -> Fut, Fut: std::future::Future>, { - 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 = None; for attempt in 0..=BACKOFFS_MS.len() { if attempt > 0 { @@ -359,6 +364,104 @@ pub(crate) async fn nd_list_songs( resp.json::().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, 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::() + .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":""}&_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, +) -> Result { + 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::().await.map_err(nd_err) +} + +/// GET `/api/album?_filters={"role__id":""}&_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__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, +) -> Result { + 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::().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( diff --git a/src/App.tsx b/src/App.tsx index 37abd14b..3250a0c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/api/navidromeBrowse.ts b/src/api/navidromeBrowse.ts index 34a60a3e..b8a0a73e 100644 --- a/src/api/navidromeBrowse.ts +++ b/src/api/navidromeBrowse.ts @@ -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, 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)[role]; + if (roleStats && typeof roleStats === 'object') { + albumCount = asNumber((roleStats as Record).albumCount); + } + } + return { + id: asString(o.id), + name: asString(o.name), + albumCount, + starred: starredFlag ? (starredAt ?? 'true') : undefined, + userRating: asNumber(o.rating), + }; +} + +function mapNdAlbum(o: Record): 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 { + const baseUrl = useAuthStore.getState().getBaseUrl(); + if (!baseUrl) throw new Error('No server configured'); + + const libraryId = currentLibraryId(); + const callOnce = async (token: string): Promise => + invoke('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, 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__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 { + const baseUrl = useAuthStore.getState().getBaseUrl(); + if (!baseUrl) throw new Error('No server configured'); + + const libraryId = currentLibraryId(); + const callOnce = async (token: string): Promise => + invoke('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)); +} + /** Drop the cached token AND the songs cache — call when the active server changes. */ export function ndClearTokenCache(): void { cachedToken = null; diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index a5729575..a1091e11 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -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() { { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )} -
handleAction(() => copyShareLink('artist', artist.id))}> +
handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}> {t('contextMenu.shareLink')}
diff --git a/src/config/navItems.ts b/src/config/navItems.ts index c71540bb..b3c396a0 100644 --- a/src/config/navItems.ts +++ b/src/config/navItems.ts @@ -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 = { 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' }, diff --git a/src/locales/de.ts b/src/locales/de.ts index a4a1200b..95c105ca 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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)', diff --git a/src/locales/en.ts b/src/locales/en.ts index 703222c0..14b5a52a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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)', diff --git a/src/locales/es.ts b/src/locales/es.ts index 1777dc59..481c5f51 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -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)', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index edbd6bb7..6a1b0d61 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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 l’album partagé.', openedArtist: 'Ouverture de l’artiste 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)', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index d0002f47..f5de79bd 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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)', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 4f36aad3..8b9afcb5 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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)', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 531ed719..8a7b1c2a 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -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: 'Название сервера (необязательно)', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index ed055c84..2d894637 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -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: '服务器名称(可选)', diff --git a/src/pages/ComposerDetail.tsx b/src/pages/ComposerDetail.tsx new file mode 100644 index 00000000..23ab0291 --- /dev/null +++ b/src/pages/ComposerDetail.tsx @@ -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(null); + const [albums, setAlbums] = useState([]); + const [info, setInfo] = useState(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(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":""}` 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 ( +
+
+
+ ); + } + + // 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 ( +
+
+ {t('composerDetail.notFound')} +
+
+ ); + } + + 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 ( +
+ + + {lightboxOpen && headerImageSrc && ( + setLightboxOpen(false)} + /> + )} + +
+
+ {headerImageSrc && !headerCoverFailed ? ( + + ) : ( + + )} +
+ +
+

+ {displayName} +

+
+ + {t('composerDetail.workCount', { count: albums.length })} +
+ +
+ {wikiUrl && ( +
+ +
+ )} + + {artist && ( + + )} + + {artist && ( + + )} +
+
+
+ + {info?.biography && ( +
+
+

{t('composerDetail.about')}

+
+
+
+
+ +
+
+
+ )} + +

+ {t('composerDetail.works')} +

+ {albums.length === 0 ? ( +
+ {t('composerDetail.noWorks')} +
+ ) : ( +
+ {albums.map((a, i) => )} +
+ )} +
+ ); +} diff --git a/src/pages/Composers.tsx b/src/pages/Composers.tsx new file mode 100644 index 00000000..2775795b --- /dev/null +++ b/src/pages/Composers.tsx @@ -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 ( +
+ {nameInitial(artist.name)} +
+ ); +} + +export default function Composers() { + const perfFlags = usePerfProbeFlags(); + const { t } = useTranslation(); + const [composers, setComposers] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState<'unsupported' | 'transient' | null>(null); + const [reloadTick, setReloadTick] = useState(0); + const [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(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, letters: [] as string[] }; + const g: Record = {}; + for (const a of visible) { + const letter = a.name[0]?.toUpperCase() ?? '#'; + const key = /^[A-Z]$/.test(letter) ? letter : '#'; + if (!g[key]) g[key] = []; + g[key].push(a); + } + return { groups: g, letters: Object.keys(g).sort() }; + }, [visible, viewMode]); + + const composerListFlatRows = useMemo((): ComposerListFlatRow[] => { + if (viewMode !== 'list') return []; + const out: ComposerListFlatRow[] = []; + for (const letter of letters) { + out.push({ kind: 'letter', letter }); + const group = groups[letter]; + for (let i = 0; i < group.length; i++) { + out.push({ kind: 'artist', artist: group[i], isLastInLetter: i === group.length - 1 }); + } + } + return out; + }, [viewMode, letters, groups]); + + const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID); + const 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 ( +
+
+

{t('composers.title')}

+
+
+ {loadError === 'unsupported' ? t('composers.unsupported') : t('composers.loadFailed')} + {loadError === 'transient' && ( +
+ +
+ )} +
+
+ ); + } + + return ( +
+
+
+
+

{t('composers.title')}

+ setFilter(e.target.value)} + id="composer-filter-input" + /> +
+ +
+ + + +
+
+ +
+ {ALPHABET.map(l => ( + + ))} +
+
+ + {loading &&
} + + {!loading && viewMode === 'grid' && ( +
+ {visible.map(artist => ( +
navigate(`/composer/${artist.id}`)} + onContextMenu={(e) => { + e.preventDefault(); + openContextMenu(e.clientX, e.clientY, artist, 'artist', undefined, undefined, undefined, 'composer'); + }} + > +
{artist.name}
+ {artist.albumCount != null && ( +
+ {t('composers.involvedIn', { count: artist.albumCount })} +
+ )} +
+ ))} +
+ )} + + {!loading && viewMode === 'list' && ( + perfFlags.disableMainstageVirtualLists ? ( + <> + {letters.map(letter => ( +
+

{letter}

+
+ {groups[letter].map(artist => ( + + ))} +
+
+ ))} + + ) : ( +
+
+ {composerListVirtualizer.getVirtualItems().map(vi => { + const row = composerListFlatRows[vi.index]; + if (!row) return null; + if (row.kind === 'letter') { + return ( +
+

{row.letter}

+
+ ); + } + const artist = row.artist; + return ( +
+ +
+ ); + })} +
+
+ ) + )} + + {!loading && hasMore && ( +
+ {loadingMore &&
} +
+ )} + + {!loading && filtered.length === 0 && ( +
+ {t('composers.notFound')} +
+ )} +
+ ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index fdbaf1d4..585aab7a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index cdab27c0..a581bebc 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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()( 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 }, diff --git a/src/store/sidebarStore.ts b/src/store/sidebarStore.ts index 82eff057..b6ab00ae 100644 --- a/src/store/sidebarStore.ts +++ b/src/store/sidebarStore.ts @@ -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 }, diff --git a/src/styles/components.css b/src/styles/components.css index 9d8664fb..30bcc98e 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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; diff --git a/src/utils/applySharePaste.ts b/src/utils/applySharePaste.ts index 3d097aad..401ebd56 100644 --- a/src/utils/applySharePaste.ts +++ b/src/utils/applySharePaste.ts @@ -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) { diff --git a/src/utils/copyEntityShareLink.ts b/src/utils/copyEntityShareLink.ts index 303cf6c7..e6662504 100644 --- a/src/utils/copyEntityShareLink.ts +++ b/src/utils/copyEntityShareLink.ts @@ -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 { const srv = useAuthStore.getState().getBaseUrl(); if (!srv || !id.trim()) return false; diff --git a/src/utils/shareLink.ts b/src/utils/shareLink.ts index 36d04a36..27036fbe 100644 --- a/src/utils/shareLink.ts +++ b/src/utils/shareLink.ts @@ -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 {