mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
@@ -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
@@ -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>,
|
||||||
|
/// A–Z / `#` / `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).
|
||||||
|
|||||||
@@ -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 effect’s dependencies change.
|
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s 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 };
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
/** A–Z, `#`, `OTHER`, or omit/`ALL` — letter bucket on local artist browse. */
|
||||||
|
artistLetterBucket?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryAlbumDto {
|
export interface LibraryAlbumDto {
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -16,4 +16,12 @@ export const artists = {
|
|||||||
startSelect: 'Активирай множествен избор',
|
startSelect: 'Активирай множествен избор',
|
||||||
cancelSelect: 'Отказ',
|
cancelSelect: 'Отказ',
|
||||||
addToPlaylist: 'Добави в плейлист',
|
addToPlaylist: 'Добави в плейлист',
|
||||||
|
browse: {
|
||||||
|
creditMode: {
|
||||||
|
album: 'Артисти на албума',
|
||||||
|
track: 'Артисти на записа',
|
||||||
|
tooltipAlbum: 'Показва само album artist от индекса на библиотеката (по подразбиране)',
|
||||||
|
tooltipTrack: 'Показва всички изпълнители на записи, включително гости в компилации',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 d’album',
|
||||||
|
track: 'Artistes de piste',
|
||||||
|
tooltipAlbum: 'Afficher uniquement les artistes d’album (index bibliothèque par défaut)',
|
||||||
|
tooltipTrack: 'Afficher tous les interprètes, y compris les invités sur les compilations',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,4 +16,12 @@ export const artists = {
|
|||||||
startSelect: '複数選択を有効化',
|
startSelect: '複数選択を有効化',
|
||||||
cancelSelect: 'キャンセル',
|
cancelSelect: 'キャンセル',
|
||||||
addToPlaylist: 'プレイリストに追加',
|
addToPlaylist: 'プレイリストに追加',
|
||||||
|
browse: {
|
||||||
|
creditMode: {
|
||||||
|
album: 'アルバムアーティスト',
|
||||||
|
track: 'トラックアーティスト',
|
||||||
|
tooltipAlbum: 'アルバムアーティストのみ表示(デフォルトのライブラリ索引)',
|
||||||
|
tooltipTrack: 'コンピレーションのゲストを含むすべてのトラック出演者を表示',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,4 +18,12 @@ export const artists = {
|
|||||||
startSelect: 'Включить множественный выбор',
|
startSelect: 'Включить множественный выбор',
|
||||||
cancelSelect: 'Отмена',
|
cancelSelect: 'Отмена',
|
||||||
addToPlaylist: 'В плейлист',
|
addToPlaylist: 'В плейлист',
|
||||||
|
browse: {
|
||||||
|
creditMode: {
|
||||||
|
album: 'Альбомные артисты',
|
||||||
|
track: 'Артисты треков',
|
||||||
|
tooltipAlbum: 'Только album artist из индекса библиотеки (по умолчанию)',
|
||||||
|
tooltipTrack: 'Все исполнители треков, включая гостей на сборниках',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,4 +16,12 @@ export const artists = {
|
|||||||
startSelect: '启用多选',
|
startSelect: '启用多选',
|
||||||
cancelSelect: '取消',
|
cancelSelect: '取消',
|
||||||
addToPlaylist: '添加到播放列表',
|
addToPlaylist: '添加到播放列表',
|
||||||
|
browse: {
|
||||||
|
creditMode: {
|
||||||
|
album: '专辑艺术家',
|
||||||
|
track: '曲目艺术家',
|
||||||
|
tooltipAlbum: '仅显示专辑艺术家(默认库索引)',
|
||||||
|
tooltipTrack: '显示所有曲目表演者,包括合辑中的客串艺术家',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 4…12; higher values mean more tiles per row and more layout/paint work.
|
* Clamped 4…12; 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;
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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'} />
|
||||||
|
|||||||
Reference in New Issue
Block a user