feat(artists): album vs track credit mode browse (#1209) (#1232)

This commit is contained in:
cucadmuh
2026-07-04 17:39:58 +03:00
committed by GitHub
parent 77ab95170d
commit bf0e73ce6c
35 changed files with 601 additions and 54 deletions
+7
View File
@@ -13,6 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Added ## Added
### Artists browse — album vs track credit mode
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1232](https://github.com/Psychotoxical/psysonic/pull/1232)**
* Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes performers from the local artist index (featured/guest credits). Star filter works in both modes; the choice persists across app restarts like **Show artist images**.
* Letter bucket filter (`A``Z`, `#`, `OTHER`) runs in local SQL instead of scanning catalog chunks client-side, so late-alphabet picks load promptly on large libraries.
### Square corners — sharp-edged cards and covers ### Square corners — sharp-edged cards and covers
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1215](https://github.com/Psychotoxical/psysonic/pull/1215)** **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1215](https://github.com/Psychotoxical/psysonic/pull/1215)**
+4 -2
View File
@@ -84,7 +84,7 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
1. **One pull request, one coherent goal.** Easier review, easier revert, fewer merge conflicts. 1. **One pull request, one coherent goal.** Easier review, easier revert, fewer merge conflicts.
2. **Match existing style** in touched files (naming, module layout, comment density). Avoid drive-by refactors unrelated to the task. 2. **Match existing style** in touched files (naming, module layout, comment density). Avoid drive-by refactors unrelated to the task.
3. **Linting and formatting:** ESLint (strict `eslint.config.mjs`) runs in CI on frontend paths; run `npm run lint` locally before opening a frontend PR. `tsc --noEmit` is also required. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt. 3. **Linting and formatting:** ESLint (strict `eslint.config.mjs`) and **`npm run dep:check`** (dependency-cruiser layering/cycle guard) run in CI on frontend paths; run both locally before opening a frontend PR. `tsc --noEmit` is also required. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
4. **Commit messages:** a short **human-readable** summary of what changed and why; Conventional Commits-style prefixes (`feat:`, `fix:`, ...) are fine if you prefer them. Do not include meta references (IDEs, assistants, or how the message was produced) — only what matters for project history. 4. **Commit messages:** a short **human-readable** summary of what changed and why; Conventional Commits-style prefixes (`feat:`, `fix:`, ...) are fine if you prefer them. Do not include meta references (IDEs, assistants, or how the message was produced) — only what matters for project history.
5. **License:** new code must remain compatible with the project's GPLv3. 5. **License:** new code must remain compatible with the project's GPLv3.
6. **Tests:** when you change behaviour users rely on, add or update tests next to the code (see [`src/test/README.md`](src/test/README.md)). Purely visual tweaks may not need tests, but behavioural regressions should be covered where the suite can catch them. 6. **Tests:** when you change behaviour users rely on, add or update tests next to the code (see [`src/test/README.md`](src/test/README.md)). Purely visual tweaks may not need tests, but behavioural regressions should be covered where the suite can catch them.
@@ -114,7 +114,7 @@ PRs must target `main`. `next` and `release` are maintainer-driven promotion bra
Workflows are path-filtered (see the YAML for exact `paths` / `paths-ignore`): Workflows are path-filtered (see the YAML for exact `paths` / `paths-ignore`):
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, ESLint config, etc.): `npm run lint` (ESLint strict), `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run. - **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, ESLint config, dependency-cruiser config, etc.): `npm run lint`, **`npm run dep:check`** (layering + cycle guard), `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
- **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage. - **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage.
The **`ci-ok`** job in [`ci-main.yml`](.github/workflows/ci-main.yml) is the merge gate: it waits for every required job above whose path filter matched the PR, and fails if any of them failed or did not finish in time. The **`ci-ok`** job in [`ci-main.yml`](.github/workflows/ci-main.yml) is the merge gate: it waits for every required job above whose path filter matched the PR, and fails if any of them failed or did not finish in time.
@@ -132,7 +132,9 @@ Assume the repository root is `psysonic/` (for example after `git clone https://
```bash ```bash
npm ci npm ci
npm run lint npm run lint
npm run dep:check
npm test npm test
npm run prebuild:release-notes
npx tsc --noEmit npx tsc --noEmit
npm run test:coverage npm run test:coverage
bash scripts/check-frontend-hot-path-coverage.sh bash scripts/check-frontend-hot-path-coverage.sh
@@ -15,7 +15,8 @@ use rusqlite::{params, OptionalExtension};
use serde_json::Value; use serde_json::Value;
use crate::dto::{ use crate::dto::{
LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto, LibraryArtistDto, ArtistCreditMode, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto,
LibraryArtistDto,
LibraryFilterClause, LibrarySearchTotals, LibrarySortClause, LibraryTrackDto, SortDir, LibraryFilterClause, LibrarySearchTotals, LibrarySortClause, LibraryTrackDto, SortDir,
}; };
use crate::filter::{self, EntityKind, FilterOp, SqlFragment}; use crate::filter::{self, EntityKind, FilterOp, SqlFragment};
@@ -480,6 +481,45 @@ fn build_album_from_tracks(
) )
} }
fn album_artist_credit_mode(req: &LibraryAdvancedSearchRequest) -> bool {
!matches!(req.artist_credit_mode, Some(ArtistCreditMode::Track))
}
/// Letter bucket filter on `name_sort` (articles already stripped in column).
fn push_artist_letter_bucket(w: &mut WhereBuilder, bucket: &str, applied: &mut BTreeSet<String>) {
if bucket.is_empty() || bucket.eq_ignore_ascii_case("ALL") {
return;
}
let col = "COALESCE(ar.name_sort, ar.name)";
match bucket {
"#" => {
w.push_raw(&format!("SUBSTR({col}, 1, 1) GLOB '[0-9]'"));
}
"OTHER" => {
w.push_raw(&format!(
"LENGTH({col}) > 0 \
AND SUBSTR({col}, 1, 1) NOT GLOB '[0-9]' \
AND LOWER(SUBSTR({col}, 1, 1)) NOT GLOB '[a-z]'"
));
}
letter if letter.len() == 1 => {
let Some(ch) = letter.chars().next() else {
return;
};
if !ch.is_ascii_alphabetic() {
return;
}
let lower = ch.to_ascii_lowercase().to_string();
w.push_param(
&format!("LOWER(SUBSTR({col}, 1, 1)) = ?"),
SqlValue::Text(lower),
);
}
_ => return,
}
applied.insert("letter".to_string());
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn build_artist( fn build_artist(
store: &LibraryStore, store: &LibraryStore,
@@ -491,11 +531,9 @@ fn build_artist(
skip_totals: bool, skip_totals: bool,
applied: &mut BTreeSet<String>, applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryArtistDto>, u32), String> { ) -> Result<(Vec<LibraryArtistDto>, u32), String> {
// #1209: browse uses a single `artist` table path — no FTS / track fallthrough.
if !scalar_requires_track_derived_entities(scalar) { if !scalar_requires_track_derived_entities(scalar) {
let table = build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?; return build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied);
if !table.0.is_empty() || table.1 > 0 {
return Ok(table);
}
} }
if let Some(q) = text.and_then(|t| fts_column_prefix_query("artist", t)) { if let Some(q) = text.and_then(|t| fts_column_prefix_query("artist", t)) {
return build_artist_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied); return build_artist_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
@@ -516,6 +554,12 @@ fn build_artist_from_table(
) -> Result<(Vec<LibraryArtistDto>, u32), String> { ) -> Result<(Vec<LibraryArtistDto>, u32), String> {
let mut w = WhereBuilder::new(); let mut w = WhereBuilder::new();
w.push_param("ar.server_id = ?", SqlValue::Text(req.server_id.clone())); w.push_param("ar.server_id = ?", SqlValue::Text(req.server_id.clone()));
if album_artist_credit_mode(req) {
w.push_raw("ar.album_count IS NOT NULL");
}
if let Some(bucket) = req.artist_letter_bucket.as_deref() {
push_artist_letter_bucket(&mut w, bucket, applied);
}
if let Some(t) = text { if let Some(t) = text {
w.push_param("ar.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); w.push_param("ar.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string()); applied.insert("text".to_string());
@@ -1461,15 +1505,7 @@ mod tests {
} }
fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) { fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) {
store insert_artist_with_album_count(store, server, id, name, Some(1));
.with_conn("misc", |c| {
c.execute(
"INSERT INTO artist (server_id, id, name, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 1, '{}')",
rusqlite::params![server, id, name],
)
})
.unwrap();
} }
fn req(server: &str, entities: &[EntityKind]) -> LibraryAdvancedSearchRequest { fn req(server: &str, entities: &[EntityKind]) -> LibraryAdvancedSearchRequest {
@@ -1486,9 +1522,29 @@ mod tests {
limit: 50, limit: 50,
offset: 0, offset: 0,
skip_totals: false, skip_totals: false,
artist_credit_mode: None,
artist_letter_bucket: None,
} }
} }
fn insert_artist_with_album_count(
store: &LibraryStore,
server: &str,
id: &str,
name: &str,
album_count: Option<i64>,
) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO artist (server_id, id, name, album_count, synced_at, raw_json) \
VALUES (?1, ?2, ?3, ?4, 1, '{}')",
rusqlite::params![server, id, name, album_count],
)
})
.unwrap();
}
fn clause(field: &str, op: FilterOp, value: Option<Value>, value_to: Option<Value>) -> LibraryFilterClause { fn clause(field: &str, op: FilterOp, value: Option<Value>, value_to: Option<Value>) -> LibraryFilterClause {
LibraryFilterClause { LibraryFilterClause {
field: field.into(), field: field.into(),
@@ -1567,8 +1623,79 @@ mod tests {
assert_eq!(resp.albums.len(), 1); assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_Aurora Nights"); assert_eq!(resp.albums[0].id, "al_Aurora Nights");
assert_eq!(resp.albums[0].cover_art_id.as_deref(), Some("cv1")); assert_eq!(resp.albums[0].cover_art_id.as_deref(), Some("cv1"));
// Artist rows come from the `artist` table only (#1209) — not track fallthrough.
assert!(resp.artists.is_empty());
}
#[test]
fn artist_credit_album_mode_excludes_backfill_only_rows() {
let store = LibraryStore::open_in_memory();
insert_artist_with_album_count(&store, "s1", "ar_va", "Various Artists", Some(12));
insert_artist_with_album_count(&store, "s1", "ar_guest", "Soundtrack Guest", None);
let mut r = req("s1", &[EntityKind::Artist]);
r.artist_credit_mode = Some(ArtistCreditMode::Album);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.artists.len(), 1); assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].id, "ar_Aurora Quartet"); assert_eq!(resp.artists[0].id, "ar_va");
}
#[test]
fn artist_credit_track_mode_includes_backfill_rows() {
let store = LibraryStore::open_in_memory();
insert_artist_with_album_count(&store, "s1", "ar_va", "Various Artists", Some(12));
insert_artist_with_album_count(&store, "s1", "ar_guest", "Soundtrack Guest", None);
let mut r = req("s1", &[EntityKind::Artist]);
r.artist_credit_mode = Some(ArtistCreditMode::Track);
r.sort = vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
}];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.artists.len(), 2);
assert_eq!(resp.artists[0].id, "ar_guest");
assert_eq!(resp.artists[1].id, "ar_va");
}
#[test]
fn artist_credit_album_mode_text_search_uses_artist_table_only() {
let store = LibraryStore::open_in_memory();
insert_artist_with_album_count(&store, "s1", "ar_va", "Various Artists", Some(12));
insert_artist_with_album_count(&store, "s1", "ar_guest", "Soundtrack Guest", None);
let mut r = req("s1", &[EntityKind::Artist]);
r.query = Some("guest".into());
r.artist_credit_mode = Some(ArtistCreditMode::Album);
let resp = run_advanced_search(&store, &r).unwrap();
assert!(resp.artists.is_empty());
let mut r2 = req("s1", &[EntityKind::Artist]);
r2.query = Some("guest".into());
r2.artist_credit_mode = Some(ArtistCreditMode::Track);
let resp2 = run_advanced_search(&store, &r2).unwrap();
assert_eq!(resp2.artists.len(), 1);
assert_eq!(resp2.artists[0].id, "ar_guest");
}
#[test]
fn artist_letter_bucket_filters_by_name_sort_prefix() {
let store = LibraryStore::open_in_memory();
insert_artist_with_album_count(&store, "s1", "ar_a", "Alpha", Some(1));
insert_artist_with_album_count(&store, "s1", "ar_m", "Mike", Some(1));
store
.with_conn("misc", |c| {
c.execute(
"UPDATE artist SET name_sort = 'alpha' WHERE id = 'ar_a'",
[],
)?;
c.execute(
"UPDATE artist SET name_sort = 'mike' WHERE id = 'ar_m'",
[],
)
})
.unwrap();
let mut r = req("s1", &[EntityKind::Artist]);
r.artist_letter_bucket = Some("M".into());
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].id, "ar_m");
} }
#[test] #[test]
@@ -523,6 +523,15 @@ pub enum SortDir {
Desc, Desc,
} }
/// Artist browse credit semantics when `entity_types` includes `artist` (#1209).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum ArtistCreditMode {
#[default]
Album,
Track,
}
/// `library_advanced_search` request (§5.13.2). `query` is shorthand for an /// `library_advanced_search` request (§5.13.2). `query` is shorthand for an
/// `fts` clause on the text fields; `entityTypes` controls which of the /// `fts` clause on the text fields; `entityTypes` controls which of the
/// three queries run; `filters` are combined with AND. /// three queries run; `filters` are combined with AND.
@@ -555,6 +564,13 @@ pub struct LibraryAdvancedSearchRequest {
/// When true, skip per-entity COUNT queries (Live Search / small pages). /// When true, skip per-entity COUNT queries (Live Search / small pages).
#[serde(default)] #[serde(default)]
pub skip_totals: bool, pub skip_totals: bool,
/// Album-artist vs track-performer browse when querying `artist` (#1209).
/// `None` is treated as [`ArtistCreditMode::Album`].
#[serde(default)]
pub artist_credit_mode: Option<ArtistCreditMode>,
/// AZ / `#` / `OTHER` / omit or `ALL` — letter bucket on `name_sort` (#1209 browse).
#[serde(default)]
pub artist_letter_bucket: Option<String>,
} }
/// Per-entity result counts (full match count, not page size). /// Per-entity result counts (full match count, not page size).
+1
View File
@@ -179,6 +179,7 @@ const CONTRIBUTOR_ENTRIES = [
'Connection recovery — shared connection status across hook instances so manual Retry clears offline sidebar gating with the header indicator (PR #1190)', 'Connection recovery — shared connection status across hook instances so manual Retry clears offline sidebar gating with the header indicator (PR #1190)',
'Timeline play history — session buffer + play_session bootstrap across queue replace; pin current to top; history replay inserts in-place (PR #1204)', 'Timeline play history — session buffer + play_session bootstrap across queue replace; pin current to top; history replay inserts in-place (PR #1204)',
'Playback — ReplayGain index prefetch, gapless playbar sync, library replayGainPeak column, live RG refresh after sync (PR #1231)', 'Playback — ReplayGain index prefetch, gapless playbar sync, library replayGainPeak column, live RG refresh after sync (PR #1231)',
'Artists browse — album vs track credit mode toggle, starred favorites in both modes, persisted credit mode, SQL letter-bucket filter (PR #1232)',
], ],
}, },
{ {
@@ -1,11 +1,14 @@
import { getArtists } from '@/lib/api/subsonicArtists';
import type { SubsonicArtist } from '@/lib/api/subsonicTypes'; import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import type { ArtistCreditMode } from '@/lib/api/library';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { dedupeById } from '@/lib/util/dedupeById'; import { dedupeById } from '@/lib/util/dedupeById';
import { import {
fetchLocalArtistCatalogChunk, fetchLocalArtistCatalogChunk,
fetchNetworkStarredArtists,
} from '@/lib/library/browseTextSearch'; } from '@/lib/library/browseTextSearch';
import {
fetchNetworkArtistCatalog,
fetchStarredArtistsForBrowse,
} from '@/features/artist/utils/artistBrowseCreditMode';
import { useOfflineBrowseContext } from '@/features/offline'; import { useOfflineBrowseContext } from '@/features/offline';
import { useOfflineBrowseReloadToken } from '@/features/offline'; import { useOfflineBrowseReloadToken } from '@/features/offline';
import { import {
@@ -23,6 +26,8 @@ export type UseArtistsBrowseCatalogArgs = {
serverId: string | null | undefined; serverId: string | null | undefined;
indexEnabled: boolean; indexEnabled: boolean;
starredOnly: boolean; starredOnly: boolean;
creditMode: ArtistCreditMode;
letterFilter: string;
musicLibraryFilterVersion: number; musicLibraryFilterVersion: number;
}; };
@@ -30,6 +35,8 @@ export function useArtistsBrowseCatalog({
serverId, serverId,
indexEnabled, indexEnabled,
starredOnly, starredOnly,
creditMode,
letterFilter,
musicLibraryFilterVersion, musicLibraryFilterVersion,
}: UseArtistsBrowseCatalogArgs) { }: UseArtistsBrowseCatalogArgs) {
const offlineBrowseActive = useOfflineBrowseContext().active; const offlineBrowseActive = useOfflineBrowseContext().active;
@@ -56,8 +63,14 @@ export function useArtistsBrowseCatalog({
serverId, serverId,
catalogOffsetRef.current, catalogOffsetRef.current,
ARTIST_CATALOG_CHUNK_SIZE, ARTIST_CATALOG_CHUNK_SIZE,
creditMode,
letterFilter,
); );
if (generation !== loadGenerationRef.current || chunk == null) return; if (generation !== loadGenerationRef.current) return;
if (chunk == null) {
if (append) setCatalogHasMore(false);
return;
}
if (append) { if (append) {
setCatalogArtists(prev => { setCatalogArtists(prev => {
const merged = dedupeById([...prev, ...chunk.artists]); const merged = dedupeById([...prev, ...chunk.artists]);
@@ -75,8 +88,14 @@ export function useArtistsBrowseCatalog({
serverId, serverId,
catalogOffsetRef.current, catalogOffsetRef.current,
ARTIST_CATALOG_CHUNK_SIZE, ARTIST_CATALOG_CHUNK_SIZE,
creditMode,
letterFilter,
); );
if (generation !== loadGenerationRef.current || chunk == null) return; if (generation !== loadGenerationRef.current) return;
if (chunk == null) {
if (append) setCatalogHasMore(false);
return;
}
if (append) { if (append) {
setCatalogArtists(prev => { setCatalogArtists(prev => {
const merged = dedupeById([...prev, ...chunk.artists]); const merged = dedupeById([...prev, ...chunk.artists]);
@@ -95,7 +114,7 @@ export function useArtistsBrowseCatalog({
setCatalogLoadingMore(false); setCatalogLoadingMore(false);
} }
} }
}, [offlineBrowseActive, serverId]); }, [creditMode, letterFilter, offlineBrowseActive, serverId]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -115,12 +134,20 @@ export function useArtistsBrowseCatalog({
if (offlineBrowseActive) { if (offlineBrowseActive) {
if (!cancelled && generation === loadGenerationRef.current) { if (!cancelled && generation === loadGenerationRef.current) {
if (serverId && starredOnly && offlineLocalBrowseEnabled(serverId)) { if (serverId && starredOnly && offlineLocalBrowseEnabled(serverId)) {
try {
setCatalogArtists(
await fetchStarredArtistsForBrowse(creditMode, serverId, true),
);
} catch {
setCatalogArtists((await fetchOfflineLocalStarredArtists(serverId)) ?? []); setCatalogArtists((await fetchOfflineLocalStarredArtists(serverId)) ?? []);
}
} else if (serverId && !starredOnly && offlineLocalBrowseEnabled(serverId)) { } else if (serverId && !starredOnly && offlineLocalBrowseEnabled(serverId)) {
const first = await fetchOfflineLocalArtistCatalogChunk( const first = await fetchOfflineLocalArtistCatalogChunk(
serverId, serverId,
0, 0,
ARTIST_CATALOG_CHUNK_SIZE, ARTIST_CATALOG_CHUNK_SIZE,
creditMode,
letterFilter,
); );
setCatalogArtists(first?.artists ?? []); setCatalogArtists(first?.artists ?? []);
catalogOffsetRef.current = first?.artists.length ?? 0; catalogOffsetRef.current = first?.artists.length ?? 0;
@@ -135,7 +162,11 @@ export function useArtistsBrowseCatalog({
} }
if (starredOnly) { if (starredOnly) {
if (!cancelled && generation === loadGenerationRef.current) { if (!cancelled && generation === loadGenerationRef.current) {
setCatalogArtists(await fetchNetworkStarredArtists()); setCatalogArtists(
await fetchStarredArtistsForBrowse(creditMode, serverId, indexEnabled),
);
setBrowseMode('network');
setCatalogHasMore(false);
} }
return; return;
} }
@@ -144,6 +175,8 @@ export function useArtistsBrowseCatalog({
serverId, serverId,
0, 0,
ARTIST_CATALOG_CHUNK_SIZE, ARTIST_CATALOG_CHUNK_SIZE,
creditMode,
letterFilter,
); );
if (cancelled || generation !== loadGenerationRef.current) return; if (cancelled || generation !== loadGenerationRef.current) return;
if (first != null) { if (first != null) {
@@ -155,12 +188,12 @@ export function useArtistsBrowseCatalog({
} }
} }
if (!cancelled && generation === loadGenerationRef.current) { if (!cancelled && generation === loadGenerationRef.current) {
setCatalogArtists(await getArtists()); setCatalogArtists(await fetchNetworkArtistCatalog(creditMode));
} }
} catch { } catch {
/* ignore */ /* ignore */
} finally { } finally {
if (!cancelled && generation === loadGenerationRef.current) { if (generation === loadGenerationRef.current) {
setLoading(false); setLoading(false);
} }
} }
@@ -169,7 +202,7 @@ export function useArtistsBrowseCatalog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]); }, [creditMode, letterFilter, musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
return { return {
catalogArtists, catalogArtists,
@@ -8,7 +8,9 @@ import {
isArtistsBrowsePath, isArtistsBrowsePath,
useArtistBrowseSessionStore, useArtistBrowseSessionStore,
} from '@/features/artist/store/artistBrowseSessionStore'; } from '@/features/artist/store/artistBrowseSessionStore';
import type { ArtistCreditMode } from '@/lib/api/library';
import { isArtistDetailPath } from '@/features/album'; import { isArtistDetailPath } from '@/features/album';
import { ALL_SENTINEL } from '@/features/artist/utils/artistsHelpers';
import { shouldRestoreArtistBrowseSession } from '@/lib/navigation/albumDetailNavigation'; import { shouldRestoreArtistBrowseSession } from '@/lib/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore'; import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
@@ -38,11 +40,13 @@ export function useArtistsBrowseFilters(
const navigationType = useNavigationType(); const navigationType = useNavigationType();
const location = useLocation(); const location = useLocation();
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages); const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const creditMode = useAuthStore(s => s.artistBrowseCreditMode);
const setArtistBrowseCreditMode = useAuthStore(s => s.setArtistBrowseCreditMode);
const [letterFilter, setLetterFilter] = useState( const [letterFilter, setLetterFilter] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter, () => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
); );
const [starredOnly, setStarredOnly] = useState( const [starredOnly, setStarredOnlyRaw] = useState(
() => returnStateForNavigation(serverId, navigationType, location.state).starredOnly, () => returnStateForNavigation(serverId, navigationType, location.state).starredOnly,
); );
const [viewMode, setViewMode] = useState<ArtistBrowseViewMode>( const [viewMode, setViewMode] = useState<ArtistBrowseViewMode>(
@@ -59,10 +63,22 @@ export function useArtistsBrowseFilters(
filter: useLiveSearchScopeStore.getState().query, filter: useLiveSearchScopeStore.getState().query,
letterFilter, letterFilter,
starredOnly, starredOnly,
creditMode,
viewMode, viewMode,
showArtistImages, showArtistImages,
}; };
const setCreditMode = (next: ArtistCreditMode) => {
if (next === creditMode) return;
setArtistBrowseCreditMode(next);
setLetterFilter(ALL_SENTINEL);
};
const setStarredOnly = (next: boolean) => {
if (next) useLiveSearchScopeStore.getState().setQuery('');
setStarredOnlyRaw(next);
};
useEffect(() => { useEffect(() => {
restoredFromStashRef.current = false; restoredFromStashRef.current = false;
}, [serverId]); }, [serverId]);
@@ -78,7 +94,8 @@ export function useArtistsBrowseFilters(
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change. // React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setLetterFilter(restored.letterFilter); setLetterFilter(restored.letterFilter);
setStarredOnly(restored.starredOnly); setStarredOnlyRaw(restored.starredOnly);
setArtistBrowseCreditMode(restored.creditMode ?? 'album');
setViewMode(restored.viewMode); setViewMode(restored.viewMode);
setShowArtistImages(restored.showArtistImages); setShowArtistImages(restored.showArtistImages);
} }
@@ -90,9 +107,9 @@ export function useArtistsBrowseFilters(
useArtistBrowseSessionStore.getState().clearReturnStash(serverId); useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
useLiveSearchScopeStore.getState().setQuery(''); useLiveSearchScopeStore.getState().setQuery('');
setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter); setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter);
setStarredOnly(false); setStarredOnlyRaw(false);
setViewMode('grid'); setViewMode('grid');
}, [serverId, navigationType, location.state, setShowArtistImages]); }, [serverId, navigationType, location.state, setShowArtistImages, setArtistBrowseCreditMode]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -119,6 +136,8 @@ export function useArtistsBrowseFilters(
setLetterFilter, setLetterFilter,
starredOnly, starredOnly,
setStarredOnly, setStarredOnly,
creditMode,
setCreditMode,
viewMode, viewMode,
setViewMode, setViewMode,
}; };
@@ -1,4 +1,5 @@
import type { SubsonicArtist } from '@/lib/api/subsonicTypes'; import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import type { ArtistCreditMode } from '@/lib/api/library';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
BROWSE_TEXT_DEBOUNCE_NETWORK_MS, BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
@@ -23,6 +24,8 @@ export function useBrowseArtistTextSearch(
indexEnabled: boolean, indexEnabled: boolean,
serverId: string | null | undefined, serverId: string | null | undefined,
surface: LibrarySearchSurface = 'artists_browse', surface: LibrarySearchSurface = 'artists_browse',
creditMode: ArtistCreditMode = 'album',
starredOnly = false,
) { ) {
const offlineBrowseActive = useOfflineBrowseContext().active; const offlineBrowseActive = useOfflineBrowseContext().active;
const [debouncedFilter, setDebouncedFilter] = useState(''); const [debouncedFilter, setDebouncedFilter] = useState('');
@@ -38,7 +41,7 @@ export function useBrowseArtistTextSearch(
useEffect(() => { useEffect(() => {
const q = debouncedFilter; const q = debouncedFilter;
if (!q || !indexEnabled || !serverId) { if (starredOnly || !q || !indexEnabled || !serverId) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback. // React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setTextSearchArtists(null); setTextSearchArtists(null);
@@ -53,7 +56,7 @@ export function useBrowseArtistTextSearch(
void (async () => { void (async () => {
if (offlineBrowseActive) { if (offlineBrowseActive) {
const artists = offlineLocalBrowseEnabled(serverId) const artists = offlineLocalBrowseEnabled(serverId)
? await searchOfflineLocalArtists(serverId, q) ? await searchOfflineLocalArtists(serverId, q, creditMode)
: []; : [];
if (isStale()) return; if (isStale()) return;
setTextSearchArtists(artists); setTextSearchArtists(artists);
@@ -62,8 +65,8 @@ export function useBrowseArtistTextSearch(
} }
const outcome = await raceBrowseWithLocalFallback( const outcome = await raceBrowseWithLocalFallback(
isStale, isStale,
() => runLocalBrowseArtists(serverId, q), () => runLocalBrowseArtists(serverId, q, creditMode),
() => runNetworkBrowseArtists(q), () => runNetworkBrowseArtists(q, creditMode),
{ {
surface, surface,
query: q, query: q,
@@ -75,7 +78,7 @@ export function useBrowseArtistTextSearch(
setTextSearchArtists(outcome?.result ?? null); setTextSearchArtists(outcome?.result ?? null);
setTextSearchLoading(false); setTextSearchLoading(false);
})(); })();
}, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, surface]); }, [creditMode, debouncedFilter, indexEnabled, offlineBrowseActive, serverId, starredOnly, surface]);
const effectiveFilter = textSearchArtists != null ? '' : filter; const effectiveFilter = textSearchArtists != null ? '' : filter;
return { textSearchArtists, textSearchLoading, effectiveFilter }; return { textSearchArtists, textSearchLoading, effectiveFilter };
+44 -5
View File
@@ -36,6 +36,7 @@ import { useArtistsBrowseScrollRestore } from '@/features/artist/hooks/useArtist
import { useArtistsBrowseScrollReset } from '@/features/artist/hooks/useArtistsBrowseScrollReset'; import { useArtistsBrowseScrollReset } from '@/features/artist/hooks/useArtistsBrowseScrollReset';
import { useNavigateToArtist } from '@/features/artist/hooks/useNavigateToArtist'; import { useNavigateToArtist } from '@/features/artist/hooks/useNavigateToArtist';
import { peekArtistBrowseScrollRestore } from '@/features/artist/store/artistBrowseSessionStore'; import { peekArtistBrowseScrollRestore } from '@/features/artist/store/artistBrowseSessionStore';
import { nextArtistCreditMode } from '@/features/artist/utils/artistBrowseCreditMode';
import { readArtistBrowseRestore } from '@/lib/navigation/albumDetailNavigation'; import { readArtistBrowseRestore } from '@/lib/navigation/albumDetailNavigation';
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore'; import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
@@ -58,6 +59,8 @@ export default function Artists() {
setLetterFilter, setLetterFilter,
starredOnly, starredOnly,
setStarredOnly, setStarredOnly,
creditMode,
setCreditMode,
viewMode, viewMode,
setViewMode, setViewMode,
} = useArtistsBrowseFilters(serverId, scrollSnapshotRef); } = useArtistsBrowseFilters(serverId, scrollSnapshotRef);
@@ -90,6 +93,8 @@ export default function Artists() {
serverId, serverId,
indexEnabled, indexEnabled,
starredOnly, starredOnly,
creditMode,
letterFilter,
musicLibraryFilterVersion, musicLibraryFilterVersion,
}); });
@@ -97,10 +102,13 @@ export default function Artists() {
artistsSearchQuery, artistsSearchQuery,
indexEnabled, indexEnabled,
serverId, serverId,
'artists_browse',
creditMode,
starredOnly,
); );
const artists = textSearchArtists ?? catalogArtists; const artists = starredOnly ? catalogArtists : (textSearchArtists ?? catalogArtists);
const loading = catalogLoading || textSearchLoading; const loading = starredOnly ? catalogLoading : (catalogLoading || textSearchLoading);
const textSearchActive = textSearchArtists != null; const textSearchActive = !starredOnly && textSearchArtists != null;
/** Scoped/plain text filter — canonical CSS grid, not row virtualization (small result sets). */ /** Scoped/plain text filter — canonical CSS grid, not row virtualization (small result sets). */
const artistBrowsePlainLayout = const artistBrowsePlainLayout =
perfFlags.disableMainstageVirtualLists perfFlags.disableMainstageVirtualLists
@@ -113,7 +121,7 @@ export default function Artists() {
loadMore: sliceLoadMore, loadMore: sliceLoadMore,
} = useClientSliceInfiniteScroll({ } = useClientSliceInfiniteScroll({
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
resetDeps: [artistsSearchQuery, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId], resetDeps: [artistsSearchQuery, letterFilter, starredOnly, creditMode, viewMode, musicLibraryFilterVersion, serverId],
getScrollRoot: getArtistsScrollRoot, getScrollRoot: getArtistsScrollRoot,
scrollRootEl: artistsScrollBodyEl, scrollRootEl: artistsScrollBodyEl,
restoreDisplayCount: restoreVisibleCountRef.current, restoreDisplayCount: restoreVisibleCountRef.current,
@@ -294,6 +302,7 @@ export default function Artists() {
artistsSearchQuery, artistsSearchQuery,
letterFilter, letterFilter,
starredOnly, starredOnly,
creditMode,
viewMode, viewMode,
serverId, serverId,
musicLibraryFilterVersion, musicLibraryFilterVersion,
@@ -331,7 +340,37 @@ export default function Artists() {
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{!(selectionMode && selectedIds.size > 0) && (<> {!(selectionMode && selectedIds.size > 0) && (<>
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} /> <StarFilterButton
size="compact"
active={starredOnly}
onChange={setStarredOnly}
/>
<button
type="button"
className={`btn btn-surface${creditMode === 'track' ? ' btn-sort-active' : ''}`}
onClick={() => setCreditMode(nextArtistCreditMode(creditMode))}
data-tooltip={
creditMode === 'album'
? t('artists.browse.creditMode.tooltipTrack')
: t('artists.browse.creditMode.tooltipAlbum')
}
data-tooltip-wrap
data-tooltip-pos="bottom"
aria-label={
creditMode === 'album'
? t('artists.browse.creditMode.tooltipTrack')
: t('artists.browse.creditMode.tooltipAlbum')
}
style={
creditMode === 'track'
? { background: 'var(--accent)', color: 'var(--text-on-accent)' }
: undefined
}
>
{creditMode === 'album'
? t('artists.browse.creditMode.track')
: t('artists.browse.creditMode.album')}
</button>
<button <button
className={`btn btn-surface`} className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)} onClick={() => setShowArtistImages(!showArtistImages)}
@@ -24,6 +24,7 @@ describe('artistBrowseSessionStore', () => {
filter: 'mozart', filter: 'mozart',
letterFilter: 'M', letterFilter: 'M',
starredOnly: false, starredOnly: false,
creditMode: 'album',
viewMode: 'list', viewMode: 'list',
showArtistImages: true, showArtistImages: true,
scrollTop: 240, scrollTop: 240,
@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { ALL_SENTINEL } from '@/features/artist/utils/artistsHelpers'; import { ALL_SENTINEL } from '@/features/artist/utils/artistsHelpers';
import type { ArtistCreditMode } from '@/lib/api/library';
export type ArtistBrowseViewMode = 'grid' | 'list'; export type ArtistBrowseViewMode = 'grid' | 'list';
@@ -8,6 +9,7 @@ export interface ArtistBrowseReturnState {
filter: string; filter: string;
letterFilter: string; letterFilter: string;
starredOnly: boolean; starredOnly: boolean;
creditMode: ArtistCreditMode;
viewMode: ArtistBrowseViewMode; viewMode: ArtistBrowseViewMode;
showArtistImages: boolean; showArtistImages: boolean;
scrollTop?: number; scrollTop?: number;
@@ -18,6 +20,7 @@ export const DEFAULT_ARTIST_BROWSE_RETURN_STATE: ArtistBrowseReturnState = {
filter: '', filter: '',
letterFilter: ALL_SENTINEL, letterFilter: ALL_SENTINEL,
starredOnly: false, starredOnly: false,
creditMode: 'album',
viewMode: 'grid', viewMode: 'grid',
showArtistImages: true, showArtistImages: true,
}; };
@@ -41,6 +44,7 @@ export const useArtistBrowseSessionStore = create<ArtistBrowseSessionStore>((set
filter: state.filter, filter: state.filter,
letterFilter: state.letterFilter, letterFilter: state.letterFilter,
starredOnly: state.starredOnly, starredOnly: state.starredOnly,
creditMode: state.creditMode,
viewMode: state.viewMode, viewMode: state.viewMode,
showArtistImages: state.showArtistImages, showArtistImages: state.showArtistImages,
...(typeof state.scrollTop === 'number' ? { scrollTop: state.scrollTop } : {}), ...(typeof state.scrollTop === 'number' ? { scrollTop: state.scrollTop } : {}),
@@ -65,6 +69,7 @@ export const useArtistBrowseSessionStore = create<ArtistBrowseSessionStore>((set
filter: stash.filter, filter: stash.filter,
letterFilter: stash.letterFilter, letterFilter: stash.letterFilter,
starredOnly: stash.starredOnly, starredOnly: stash.starredOnly,
creditMode: stash.creditMode ?? 'album',
viewMode: stash.viewMode, viewMode: stash.viewMode,
showArtistImages: stash.showArtistImages, showArtistImages: stash.showArtistImages,
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}), ...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
@@ -0,0 +1,84 @@
import { getArtists } from '@/lib/api/subsonicArtists';
import { getStarred } from '@/lib/api/subsonicStarRating';
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import { libraryAdvancedSearch } from '@/lib/api/library';
import type { ArtistCreditMode } from '@/lib/api/library';
import { libraryScopeForServer } from '@/lib/api/subsonicClient';
import { libraryIsReady } from '@/lib/library/libraryReady';
import { ndListArtistsByRole } from '@/lib/api/navidromeBrowse';
/** Network artist catalog before local index is ready (#1209). */
export async function fetchNetworkArtistCatalog(
creditMode: ArtistCreditMode,
): Promise<SubsonicArtist[]> {
if (creditMode === 'track') {
try {
return await ndListArtistsByRole('performer', 0, 10_000);
} catch {
return getArtists();
}
}
return getArtists();
}
/** Artist ids in the current credit-mode catalog (local index). */
async function fetchLocalArtistIdsForMode(
serverId: string,
creditMode: ArtistCreditMode,
): Promise<Set<string> | null> {
if (!(await libraryIsReady(serverId))) return null;
try {
const resp = await libraryAdvancedSearch({
serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
entityTypes: ['artist'],
artistCreditMode: creditMode,
limit: 100_000,
offset: 0,
skipTotals: true,
});
if (resp.source !== 'local') return null;
return new Set(resp.artists.map(a => a.id));
} catch {
return null;
}
}
async function fetchNetworkArtistIdsForMode(
creditMode: ArtistCreditMode,
): Promise<Set<string>> {
return new Set((await fetchNetworkArtistCatalog(creditMode)).map(a => a.id));
}
/**
* `getStarred2` artist slice intersected with the active credit-mode catalog
* (album-artist index or full track performer set) works in both modes (#1209).
*/
export async function fetchStarredArtistsForBrowse(
creditMode: ArtistCreditMode,
serverId: string | null | undefined,
indexEnabled: boolean,
): Promise<SubsonicArtist[]> {
const { artists: starredRaw } = await getStarred();
const starred = starredRaw.map(a => ({ ...a, starred: a.starred ?? 'true' }));
if (starred.length === 0) return starred;
let scopeIds: Set<string> | null = null;
if (indexEnabled && serverId) {
scopeIds = await fetchLocalArtistIdsForMode(serverId, creditMode);
}
if (!scopeIds) {
try {
scopeIds = await fetchNetworkArtistIdsForMode(creditMode);
} catch {
return starred;
}
}
return starred.filter(a => scopeIds!.has(a.id));
}
export function nextArtistCreditMode(mode: ArtistCreditMode): ArtistCreditMode {
return mode === 'album' ? 'track' : 'album';
}
export type { ArtistCreditMode };
@@ -1,4 +1,4 @@
import type { LibraryTrackDto } from '@/lib/api/library'; import type { ArtistCreditMode, LibraryTrackDto } from '@/lib/api/library';
import { libraryAdvancedSearch, libraryGetTracksBatchChunked, libraryGetTracksByAlbum } from '@/lib/api/library'; import { libraryAdvancedSearch, libraryGetTracksBatchChunked, libraryGetTracksByAlbum } from '@/lib/api/library';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes'; import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import { useLibraryIndexStore } from '@/store/libraryIndexStore'; import { useLibraryIndexStore } from '@/store/libraryIndexStore';
@@ -181,12 +181,49 @@ export async function fetchOfflineLocalStarredArtists(serverId: string): Promise
return aggregateArtistsFromTracks(tracks, serverId); return aggregateArtistsFromTracks(tracks, serverId);
} }
async function fetchOfflineLocalArtistCatalogFromIndex(
serverId: string,
offset: number,
chunkSize: number,
creditMode: ArtistCreditMode,
letterBucket?: string | null,
): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> {
const bucket = letterBucket && letterBucket !== 'ALL' ? letterBucket : undefined;
try {
const resp = await libraryAdvancedSearch({
serverId,
entityTypes: ['artist'],
artistCreditMode: creditMode,
...(bucket ? { artistLetterBucket: bucket } : {}),
sort: [{ field: 'name', dir: 'asc' }],
limit: chunkSize,
offset,
skipTotals: true,
});
if (resp.source !== 'local') return null;
const artists = resp.artists.map(artistToArtist).map(a => ({ ...a, serverId }));
return { artists, hasMore: artists.length === chunkSize };
} catch {
return null;
}
}
export async function fetchOfflineLocalArtistCatalogChunk( export async function fetchOfflineLocalArtistCatalogChunk(
serverId: string, serverId: string,
offset: number, offset: number,
chunkSize: number, chunkSize: number,
creditMode: ArtistCreditMode = 'album',
letterBucket?: string | null,
): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> { ): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> {
if (!offlineLocalBrowseEnabled(serverId)) return null; if (!offlineLocalBrowseEnabled(serverId)) return null;
const fromIndex = await fetchOfflineLocalArtistCatalogFromIndex(
serverId,
offset,
chunkSize,
creditMode,
letterBucket,
);
if (fromIndex) return fromIndex;
const tracks = await fetchBrowsableLocalTrackDtos(serverId); const tracks = await fetchBrowsableLocalTrackDtos(serverId);
const artists = aggregateArtistsFromTracks(tracks, serverId); const artists = aggregateArtistsFromTracks(tracks, serverId);
const slice = artists.slice(offset, offset + chunkSize); const slice = artists.slice(offset, offset + chunkSize);
@@ -199,10 +236,30 @@ export async function fetchOfflineLocalArtistCatalogChunk(
export async function searchOfflineLocalArtists( export async function searchOfflineLocalArtists(
serverId: string, serverId: string,
query: string, query: string,
creditMode: ArtistCreditMode = 'album',
): Promise<SubsonicArtist[] | null> { ): Promise<SubsonicArtist[] | null> {
if (!offlineLocalBrowseEnabled(serverId)) return null; if (!offlineLocalBrowseEnabled(serverId)) return null;
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return []; if (!q) return [];
try {
const resp = await libraryAdvancedSearch({
serverId,
entityTypes: ['artist'],
artistCreditMode: creditMode,
query: q,
limit: 500,
offset: 0,
skipTotals: true,
});
if (resp.source === 'local') {
return resp.artists
.map(artistToArtist)
.map(a => ({ ...a, serverId }))
.filter(a => a.name.toLowerCase().includes(q));
}
} catch {
/* fall through */
}
const tracks = await fetchBrowsableLocalTrackDtos(serverId); const tracks = await fetchBrowsableLocalTrackDtos(serverId);
return aggregateArtistsFromTracks(tracks, serverId) return aggregateArtistsFromTracks(tracks, serverId)
.filter(a => a.name.toLowerCase().includes(q)); .filter(a => a.name.toLowerCase().includes(q));
+7
View File
@@ -181,6 +181,9 @@ export type FilterOperator = 'eq' | 'gte' | 'lte' | 'between' | 'fts' | 'is_true
export type SortDir = 'asc' | 'desc'; export type SortDir = 'asc' | 'desc';
/** Album-artist vs track-performer browse when querying artists (#1209). */
export type ArtistCreditMode = 'album' | 'track';
export interface LibraryFilterClause { export interface LibraryFilterClause {
field: string; // registry id, e.g. 'genre' | 'year' | 'bpm' field: string; // registry id, e.g. 'genre' | 'year' | 'bpm'
op: FilterOperator; op: FilterOperator;
@@ -209,6 +212,10 @@ export interface LibraryAdvancedSearchRequest {
skipTotals?: boolean; skipTotals?: boolean;
/** Album text query matches title/name only (All Albums scoped browse). */ /** Album text query matches title/name only (All Albums scoped browse). */
queryAlbumTitleOnly?: boolean | null; queryAlbumTitleOnly?: boolean | null;
/** Artist browse credit semantics (#1209). Omitted/null → album artists. */
artistCreditMode?: ArtistCreditMode | null;
/** AZ, `#`, `OTHER`, or omit/`ALL` — letter bucket on local artist browse. */
artistLetterBucket?: string | null;
} }
export interface LibraryAlbumDto { export interface LibraryAlbumDto {
+3
View File
@@ -51,6 +51,8 @@ export interface LocalSearchOpts {
resultType: AdvancedResultType; resultType: AdvancedResultType;
/** When searching albums, match album title only (not album artist). */ /** When searching albums, match album title only (not album artist). */
albumTitleOnly?: boolean; albumTitleOnly?: boolean;
/** Artist browse credit mode (#1209). */
artistCreditMode?: 'album' | 'track';
} }
export interface LocalAdvancedSearchPage { export interface LocalAdvancedSearchPage {
@@ -148,6 +150,7 @@ function buildRequest(
...(opts.resultType === 'albums' && opts.albumTitleOnly ...(opts.resultType === 'albums' && opts.albumTitleOnly
? { queryAlbumTitleOnly: true } ? { queryAlbumTitleOnly: true }
: {}), : {}),
...(opts.artistCreditMode ? { artistCreditMode: opts.artistCreditMode } : {}),
}; };
} }
+15 -3
View File
@@ -2,6 +2,8 @@
* Browse-page text search local index vs network race (LiveSearch / SearchBrowsePage pattern). * Browse-page text search local index vs network race (LiveSearch / SearchBrowsePage pattern).
*/ */
import { getStarred } from '@/lib/api/subsonicStarRating'; import { getStarred } from '@/lib/api/subsonicStarRating';
import { getArtists } from '@/lib/api/subsonicArtists';
import type { ArtistCreditMode } from '@/lib/api/library';
import { search, searchSongsPaged } from '@/lib/api/subsonicSearch'; import { search, searchSongsPaged } from '@/lib/api/subsonicSearch';
import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes'; import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import { libraryAdvancedSearch, libraryGetArtistLosslessBrowse, libraryListLosslessAlbums } from '@/lib/api/library'; import { libraryAdvancedSearch, libraryGetArtistLosslessBrowse, libraryListLosslessAlbums } from '@/lib/api/library';
@@ -177,7 +179,7 @@ export function browseRaceCountsFullSearch(result: unknown): LibrarySearchDebugE
const ARTIST_BROWSE_LIMIT = 500; const ARTIST_BROWSE_LIMIT = 500;
const ALBUM_BROWSE_LIMIT = 500; const ALBUM_BROWSE_LIMIT = 500;
const emptyBrowseOpts = (query: string): LocalSearchOpts => ({ const emptyBrowseOpts = (query: string, artistCreditMode?: ArtistCreditMode): LocalSearchOpts => ({
query, query,
genre: '', genre: '',
yearFrom: '', yearFrom: '',
@@ -186,6 +188,7 @@ const emptyBrowseOpts = (query: string): LocalSearchOpts => ({
bpmTo: '', bpmTo: '',
moodGroup: '', moodGroup: '',
resultType: 'artists', resultType: 'artists',
...(artistCreditMode ? { artistCreditMode } : {}),
}); });
const albumBrowseOpts = (query: string, losslessOnly = false): LocalSearchOpts => ({ const albumBrowseOpts = (query: string, losslessOnly = false): LocalSearchOpts => ({
@@ -227,11 +230,12 @@ const fullSearchOpts = (query: string): LocalSearchOpts => ({
export async function runLocalBrowseArtists( export async function runLocalBrowseArtists(
serverId: string | null | undefined, serverId: string | null | undefined,
query: string, query: string,
creditMode: ArtistCreditMode = 'album',
limit = ARTIST_BROWSE_LIMIT, limit = ARTIST_BROWSE_LIMIT,
): Promise<SubsonicArtist[] | null> { ): Promise<SubsonicArtist[] | null> {
const page = await runLocalAdvancedSearch( const page = await runLocalAdvancedSearch(
serverId, serverId,
emptyBrowseOpts(query), emptyBrowseOpts(query, creditMode),
limit, limit,
false, false,
true, true,
@@ -244,13 +248,16 @@ export async function runLocalBrowseArtists(
/** Network search3 artist slice for browse pages. */ /** Network search3 artist slice for browse pages. */
export async function runNetworkBrowseArtists( export async function runNetworkBrowseArtists(
query: string, query: string,
creditMode: ArtistCreditMode = 'album',
limit = ARTIST_BROWSE_LIMIT, limit = ARTIST_BROWSE_LIMIT,
): Promise<SubsonicArtist[] | null> { ): Promise<SubsonicArtist[] | null> {
const q = query.trim(); const q = query.trim();
if (!q) return null; if (!q) return null;
try { try {
const r = await search(q, { artistCount: limit, albumCount: 0, songCount: 0 }); const r = await search(q, { artistCount: limit, albumCount: 0, songCount: 0 });
return r.artists; if (creditMode === 'track') return r.artists;
const indexIds = new Set((await getArtists()).map(a => a.id));
return r.artists.filter(a => indexIds.has(a.id));
} catch { } catch {
return null; return null;
} }
@@ -561,13 +568,18 @@ export async function fetchLocalArtistCatalogChunk(
serverId: string, serverId: string,
offset: number, offset: number,
chunkSize: number, chunkSize: number,
creditMode: ArtistCreditMode = 'album',
letterBucket?: string | null,
): Promise<ArtistCatalogChunkResult | null> { ): Promise<ArtistCatalogChunkResult | null> {
if (!serverId || !(await libraryIsReady(serverId))) return null; if (!serverId || !(await libraryIsReady(serverId))) return null;
const bucket = letterBucket && letterBucket !== 'ALL' ? letterBucket : undefined;
try { try {
const resp = await libraryAdvancedSearch({ const resp = await libraryAdvancedSearch({
serverId, serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined, libraryScope: libraryScopeForServer(serverId) ?? undefined,
entityTypes: ['artist'], entityTypes: ['artist'],
artistCreditMode: creditMode,
...(bucket ? { artistLetterBucket: bucket } : {}),
sort: [{ field: 'name', dir: 'asc' }], sort: [{ field: 'name', dir: 'asc' }],
limit: chunkSize, limit: chunkSize,
offset, offset,
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: 'Активирай множествен избор', startSelect: 'Активирай множествен избор',
cancelSelect: 'Отказ', cancelSelect: 'Отказ',
addToPlaylist: 'Добави в плейлист', addToPlaylist: 'Добави в плейлист',
browse: {
creditMode: {
album: 'Артисти на албума',
track: 'Артисти на записа',
tooltipAlbum: 'Показва само album artist от индекса на библиотеката (по подразбиране)',
tooltipTrack: 'Показва всички изпълнители на записи, включително гости в компилации',
},
},
}; };
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: 'Mehrfachauswahl aktivieren', startSelect: 'Mehrfachauswahl aktivieren',
cancelSelect: 'Abbrechen', cancelSelect: 'Abbrechen',
addToPlaylist: 'Zur Playlist hinzufügen', addToPlaylist: 'Zur Playlist hinzufügen',
browse: {
creditMode: {
album: 'Album-Künstler',
track: 'Track-Künstler',
tooltipAlbum: 'Nur Album-Künstler anzeigen (Standard-Bibliotheksindex)',
tooltipTrack: 'Alle Track-Interpreten, einschließlich Gäste auf Samplern',
},
},
}; };
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: 'Enable multi-select', startSelect: 'Enable multi-select',
cancelSelect: 'Cancel', cancelSelect: 'Cancel',
addToPlaylist: 'Add to Playlist', addToPlaylist: 'Add to Playlist',
browse: {
creditMode: {
album: 'Album artists',
track: 'Track artists',
tooltipAlbum: 'Show album artists only (default library index)',
tooltipTrack: 'Show all track performers, including guests on compilations',
},
},
}; };
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: 'Activar selección múltiple', startSelect: 'Activar selección múltiple',
cancelSelect: 'Cancelar', cancelSelect: 'Cancelar',
addToPlaylist: 'Agregar a Lista', addToPlaylist: 'Agregar a Lista',
browse: {
creditMode: {
album: 'Artistas del álbum',
track: 'Artistas del tema',
tooltipAlbum: 'Mostrar solo artistas del álbum (índice de biblioteca predeterminado)',
tooltipTrack: 'Mostrar todos los intérpretes, incluidos invitados en recopilaciones',
},
},
}; };
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: 'Activer la sélection multiple', startSelect: 'Activer la sélection multiple',
cancelSelect: 'Annuler', cancelSelect: 'Annuler',
addToPlaylist: 'Ajouter à la playlist', addToPlaylist: 'Ajouter à la playlist',
browse: {
creditMode: {
album: 'Artistes dalbum',
track: 'Artistes de piste',
tooltipAlbum: 'Afficher uniquement les artistes dalbum (index bibliothèque par défaut)',
tooltipTrack: 'Afficher tous les interprètes, y compris les invités sur les compilations',
},
},
}; };
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: 'Többszörös kijelölés bekapcsolása', startSelect: 'Többszörös kijelölés bekapcsolása',
cancelSelect: 'Mégse', cancelSelect: 'Mégse',
addToPlaylist: 'Hozzáadás lejátszási listához', addToPlaylist: 'Hozzáadás lejátszási listához',
browse: {
creditMode: {
album: 'Album előadók',
track: 'Szám előadók',
tooltipAlbum: 'Csak album előadók megjelenítése (alapértelmezett könyvtárindex)',
tooltipTrack: 'Minden szám előadó megjelenítése, beleértve a vendégeket válogatásokon',
},
},
}; };
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: '複数選択を有効化', startSelect: '複数選択を有効化',
cancelSelect: 'キャンセル', cancelSelect: 'キャンセル',
addToPlaylist: 'プレイリストに追加', addToPlaylist: 'プレイリストに追加',
browse: {
creditMode: {
album: 'アルバムアーティスト',
track: 'トラックアーティスト',
tooltipAlbum: 'アルバムアーティストのみ表示(デフォルトのライブラリ索引)',
tooltipTrack: 'コンピレーションのゲストを含むすべてのトラック出演者を表示',
},
},
}; };
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: 'Aktiver multivalg', startSelect: 'Aktiver multivalg',
cancelSelect: 'Avbryt', cancelSelect: 'Avbryt',
addToPlaylist: 'Legg til i spilleliste', addToPlaylist: 'Legg til i spilleliste',
browse: {
creditMode: {
album: 'Albumartister',
track: 'Sporartister',
tooltipAlbum: 'Vis kun albumartister (standard biblioteksindeks)',
tooltipTrack: 'Vis alle sporutøvere, inkludert gjester på samlinger',
},
},
}; };
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: 'Meervoudige selectie inschakelen', startSelect: 'Meervoudige selectie inschakelen',
cancelSelect: 'Annuleren', cancelSelect: 'Annuleren',
addToPlaylist: 'Toevoegen aan playlist', addToPlaylist: 'Toevoegen aan playlist',
browse: {
creditMode: {
album: 'Albumartiesten',
track: 'Trackartiesten',
tooltipAlbum: 'Alleen albumartiesten tonen (standaard bibliotheekindex)',
tooltipTrack: 'Alle trackuitvoerders tonen, inclusief gasten op compilaties',
},
},
}; };
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: 'Włącz wybór wielokrotny', startSelect: 'Włącz wybór wielokrotny',
cancelSelect: 'Anuluj', cancelSelect: 'Anuluj',
addToPlaylist: 'Dodaj do playlisty', addToPlaylist: 'Dodaj do playlisty',
browse: {
creditMode: {
album: 'Wykonawcy albumu',
track: 'Wykonawcy utworu',
tooltipAlbum: 'Pokaż tylko wykonawców albumu (domyślny indeks biblioteki)',
tooltipTrack: 'Pokaż wszystkich wykonawców utworów, w tym gości na składankach',
},
},
}; };
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: 'Activează multi-selecția', startSelect: 'Activează multi-selecția',
cancelSelect: 'Anulează', cancelSelect: 'Anulează',
addToPlaylist: 'Adaugă la Playlist', addToPlaylist: 'Adaugă la Playlist',
browse: {
creditMode: {
album: 'Artiști de album',
track: 'Artiști de piesă',
tooltipAlbum: 'Afișează doar artiștii de album (index bibliotecă implicit)',
tooltipTrack: 'Afișează toți interpreții pieselor, inclusiv invitați pe compilații',
},
},
}; };
+8
View File
@@ -18,4 +18,12 @@ export const artists = {
startSelect: 'Включить множественный выбор', startSelect: 'Включить множественный выбор',
cancelSelect: 'Отмена', cancelSelect: 'Отмена',
addToPlaylist: 'В плейлист', addToPlaylist: 'В плейлист',
browse: {
creditMode: {
album: 'Альбомные артисты',
track: 'Артисты треков',
tooltipAlbum: 'Только album artist из индекса библиотеки (по умолчанию)',
tooltipTrack: 'Все исполнители треков, включая гостей на сборниках',
},
},
}; };
+8
View File
@@ -16,4 +16,12 @@ export const artists = {
startSelect: '启用多选', startSelect: '启用多选',
cancelSelect: '取消', cancelSelect: '取消',
addToPlaylist: '添加到播放列表', addToPlaylist: '添加到播放列表',
browse: {
creditMode: {
album: '专辑艺术家',
track: '曲目艺术家',
tooltipAlbum: '仅显示专辑艺术家(默认库索引)',
tooltipTrack: '显示所有曲目表演者,包括合辑中的客串艺术家',
},
},
}; };
+1
View File
@@ -51,6 +51,7 @@ describe('trivial pass-through setters', () => {
['setInfiniteQueueEnabled', 'infiniteQueueEnabled', true], ['setInfiniteQueueEnabled', 'infiniteQueueEnabled', true],
['setPreservePlayNextOrder', 'preservePlayNextOrder', true], ['setPreservePlayNextOrder', 'preservePlayNextOrder', true],
['setShowArtistImages', 'showArtistImages', true], ['setShowArtistImages', 'showArtistImages', true],
['setArtistBrowseCreditMode', 'artistBrowseCreditMode', 'track'],
['setShowTrayIcon', 'showTrayIcon', false], ['setShowTrayIcon', 'showTrayIcon', false],
['setMinimizeToTray', 'minimizeToTray', true], ['setMinimizeToTray', 'minimizeToTray', true],
['setClockFormat', 'clockFormat', '24h'], ['setClockFormat', 'clockFormat', '24h'],
+1
View File
@@ -66,6 +66,7 @@ export const useAuthStore = create<AuthState>()(
infiniteQueueEnabled: false, infiniteQueueEnabled: false,
preservePlayNextOrder: false, preservePlayNextOrder: false,
showArtistImages: false, showArtistImages: false,
artistBrowseCreditMode: 'album',
libraryGridMaxColumns: DEFAULT_LIBRARY_GRID_MAX_COLUMNS, libraryGridMaxColumns: DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
showTrayIcon: true, showTrayIcon: true,
minimizeToTray: false, minimizeToTray: false,
+9
View File
@@ -19,6 +19,7 @@ import {
} from './authStoreHelpers'; } from './authStoreHelpers';
import type { import type {
AuthState, AuthState,
ArtistBrowseCreditMode,
DiscordCoverSource, DiscordCoverSource,
DurationMode, DurationMode,
LyricsSourceConfig, LyricsSourceConfig,
@@ -129,6 +130,13 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
? {} ? {}
: { queueDisplayMode: 'queue' as QueueDisplayMode }; : { queueDisplayMode: 'queue' as QueueDisplayMode };
const VALID_ARTIST_BROWSE_CREDIT_MODES = new Set<string>(['album', 'track']);
const artistBrowseCreditModeMigrated = VALID_ARTIST_BROWSE_CREDIT_MODES.has(
(state as { artistBrowseCreditMode?: unknown }).artistBrowseCreditMode as string,
)
? {}
: { artistBrowseCreditMode: 'album' as ArtistBrowseCreditMode };
const VALID_WAYLAND_TEXT_PROFILE = new Set<string>(['balanced', 'sharp', 'gpu', 'minimal']); const VALID_WAYLAND_TEXT_PROFILE = new Set<string>(['balanced', 'sharp', 'gpu', 'minimal']);
const rawWaylandProfile = (state as { linuxWaylandTextRenderProfile?: unknown }).linuxWaylandTextRenderProfile; const rawWaylandProfile = (state as { linuxWaylandTextRenderProfile?: unknown }).linuxWaylandTextRenderProfile;
const linuxWaylandTextRenderProfileMigrated = VALID_WAYLAND_TEXT_PROFILE.has(rawWaylandProfile as string) const linuxWaylandTextRenderProfileMigrated = VALID_WAYLAND_TEXT_PROFILE.has(rawWaylandProfile as string)
@@ -265,6 +273,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
...windowButtonStyleMigrated, ...windowButtonStyleMigrated,
...queueDurationDisplayModeMigrated, ...queueDurationDisplayModeMigrated,
...queueDisplayModeMigrated, ...queueDisplayModeMigrated,
...artistBrowseCreditModeMigrated,
...linuxWaylandTextRenderProfileMigrated, ...linuxWaylandTextRenderProfileMigrated,
...discordCoverSourceMigrated, ...discordCoverSourceMigrated,
...maxCacheMbMigrated, ...maxCacheMbMigrated,
+6
View File
@@ -7,6 +7,9 @@ import type {
} from '@/lib/server/subsonicServerIdentity'; } from '@/lib/server/subsonicServerIdentity';
import type { PersistedAccount } from '../music-network'; import type { PersistedAccount } from '../music-network';
/** Album-artist vs track-performer browse (#1209). Duplicated here — not `@/lib/api/library` — to avoid store ↔ library import cycles (dependency-cruiser). */
export type ArtistBrowseCreditMode = 'album' | 'track';
export type CustomHeaderEntry = { export type CustomHeaderEntry = {
name: string; name: string;
value: string; value: string;
@@ -170,6 +173,8 @@ export interface AuthState {
infiniteQueueEnabled: boolean; infiniteQueueEnabled: boolean;
preservePlayNextOrder: boolean; preservePlayNextOrder: boolean;
showArtistImages: boolean; showArtistImages: boolean;
/** Artists browse: album artists vs track performers (#1209). Persisted across sessions. */
artistBrowseCreditMode: ArtistBrowseCreditMode;
/** /**
* Max columns for album/artist/playlist-style card grids (Settings Library). * Max columns for album/artist/playlist-style card grids (Settings Library).
* Clamped 412; higher values mean more tiles per row and more layout/paint work. * Clamped 412; higher values mean more tiles per row and more layout/paint work.
@@ -391,6 +396,7 @@ export interface AuthState {
setInfiniteQueueEnabled: (v: boolean) => void; setInfiniteQueueEnabled: (v: boolean) => void;
setPreservePlayNextOrder: (v: boolean) => void; setPreservePlayNextOrder: (v: boolean) => void;
setShowArtistImages: (v: boolean) => void; setShowArtistImages: (v: boolean) => void;
setArtistBrowseCreditMode: (v: ArtistBrowseCreditMode) => void;
setLibraryGridMaxColumns: (v: number) => void; setLibraryGridMaxColumns: (v: number) => void;
setShowTrayIcon: (v: boolean) => void; setShowTrayIcon: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void; setMinimizeToTray: (v: boolean) => void;
+2
View File
@@ -13,6 +13,7 @@ type SetState = (
export function createUiAppearanceActions(set: SetState): Pick< export function createUiAppearanceActions(set: SetState): Pick<
AuthState, AuthState,
| 'setShowArtistImages' | 'setShowArtistImages'
| 'setArtistBrowseCreditMode'
| 'setLibraryGridMaxColumns' | 'setLibraryGridMaxColumns'
| 'setShowTrayIcon' | 'setShowTrayIcon'
| 'setMinimizeToTray' | 'setMinimizeToTray'
@@ -37,6 +38,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
> { > {
return { return {
setShowArtistImages: (v) => set({ showArtistImages: v }), setShowArtistImages: (v) => set({ showArtistImages: v }),
setArtistBrowseCreditMode: (v) => set({ artistBrowseCreditMode: v === 'track' ? 'track' : 'album' }),
setLibraryGridMaxColumns: (v) => set({ libraryGridMaxColumns: clampLibraryGridMaxColumns(v) }), setLibraryGridMaxColumns: (v) => set({ libraryGridMaxColumns: clampLibraryGridMaxColumns(v) }),
setShowTrayIcon: (v) => set({ showTrayIcon: v }), setShowTrayIcon: (v) => set({ showTrayIcon: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }), setMinimizeToTray: (v) => set({ minimizeToTray: v }),
+17 -9
View File
@@ -5,28 +5,32 @@ import FilterQuickClear from '@/ui/FilterQuickClear';
interface Props { interface Props {
active: boolean; active: boolean;
onChange: (next: boolean) => void; onChange: (next: boolean) => void;
disabled?: boolean;
/** 'default' = icon + label, regular padding (Albums toolbar). /** 'default' = icon + label, regular padding (Albums toolbar).
* 'compact' = icon-only, 0.5rem padding (Artists view-mode buttons). * 'compact' = icon-only, 0.5rem padding (Artists view-mode buttons).
* 'small' = icon + label, 4px/14px padding + 12px text (SearchBrowsePage tabs). */ * 'small' = icon + label, 4px/14px padding + 12px text (SearchBrowsePage tabs). */
size?: 'default' | 'compact' | 'small'; size?: 'default' | 'compact' | 'small';
} }
export default function StarFilterButton({ active, onChange, size = 'default' }: Props) { export default function StarFilterButton({ active, onChange, disabled = false, size = 'default' }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const tooltip = active ? t('common.favoritesTooltipOn') : t('common.favoritesTooltipOff'); const tooltip = active ? t('common.favoritesTooltipOn') : t('common.favoritesTooltipOff');
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}; const activeStyle = active && !disabled ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
const disabledStyle = disabled ? { opacity: 0.45, cursor: 'not-allowed' } : {};
if (size === 'compact') { if (size === 'compact') {
return ( return (
<button <button
type="button" type="button"
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`} className={`btn btn-surface${active && !disabled ? ' btn-sort-active' : ''}`}
onClick={() => onChange(!active)} onClick={() => { if (!disabled) onChange(!active); }}
disabled={disabled}
aria-pressed={active} aria-pressed={active}
aria-disabled={disabled}
aria-label={tooltip} aria-label={tooltip}
data-tooltip={tooltip} data-tooltip={tooltip}
data-tooltip-pos="bottom" data-tooltip-pos="bottom"
style={{ padding: '0.5rem', ...activeStyle }} style={{ padding: '0.5rem', ...activeStyle, ...disabledStyle }}
> >
<Star size={20} fill={active ? 'currentColor' : 'none'} /> <Star size={20} fill={active ? 'currentColor' : 'none'} />
</button> </button>
@@ -38,10 +42,12 @@ export default function StarFilterButton({ active, onChange, size = 'default' }:
<button <button
type="button" type="button"
className={`btn ${active ? 'btn-primary' : 'btn-surface'}`} className={`btn ${active ? 'btn-primary' : 'btn-surface'}`}
onClick={() => onChange(!active)} onClick={() => { if (!disabled) onChange(!active); }}
disabled={disabled}
aria-pressed={active} aria-pressed={active}
aria-disabled={disabled}
data-tooltip={tooltip} data-tooltip={tooltip}
style={{ fontSize: 12, padding: '4px 14px', display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }} style={{ fontSize: 12, padding: '4px 14px', display: 'inline-flex', alignItems: 'center', gap: '0.35rem', ...disabledStyle }}
> >
<Star size={12} fill={active ? 'currentColor' : 'none'} /> <Star size={12} fill={active ? 'currentColor' : 'none'} />
{t('common.favorites')} {t('common.favorites')}
@@ -53,12 +59,14 @@ export default function StarFilterButton({ active, onChange, size = 'default' }:
<button <button
type="button" type="button"
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`} className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
onClick={() => onChange(!active)} onClick={() => { if (!disabled) onChange(!active); }}
disabled={disabled}
aria-pressed={active} aria-pressed={active}
aria-disabled={disabled}
data-tooltip={tooltip} data-tooltip={tooltip}
data-tooltip-pos="bottom" data-tooltip-pos="bottom"
style={{ style={{
display: 'flex', alignItems: 'center', gap: '0.4rem', ...activeStyle, display: 'flex', alignItems: 'center', gap: '0.4rem', ...activeStyle, ...disabledStyle,
}} }}
> >
<Star size={14} fill={active ? 'currentColor' : 'none'} /> <Star size={14} fill={active ? 'currentColor' : 'none'} />