feat(qt): artist scene, musician page and the shared scene-discovery engine
Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.
- qbz-core gains scene discovery ("artists from the same place"): a
cancellable run with a phase for the progress label and a typed
SceneDiscoveryError, so a view can tell offline, all-upstreams-failed and
an honest empty result apart. CancelToken, ScenePhase and SceneDiscoveryError
are re-exported for frontends.
- qbz-integrations: MusicBrainz location resolution plus its cache layer.
- qbz-qt: ArtistSceneView with its three scene components, MusicianPageView
and MusicianModal, backed by artist_scene_qt.rs, musician_qt.rs and the two
bridges. The five-branch musician dispatcher is wired to all six of its
consumers. Both scene doors are live: the sidebar location link and the
overflow row that shipped grey with action: "stub".
- Translations: the new msgids in all eight locales.
Three defects fixed on the way, all with the same signature — a comment
asserting a mechanism that does not exist:
- Volume opened at 100 %. The comment claimed the poll loop mirrored it from
the engine; now_playing::set_volume has two callers and neither runs in a
local session.
- mbAvailable was documented as "MusicBrainz enabled AND resolved to an MBID"
while being assigned from the opt-in alone. Consumers now test the MBID.
- Session restore could not resume from a cold cursor ("cannot resume - no
audio data available") for want of a has_loaded_audio() guard. Investigation
in qbz-nix-docs/qt-frontend/2026-08-14-cold-cursor-resume.md.
Contract and evidence:
qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md,
which wins over specs 01..09 and records 20 deliberate divergences from the
reference in section 6.
cc
This commit is contained in:
37 files changed
+9203
-83
No files matched your search
+518
-21
@@ -17,7 +17,7 @@ use qbz_models::{
|
|||||||
RepeatMode, SearchAllResults, SearchResultsPage, StreamUrl, Track, TrackToAnalyse,
|
RepeatMode, SearchAllResults, SearchResultsPage, StreamUrl, Track, TrackToAnalyse,
|
||||||
TracksContainer, UserSession,
|
TracksContainer, UserSession,
|
||||||
};
|
};
|
||||||
use qbz_integrations::musicbrainz::cache::MusicBrainzCache;
|
use qbz_integrations::musicbrainz::cache::{MusicBrainzCache, QobuzArtistMatch};
|
||||||
use qbz_integrations::musicbrainz::genre::{extract_affinity_seeds, genre_summary, is_broad_genre};
|
use qbz_integrations::musicbrainz::genre::{extract_affinity_seeds, genre_summary, is_broad_genre};
|
||||||
use qbz_integrations::musicbrainz::location::compute_affinity_score;
|
use qbz_integrations::musicbrainz::location::compute_affinity_score;
|
||||||
use qbz_integrations::musicbrainz::{
|
use qbz_integrations::musicbrainz::{
|
||||||
@@ -2456,13 +2456,229 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
|
|
||||||
// ----- Location / scene discovery -------------------------------------------
|
// ----- Location / scene discovery -------------------------------------------
|
||||||
|
|
||||||
|
/// Which stage of scene discovery a progress event describes.
|
||||||
|
///
|
||||||
|
/// [`ScenePhase::as_str`] is the ONLY place the wire spelling lives — the
|
||||||
|
/// scene bridge document (`progress.phase`) is `"searching" | "validating" |
|
||||||
|
/// "done"` and nothing else may hand-write those literals.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ScenePhase {
|
||||||
|
Searching,
|
||||||
|
Validating,
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScenePhase {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ScenePhase::Searching => "searching",
|
||||||
|
ScenePhase::Validating => "validating",
|
||||||
|
ScenePhase::Done => "done",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cooperative cancellation for scene discovery.
|
||||||
|
///
|
||||||
|
/// A generation guard in the frontend only drops the FINAL publish; it does
|
||||||
|
/// not stop the traffic behind it, and ONE page can run up to 100 sequential
|
||||||
|
/// Qobuz artist searches on top of rate-limited MusicBrainz calls (which are
|
||||||
|
/// serialised at ~1.1 s each). This token is checked between every request, so
|
||||||
|
/// leaving the view actually stops the work instead of merely ignoring it.
|
||||||
|
///
|
||||||
|
/// Deliberately an `AtomicBool` rather than `tokio_util::sync::
|
||||||
|
/// CancellationToken`: no new dependency, and `Clone` is free into whichever
|
||||||
|
/// task owns the discovery. The frontend holds one token per generation and
|
||||||
|
/// trips the old one on a new scene, on Retry, on view deactivation and on an
|
||||||
|
/// offline transition.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct CancelToken(Arc<std::sync::atomic::AtomicBool>);
|
||||||
|
|
||||||
|
impl CancelToken {
|
||||||
|
/// A fresh, un-tripped token.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trip the token. Idempotent; every clone sees it.
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_cancelled(&self) -> bool {
|
||||||
|
self.0.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why scene discovery could not produce a page.
|
||||||
|
///
|
||||||
|
/// This type exists because the old signature returned `Ok(vec![])` for
|
||||||
|
/// offline, MusicBrainz-disabled, unauthenticated, rate-limited AND
|
||||||
|
/// genuinely-empty alike — so the UI's error state was unreachable and all
|
||||||
|
/// five collapsed into "No matching artists from this scene…". Every variant
|
||||||
|
/// here is the ERROR class (error state + Retry) except [`Self::Cancelled`],
|
||||||
|
/// which the user asked for and must paint nothing at all.
|
||||||
|
///
|
||||||
|
/// "Empty" is NOT an error: a query that completed and found nothing still
|
||||||
|
/// returns `Ok` with an empty `artists` vector.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SceneDiscoveryError {
|
||||||
|
/// No Qobuz client — the core booted offline-tolerant, or never init'd.
|
||||||
|
#[error("Not initialized")]
|
||||||
|
NotInitialized,
|
||||||
|
|
||||||
|
/// Qobuz refused on authentication/entitlement grounds.
|
||||||
|
#[error("Authentication required: {0}")]
|
||||||
|
NotAuthenticated(String),
|
||||||
|
|
||||||
|
/// EVERY MusicBrainz genre search failed. Also how a disabled MusicBrainz
|
||||||
|
/// integration surfaces, since the client refuses each call.
|
||||||
|
#[error("MusicBrainz unavailable: {0}")]
|
||||||
|
UpstreamUnavailable(String),
|
||||||
|
|
||||||
|
/// There were candidates to validate and EVERY Qobuz search failed.
|
||||||
|
#[error("Qobuz catalog unavailable: {0}")]
|
||||||
|
CatalogUnavailable(String),
|
||||||
|
|
||||||
|
/// The caller tripped the [`CancelToken`]. Never user-visible.
|
||||||
|
#[error("Scene discovery cancelled")]
|
||||||
|
Cancelled,
|
||||||
|
|
||||||
|
#[error("Internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SceneDiscoveryError {
|
||||||
|
/// True when the view should show its error state with a Retry button.
|
||||||
|
/// False only for [`Self::Cancelled`], which must leave the UI untouched.
|
||||||
|
pub fn shows_error_state(&self) -> bool {
|
||||||
|
!matches!(self, SceneDiscoveryError::Cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_cancelled(&self) -> bool {
|
||||||
|
matches!(self, SceneDiscoveryError::Cancelled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SceneDiscoveryError> for CoreError {
|
||||||
|
fn from(err: SceneDiscoveryError) -> Self {
|
||||||
|
match err {
|
||||||
|
SceneDiscoveryError::NotInitialized => CoreError::NotInitialized,
|
||||||
|
SceneDiscoveryError::NotAuthenticated(msg) => CoreError::AuthFailed(msg),
|
||||||
|
other => CoreError::Internal(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this Qobuz failure end the whole page, or just this one candidate?
|
||||||
|
///
|
||||||
|
/// Terminal classes abort the loop immediately instead of burning up to 99
|
||||||
|
/// more doomed searches: no client, an auth rejection, an entitlement/403
|
||||||
|
/// refusal, the open 403 circuit breaker, and offline mode. Everything else
|
||||||
|
/// (timeouts, 5xx, one odd response) is per-candidate and merely counted, so a
|
||||||
|
/// single flaky artist cannot fail a whole scene.
|
||||||
|
fn scene_fatal_qobuz(err: &CoreError) -> Option<SceneDiscoveryError> {
|
||||||
|
use qbz_qobuz::ApiError;
|
||||||
|
match err {
|
||||||
|
CoreError::NotInitialized => Some(SceneDiscoveryError::NotInitialized),
|
||||||
|
CoreError::AuthRequired => {
|
||||||
|
Some(SceneDiscoveryError::NotAuthenticated("session missing".into()))
|
||||||
|
}
|
||||||
|
CoreError::AuthFailed(msg) => Some(SceneDiscoveryError::NotAuthenticated(msg.clone())),
|
||||||
|
CoreError::Api(api) => match api {
|
||||||
|
ApiError::AuthenticationError(_)
|
||||||
|
| ApiError::InvalidAppId
|
||||||
|
| ApiError::InvalidAppSecret
|
||||||
|
| ApiError::IneligibleUser
|
||||||
|
| ApiError::Forbidden(_)
|
||||||
|
| ApiError::ForbiddenCircuitOpen(_) => {
|
||||||
|
Some(SceneDiscoveryError::NotAuthenticated(api.to_string()))
|
||||||
|
}
|
||||||
|
ApiError::OfflineMode => Some(SceneDiscoveryError::CatalogUnavailable(api.to_string())),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Longest we will honour a `Retry-After` before giving up on that request.
|
||||||
|
/// MusicBrainz's per-IP limiter recovers in ~1 s; anything asking for more
|
||||||
|
/// than this is an outage, not a hiccup, and the user is staring at a bar.
|
||||||
|
const SCENE_MAX_BACKOFF_SECS: u64 = 10;
|
||||||
|
|
||||||
|
/// Sleep `secs` (capped), waking every 250 ms so a cancellation lands promptly
|
||||||
|
/// instead of after a ten-second nap. Returns `false` when cancelled.
|
||||||
|
async fn scene_backoff(secs: u64, cancel: &CancelToken) -> bool {
|
||||||
|
let total = secs.min(SCENE_MAX_BACKOFF_SECS);
|
||||||
|
let slices = total * 4;
|
||||||
|
for _ in 0..slices {
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
!cancel.is_cancelled()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop blacklisted rows from a scene response and keep `total_candidates`
|
||||||
|
/// honest. Returns how many were removed.
|
||||||
|
///
|
||||||
|
/// Run on EVERY cache hit: a hit short-circuits the validation loop (which is
|
||||||
|
/// where the blacklist is normally applied, Tauri-style), so without this a
|
||||||
|
/// blocked artist would keep reappearing for the whole 30-day TTL. Rows with
|
||||||
|
/// no usable Qobuz id are kept — fail-open, same as the Slint adapter.
|
||||||
|
fn apply_scene_blacklist(
|
||||||
|
response: &mut LocationDiscoveryResponse,
|
||||||
|
blacklist: &(dyn Fn(u64) -> bool + Send + Sync),
|
||||||
|
) -> usize {
|
||||||
|
let before = response.artists.len();
|
||||||
|
response.artists.retain(|c| match c.qobuz_id {
|
||||||
|
Some(id) if id > 0 => !blacklist(id as u64),
|
||||||
|
_ => true,
|
||||||
|
});
|
||||||
|
let removed = before - response.artists.len();
|
||||||
|
response.total_candidates = response.total_candidates.saturating_sub(removed);
|
||||||
|
if removed > 0 {
|
||||||
|
response.has_more = response.next_offset < response.total_candidates;
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||||
|
/// Throw away every cached scene.
|
||||||
|
///
|
||||||
|
/// Call it when the artist blacklist (or its enabled flag) changes, and on
|
||||||
|
/// account switch. The post-filter on read covers newly-BLOCKED artists;
|
||||||
|
/// this covers the two cases it cannot: un-blocking (the validation loop
|
||||||
|
/// had already substituted a different same-name Qobuz artist for that
|
||||||
|
/// MBID) and a change of account territory (which decides what validates
|
||||||
|
/// at all and is not yet part of the cache key).
|
||||||
|
pub fn invalidate_scene_cache(&self) {
|
||||||
|
if let Ok(guard) = self.musicbrainz_cache.lock() {
|
||||||
|
if let Some(cache) = guard.as_ref() {
|
||||||
|
if let Err(e) = cache.clear_scene_cache() {
|
||||||
|
log::warn!("invalidate_scene_cache: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// "Artists from the same place" — given a source artist's MBID,
|
/// "Artists from the same place" — given a source artist's MBID,
|
||||||
/// area and genre/tag seeds, find other artists from that area
|
/// area and genre/tag seeds, find other artists from that area
|
||||||
/// who share the genres, validated against Qobuz. Ports
|
/// who share the genres, validated against Qobuz.
|
||||||
/// v2_discover_artists_by_location's core pipeline (the scene
|
///
|
||||||
/// cache + progress events are omitted; subdivision resolution
|
/// Thin wrapper over [`Self::discover_artists_by_location_with_progress`]
|
||||||
/// and affinity scoring are kept).
|
/// with no progress sink, no blacklist and a token that is never tripped.
|
||||||
|
/// It exists because Rust has no default arguments: adding parameters to
|
||||||
|
/// this method would break every existing caller, and the Slint binary's
|
||||||
|
/// `location_view.rs` (which does its own blacklist pass on the way out)
|
||||||
|
/// must keep compiling untouched.
|
||||||
|
///
|
||||||
|
/// NOTE the one behavioural change for existing callers: a page where
|
||||||
|
/// EVERY upstream search failed is now `Err`, where it used to be `Ok`
|
||||||
|
/// with an empty vector. That is the entire point of B7 — offline,
|
||||||
|
/// MusicBrainz-off, unauthenticated and "genuinely nobody" are different
|
||||||
|
/// answers and the UI cannot say so if the core flattens them.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn discover_artists_by_location(
|
pub async fn discover_artists_by_location(
|
||||||
&self,
|
&self,
|
||||||
@@ -2475,11 +2691,107 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
) -> Result<LocationDiscoveryResponse, CoreError> {
|
) -> Result<LocationDiscoveryResponse, CoreError> {
|
||||||
|
let no_blacklist = |_id: u64| false;
|
||||||
|
let no_progress = |_phase: ScenePhase, _pct: u8, _detail: String| {};
|
||||||
|
self.discover_artists_by_location_with_progress(
|
||||||
|
source_mbid,
|
||||||
|
area_id,
|
||||||
|
area_name,
|
||||||
|
country,
|
||||||
|
genres,
|
||||||
|
tags,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
&no_blacklist,
|
||||||
|
&CancelToken::new(),
|
||||||
|
&no_progress,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(CoreError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scene discovery with a progress stream, a blacklist predicate and
|
||||||
|
/// cancellation. Ports `v2_discover_artists_by_location` end to end —
|
||||||
|
/// subdivision resolution, affinity scoring, the 30-day scene cache and
|
||||||
|
/// the four progress emit points.
|
||||||
|
///
|
||||||
|
/// `blacklist` is INJECTED rather than imported because the blacklist
|
||||||
|
/// store lives in `qbz-app`/the frontend and `qbz-core` must not learn
|
||||||
|
/// about a frontend module. It is applied in two places, exactly as Tauri
|
||||||
|
/// did: inside the validation loop (so a blocked artist lets the
|
||||||
|
/// NEXT-best same-name Qobuz artist take the slot rather than losing the
|
||||||
|
/// row) and on every cache hit.
|
||||||
|
///
|
||||||
|
/// `progress` fires with `(phase, 0..=100, detail)`. Two paths emit
|
||||||
|
/// NOTHING and that is deliberate: a cache hit and a no-seed return.
|
||||||
|
/// Completion is signalled by this future resolving, never by waiting for
|
||||||
|
/// a `Done` event — a cached scene would otherwise hang the bar at 0 %.
|
||||||
|
/// The 95 → 100 jump at the end is Tauri's and is kept; the bar's 300 ms
|
||||||
|
/// width transition smooths it.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn discover_artists_by_location_with_progress(
|
||||||
|
&self,
|
||||||
|
source_mbid: &str,
|
||||||
|
area_id: Option<&str>,
|
||||||
|
area_name: &str,
|
||||||
|
country: Option<&str>,
|
||||||
|
genres: Vec<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
limit: usize,
|
||||||
|
offset: usize,
|
||||||
|
blacklist: &(dyn Fn(u64) -> bool + Send + Sync),
|
||||||
|
cancel: &CancelToken,
|
||||||
|
progress: &(dyn Fn(ScenePhase, u8, String) + Send + Sync),
|
||||||
|
) -> Result<LocationDiscoveryResponse, SceneDiscoveryError> {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
return Err(SceneDiscoveryError::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_seeds = AffinitySeeds {
|
||||||
|
genres: genres.clone(),
|
||||||
|
tags: tags.clone(),
|
||||||
|
normalized_seeds: genres.iter().chain(tags.iter()).cloned().collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The cache is consulted BEFORE the area resolution below, unlike
|
||||||
|
// Tauri: resolution is a pure function of `area_id`, so keying on the
|
||||||
|
// pre-resolution identity saves up to five MusicBrainz round-trips on
|
||||||
|
// every hit. First page only, matching Tauri — paginated pages are
|
||||||
|
// cheap to recompute and would multiply the key space by the offset.
|
||||||
|
let cache_key = location::build_scene_cache_key_v1(&location::SceneCacheKey {
|
||||||
|
source_mbid,
|
||||||
|
area_id: area_id.unwrap_or(area_name),
|
||||||
|
country,
|
||||||
|
seeds: &source_seeds,
|
||||||
|
page_size: limit,
|
||||||
|
catalog_scope: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
if offset == 0 {
|
||||||
|
let mut cached = None;
|
||||||
|
if let Ok(guard) = self.musicbrainz_cache.lock() {
|
||||||
|
if let Some(cache) = guard.as_ref() {
|
||||||
|
cached = cache.get_scene_cache(&cache_key).ok().flatten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(mut hit) = cached {
|
||||||
|
let removed = apply_scene_blacklist(&mut hit, blacklist);
|
||||||
|
log::info!(
|
||||||
|
"[scene] cache hit for {} ({} artists, {} blacklisted out)",
|
||||||
|
area_name,
|
||||||
|
hit.artists.len(),
|
||||||
|
removed
|
||||||
|
);
|
||||||
|
return Ok(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Step 0: smart area resolution — city → parent subdivision
|
// Step 0: smart area resolution — city → parent subdivision
|
||||||
// for broader results (Leyton → England, Seattle →
|
// for broader results (Leyton → England, Seattle →
|
||||||
// Washington).
|
// Washington). A failure here is PARTIAL, not fatal: the raw
|
||||||
|
// area name still searches, just less broadly.
|
||||||
let (search_name, display_name) = match area_id {
|
let (search_name, display_name) = match area_id {
|
||||||
Some(aid) => match self.musicbrainz.resolve_parent_subdivision(aid).await {
|
Some(aid) => match self.musicbrainz.resolve_parent_subdivision(aid).await {
|
||||||
Ok(Some((subdivision, _))) => {
|
Ok(Some((subdivision, _))) => {
|
||||||
@@ -2488,7 +2800,10 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
.unwrap_or_else(|| subdivision.clone());
|
.unwrap_or_else(|| subdivision.clone());
|
||||||
(subdivision, display)
|
(subdivision, display)
|
||||||
}
|
}
|
||||||
_ => {
|
other => {
|
||||||
|
if let Err(e) = other {
|
||||||
|
log::warn!("[scene] area resolution failed for {area_name:?}: {e}");
|
||||||
|
}
|
||||||
let display = country
|
let display = country
|
||||||
.map(|c| format!("{}, {}", c, area_name))
|
.map(|c| format!("{}, {}", c, area_name))
|
||||||
.unwrap_or_else(|| area_name.to_string());
|
.unwrap_or_else(|| area_name.to_string());
|
||||||
@@ -2503,11 +2818,9 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let source_seeds = AffinitySeeds {
|
if cancel.is_cancelled() {
|
||||||
genres: genres.clone(),
|
return Err(SceneDiscoveryError::Cancelled);
|
||||||
tags: tags.clone(),
|
}
|
||||||
normalized_seeds: genres.iter().chain(tags.iter()).cloned().collect(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Step 2: pick search genres, dropping overly broad tags that
|
// Step 2: pick search genres, dropping overly broad tags that
|
||||||
// would return the whole country's catalog.
|
// would return the whole country's catalog.
|
||||||
@@ -2549,13 +2862,57 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
let mut candidate_map: HashMap<String, (String, i32, usize, Vec<String>)> =
|
let mut candidate_map: HashMap<String, (String, i32, usize, Vec<String>)> =
|
||||||
HashMap::new();
|
HashMap::new();
|
||||||
let per_genre_limit = 200usize;
|
let per_genre_limit = 200usize;
|
||||||
for genre in &search_genres {
|
let total_genres = search_genres.len();
|
||||||
let result = self
|
// Failures used to be swallowed one by one and the whole method still
|
||||||
|
// returned Ok(empty). Counted now, because "one genre 503'd" and "the
|
||||||
|
// integration is off / we are offline" must not look the same.
|
||||||
|
let mut genre_failures = 0usize;
|
||||||
|
let mut last_genre_error = String::new();
|
||||||
|
for (genre_idx, genre) in search_genres.iter().enumerate() {
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
return Err(SceneDiscoveryError::Cancelled);
|
||||||
|
}
|
||||||
|
// Progress: MusicBrainz search phase = 0-40 %.
|
||||||
|
progress(
|
||||||
|
ScenePhase::Searching,
|
||||||
|
((genre_idx as f64 / total_genres as f64) * 40.0) as u8,
|
||||||
|
genre.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut result = self
|
||||||
.musicbrainz
|
.musicbrainz
|
||||||
.search_artists_by_tag_and_area(genre, &search_name, country, per_genre_limit, 0)
|
.search_artists_by_tag_and_area(genre, &search_name, country, per_genre_limit, 0)
|
||||||
.await;
|
.await;
|
||||||
let Ok(response) = result else {
|
|
||||||
continue;
|
// Honour the server's Retry-After once. MusicBrainz answers 503
|
||||||
|
// (and the proxy 429) with a whole-second header; the client has
|
||||||
|
// already parsed it into RateLimited(secs).
|
||||||
|
if let Err(qbz_integrations::IntegrationError::RateLimited(secs)) = &result {
|
||||||
|
let secs = *secs;
|
||||||
|
log::info!("[scene] MusicBrainz rate-limited on {genre:?}, backing off {secs}s");
|
||||||
|
if !scene_backoff(secs, cancel).await {
|
||||||
|
return Err(SceneDiscoveryError::Cancelled);
|
||||||
|
}
|
||||||
|
result = self
|
||||||
|
.musicbrainz
|
||||||
|
.search_artists_by_tag_and_area(
|
||||||
|
genre,
|
||||||
|
&search_name,
|
||||||
|
country,
|
||||||
|
per_genre_limit,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = match result {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[scene] genre+area search failed for {genre:?}: {e}");
|
||||||
|
genre_failures += 1;
|
||||||
|
last_genre_error = e.to_string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
for artist in &response.artists {
|
for artist in &response.artists {
|
||||||
if artist.id == source_mbid {
|
if artist.id == source_mbid {
|
||||||
@@ -2599,6 +2956,13 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every upstream search failed => this is an ERROR, not an empty
|
||||||
|
// scene. It is also how "MusicBrainz is disabled" arrives, since the
|
||||||
|
// client refuses each call with ServiceUnavailable.
|
||||||
|
if total_genres > 0 && genre_failures == total_genres {
|
||||||
|
return Err(SceneDiscoveryError::UpstreamUnavailable(last_genre_error));
|
||||||
|
}
|
||||||
|
|
||||||
// Step 3: score + sort. Final = affinity + (genre_hits-1)*15.
|
// Step 3: score + sort. Final = affinity + (genre_hits-1)*15.
|
||||||
let mut scored: Vec<(String, String, Vec<String>, i32)> = candidate_map
|
let mut scored: Vec<(String, String, Vec<String>, i32)> = candidate_map
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -2623,22 +2987,127 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
|
|
||||||
// Step 4: validate against Qobuz (exact normalized-name match,
|
// Step 4: validate against Qobuz (exact normalized-name match,
|
||||||
// pick the one with the most albums as a popularity proxy).
|
// pick the one with the most albums as a popularity proxy).
|
||||||
|
let total_to_validate = to_validate.len();
|
||||||
|
progress(
|
||||||
|
ScenePhase::Validating,
|
||||||
|
40,
|
||||||
|
format!("{} candidates", total_to_validate),
|
||||||
|
);
|
||||||
|
|
||||||
let mut validated: Vec<LocationCandidate> = Vec::new();
|
let mut validated: Vec<LocationCandidate> = Vec::new();
|
||||||
for (mbid, mb_name, candidate_genres, score) in &to_validate {
|
let mut validation_failures = 0usize;
|
||||||
let Ok(results) = self.search_artists(mb_name, 5, 0, None).await else {
|
let mut last_validation_error = String::new();
|
||||||
|
for (validate_idx, (mbid, mb_name, candidate_genres, score)) in
|
||||||
|
to_validate.iter().enumerate()
|
||||||
|
{
|
||||||
|
if cancel.is_cancelled() {
|
||||||
|
return Err(SceneDiscoveryError::Cancelled);
|
||||||
|
}
|
||||||
|
// Progress: Qobuz validation phase = 40-95 %, every 5th row.
|
||||||
|
if validate_idx % 5 == 0 && total_to_validate > 0 {
|
||||||
|
progress(
|
||||||
|
ScenePhase::Validating,
|
||||||
|
40 + ((validate_idx as f64 / total_to_validate as f64) * 55.0) as u8,
|
||||||
|
format!("{}/{}", validate_idx, total_to_validate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let name_normalized = MusicBrainzCache::normalize_name(mb_name);
|
||||||
|
|
||||||
|
// Read-through validation cache. Only the Qobuz projection is
|
||||||
|
// stored (id/name/image/album count) — those four are a pure
|
||||||
|
// function of the name. The candidate is rebuilt around them from
|
||||||
|
// THIS scene's mbid, score and genres, which is exactly what made
|
||||||
|
// the Tauri-era whole-LocationCandidate cache unreplayable.
|
||||||
|
let mut cached_match = None;
|
||||||
|
if let Ok(guard) = self.musicbrainz_cache.lock() {
|
||||||
|
if let Some(cache) = guard.as_ref() {
|
||||||
|
cached_match = cache.get_qobuz_artist_match(&name_normalized).ok().flatten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A hit on a since-blacklisted artist is treated as a MISS, not as
|
||||||
|
// "no match": re-running the search lets the next-best same-name
|
||||||
|
// Qobuz artist take the slot, exactly as a cold run would.
|
||||||
|
let cached_match =
|
||||||
|
cached_match.filter(|m| !(m.qobuz_id > 0 && blacklist(m.qobuz_id as u64)));
|
||||||
|
|
||||||
|
// RE-ASSERT THE ACCEPTANCE GATE ON REPLAY. The cache KEY is
|
||||||
|
// `MusicBrainzCache::normalize_name`, which also strips `'`, `"`,
|
||||||
|
// `.` and `,`; the cold path below accepts a result only when
|
||||||
|
// `normalize_artist_name` matches, and that one merely
|
||||||
|
// trims/lowercases/collapses whitespace. The key is therefore
|
||||||
|
// strictly COARSER than the predicate, so two MusicBrainz
|
||||||
|
// candidates that the cold path would treat as different names
|
||||||
|
// share one cache slot — "Guns N' Roses" and "Guns N Roses",
|
||||||
|
// "Eve." and "Eve". Whichever validated first would then own the
|
||||||
|
// row for BOTH, for the full 30-day TTL, and a replay that skipped
|
||||||
|
// the gate is the only way an artist the cold path would have
|
||||||
|
// REJECTED can end up attributed to a candidate.
|
||||||
|
//
|
||||||
|
// Checked here rather than by narrowing the key, so it still holds
|
||||||
|
// if either normaliser changes independently later.
|
||||||
|
let cached_match = cached_match
|
||||||
|
.filter(|m| normalize_artist_name(&m.name) == normalize_artist_name(mb_name));
|
||||||
|
|
||||||
|
if let Some(hit) = cached_match {
|
||||||
|
validated.push(LocationCandidate {
|
||||||
|
mbid: mbid.clone(),
|
||||||
|
mb_name: mb_name.clone(),
|
||||||
|
qobuz_id: Some(hit.qobuz_id),
|
||||||
|
qobuz_name: Some(hit.name),
|
||||||
|
qobuz_image: hit.image,
|
||||||
|
score: *score,
|
||||||
|
genres: candidate_genres.clone(),
|
||||||
|
qobuz_albums_count: hit.albums_count,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = match self.search_artists(mb_name, 5, 0, None).await {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(e) => {
|
||||||
|
// Terminal classes end the page here rather than burning
|
||||||
|
// up to 99 more searches that will fail the same way.
|
||||||
|
if let Some(fatal) = scene_fatal_qobuz(&e) {
|
||||||
|
log::warn!("[scene] Qobuz validation aborted: {e}");
|
||||||
|
return Err(fatal);
|
||||||
|
}
|
||||||
|
log::warn!("[scene] Qobuz validation failed for {mb_name:?}: {e}");
|
||||||
|
validation_failures += 1;
|
||||||
|
last_validation_error = e.to_string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let mb_norm = normalize_artist_name(mb_name);
|
let mb_norm = normalize_artist_name(mb_name);
|
||||||
|
// The blacklist filters INSIDE the match, not after it: when the
|
||||||
|
// top same-name artist is blocked, the next-best one takes the
|
||||||
|
// slot instead of the row disappearing. That is Tauri's rule.
|
||||||
let best = results
|
let best = results
|
||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|a| normalize_artist_name(&a.name) == mb_norm)
|
.filter(|a| normalize_artist_name(&a.name) == mb_norm && !blacklist(a.id))
|
||||||
.max_by_key(|a| a.albums_count.unwrap_or(0));
|
.max_by_key(|a| a.albums_count.unwrap_or(0));
|
||||||
if let Some(qobuz_artist) = best {
|
if let Some(qobuz_artist) = best {
|
||||||
let image_url = qobuz_artist
|
let image_url = qobuz_artist
|
||||||
.image
|
.image
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|img| img.small.as_ref().or(img.thumbnail.as_ref()).cloned());
|
.and_then(|img| img.small.as_ref().or(img.thumbnail.as_ref()).cloned());
|
||||||
|
// Positive results only. Tauri's negative cache is disabled on
|
||||||
|
// purpose: "not on Qobuz" stuck for 30 days is how a newly
|
||||||
|
// added artist stays invisible.
|
||||||
|
if let Ok(guard) = self.musicbrainz_cache.lock() {
|
||||||
|
if let Some(cache) = guard.as_ref() {
|
||||||
|
let _ = cache.set_qobuz_artist_match(
|
||||||
|
&name_normalized,
|
||||||
|
&QobuzArtistMatch {
|
||||||
|
qobuz_id: qobuz_artist.id as i64,
|
||||||
|
name: qobuz_artist.name.clone(),
|
||||||
|
image: image_url.clone(),
|
||||||
|
albums_count: qobuz_artist.albums_count,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
validated.push(LocationCandidate {
|
validated.push(LocationCandidate {
|
||||||
mbid: mbid.clone(),
|
mbid: mbid.clone(),
|
||||||
mb_name: mb_name.clone(),
|
mb_name: mb_name.clone(),
|
||||||
@@ -2652,18 +3121,46 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// There were candidates and not one could be checked — the catalog is
|
||||||
|
// unreachable. Distinct from "checked them all, none are on Qobuz",
|
||||||
|
// which is a legitimately empty scene.
|
||||||
|
if total_to_validate > 0 && validation_failures == total_to_validate {
|
||||||
|
return Err(SceneDiscoveryError::CatalogUnavailable(
|
||||||
|
last_validation_error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
progress(
|
||||||
|
ScenePhase::Done,
|
||||||
|
100,
|
||||||
|
format!("{} artists", validated.len()),
|
||||||
|
);
|
||||||
|
|
||||||
let scene_label = country
|
let scene_label = country
|
||||||
.map(|c| c.to_string())
|
.map(|c| c.to_string())
|
||||||
.unwrap_or_else(|| display_name.clone());
|
.unwrap_or_else(|| display_name.clone());
|
||||||
let next_offset = offset + to_validate.len();
|
let next_offset = offset + to_validate.len();
|
||||||
Ok(LocationDiscoveryResponse {
|
let response = LocationDiscoveryResponse {
|
||||||
artists: validated,
|
artists: validated,
|
||||||
scene_label,
|
scene_label,
|
||||||
genre_summary: genre_summary(&source_seeds),
|
genre_summary: genre_summary(&source_seeds),
|
||||||
total_candidates,
|
total_candidates,
|
||||||
has_more: next_offset < total_candidates,
|
has_more: next_offset < total_candidates,
|
||||||
next_offset,
|
next_offset,
|
||||||
})
|
};
|
||||||
|
|
||||||
|
// First page only, non-empty only — Tauri's rule. The stored rows are
|
||||||
|
// already blacklist-aware; the post-filter on read catches anything
|
||||||
|
// blocked afterwards, and `invalidate_scene_cache` covers un-blocking.
|
||||||
|
if offset == 0 && !response.artists.is_empty() {
|
||||||
|
if let Ok(guard) = self.musicbrainz_cache.lock() {
|
||||||
|
if let Some(cache) = guard.as_ref() {
|
||||||
|
let _ = cache.set_scene_cache(&cache_key, &response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,4 +62,10 @@ pub use qbz_models::{CoreEvent, FrontendAdapter, LoggingAdapter, NoOpAdapter};
|
|||||||
|
|
||||||
// Re-exports from this crate
|
// Re-exports from this crate
|
||||||
pub use core::{normalize_artist_name, QbzCore};
|
pub use core::{normalize_artist_name, QbzCore};
|
||||||
|
// Scene discovery ("artists from the same place"). A frontend driving it needs
|
||||||
|
// all four: the token to cancel a run it has navigated away from, the phase for
|
||||||
|
// the progress label, and the typed error to tell "offline" and "the upstreams
|
||||||
|
// all failed" apart from an honest empty result — which the untyped
|
||||||
|
// `CoreError` cannot express and which the view's error state depends on.
|
||||||
|
pub use core::{CancelToken, ScenePhase, SceneDiscoveryError};
|
||||||
pub use error::CoreError;
|
pub use error::CoreError;
|
||||||
@@ -26,6 +26,30 @@ const METADATA_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
|||||||
const SCENE_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
const SCENE_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||||
/// TTL for Qobuz artist validation cache (30 days)
|
/// TTL for Qobuz artist validation cache (30 days)
|
||||||
const QOBUZ_VALIDATION_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
const QOBUZ_VALIDATION_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||||
|
/// TTL for the typed Qobuz artist-match cache (30 days, same as above)
|
||||||
|
const QOBUZ_ARTIST_MATCH_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
/// The Qobuz half of a scene validation, and NOTHING else.
|
||||||
|
///
|
||||||
|
/// This exists because the old `mb_qobuz_validation` table stores a whole
|
||||||
|
/// `LocationCandidate` — MBID, affinity score and genres included — keyed on
|
||||||
|
/// the normalised artist NAME alone. Tauri only ever wrote it; reading it back
|
||||||
|
/// would transplant one scene's identity and ranking onto another scene's
|
||||||
|
/// candidate with the same name. Only these four fields are a pure function of
|
||||||
|
/// the name (they are literally what the Qobuz artist search returns), so only
|
||||||
|
/// these four are safe to replay. The caller rebuilds the candidate around them
|
||||||
|
/// from the CURRENT mbid, score and genres.
|
||||||
|
///
|
||||||
|
/// Negative results are deliberately NOT cached: Tauri disabled its negative
|
||||||
|
/// cache (`// Negative cache — TEMPORARILY DISABLED`) and a "not on Qobuz"
|
||||||
|
/// verdict that sticks for 30 days is how a newly-added artist stays invisible.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct QobuzArtistMatch {
|
||||||
|
pub qobuz_id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub albums_count: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Cache statistics
|
/// Cache statistics
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
@@ -123,6 +147,17 @@ impl MusicBrainzCache {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_mb_qobuz_validation_fetched ON mb_qobuz_validation(fetched_at);
|
CREATE INDEX IF NOT EXISTS idx_mb_qobuz_validation_fetched ON mb_qobuz_validation(fetched_at);
|
||||||
|
|
||||||
|
-- Typed Qobuz artist match (id/name/image/album count only),
|
||||||
|
-- keyed on the normalized artist name. Separate table from
|
||||||
|
-- mb_qobuz_validation because that one holds Tauri-era whole
|
||||||
|
-- LocationCandidate rows that must never be replayed.
|
||||||
|
CREATE TABLE IF NOT EXISTS mb_qobuz_artist_match (
|
||||||
|
name_normalized TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
fetched_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mb_qobuz_artist_match_fetched ON mb_qobuz_artist_match(fetched_at);
|
||||||
|
|
||||||
-- V2 resolved tracks (simple cache)
|
-- V2 resolved tracks (simple cache)
|
||||||
CREATE TABLE IF NOT EXISTS resolved_tracks (
|
CREATE TABLE IF NOT EXISTS resolved_tracks (
|
||||||
isrc TEXT PRIMARY KEY,
|
isrc TEXT PRIMARY KEY,
|
||||||
@@ -455,9 +490,75 @@ impl MusicBrainzCache {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop every cached scene, leaving the rest of the cache alone.
|
||||||
|
///
|
||||||
|
/// Targeted invalidation for the cases a post-filter cannot fix: the
|
||||||
|
/// blacklist is applied INSIDE the validation loop (a blocked artist makes
|
||||||
|
/// the loop pick the next-best same-name Qobuz artist for that MBID), so
|
||||||
|
/// UN-blacklisting cannot be undone by filtering on the way out. Also the
|
||||||
|
/// right hammer on account switch, because the account territory decides
|
||||||
|
/// which candidates validate and is not yet part of the key.
|
||||||
|
///
|
||||||
|
/// Returns the number of rows removed.
|
||||||
|
pub fn clear_scene_cache(&self) -> Result<usize, String> {
|
||||||
|
let deleted = self
|
||||||
|
.conn
|
||||||
|
.execute("DELETE FROM mb_scene_cache", [])
|
||||||
|
.map_err(|e| format!("Failed to clear scene cache: {}", e))?;
|
||||||
|
if deleted > 0 {
|
||||||
|
log::info!("MusicBrainz scene cache invalidated: {} entries", deleted);
|
||||||
|
}
|
||||||
|
Ok(deleted)
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Qobuz Validation Cache ============
|
// ============ Qobuz Validation Cache ============
|
||||||
|
|
||||||
|
/// Get the typed Qobuz match for a normalized artist name.
|
||||||
|
///
|
||||||
|
/// A parse failure is a MISS, not an error — the row is from an older
|
||||||
|
/// shape and re-validating costs one search.
|
||||||
|
pub fn get_qobuz_artist_match(
|
||||||
|
&self,
|
||||||
|
name_normalized: &str,
|
||||||
|
) -> Result<Option<QobuzArtistMatch>, String> {
|
||||||
|
let min_fetched_at = Self::current_timestamp() - QOBUZ_ARTIST_MATCH_TTL_SECS;
|
||||||
|
let result: Option<String> = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT data FROM mb_qobuz_artist_match WHERE name_normalized = ? AND fetched_at > ?",
|
||||||
|
params![name_normalized, min_fetched_at],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| format!("Failed to query artist-match cache: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result.and_then(|data| serde_json::from_str(&data).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache the typed Qobuz match for a normalized artist name.
|
||||||
|
pub fn set_qobuz_artist_match(
|
||||||
|
&self,
|
||||||
|
name_normalized: &str,
|
||||||
|
data: &QobuzArtistMatch,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let fetched_at = Self::current_timestamp();
|
||||||
|
let json = serde_json::to_string(data)
|
||||||
|
.map_err(|e| format!("Failed to serialize artist match: {}", e))?;
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"INSERT OR REPLACE INTO mb_qobuz_artist_match (name_normalized, data, fetched_at) VALUES (?, ?, ?)",
|
||||||
|
params![name_normalized, json, fetched_at],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to cache artist match: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get cached Qobuz validation result for an artist name
|
/// Get cached Qobuz validation result for an artist name
|
||||||
|
///
|
||||||
|
/// DEAD in this tree and left that way on purpose: the stored value is a
|
||||||
|
/// whole scene-specific `LocationCandidate` keyed on the name alone, so
|
||||||
|
/// replaying it in another scene transplants the wrong MBID, score and
|
||||||
|
/// genres. [`Self::get_qobuz_artist_match`] is the safe replacement.
|
||||||
pub fn get_qobuz_validation(&self, name_normalized: &str) -> Result<Option<String>, String> {
|
pub fn get_qobuz_validation(&self, name_normalized: &str) -> Result<Option<String>, String> {
|
||||||
let min_fetched_at = Self::current_timestamp() - QOBUZ_VALIDATION_TTL_SECS;
|
let min_fetched_at = Self::current_timestamp() - QOBUZ_VALIDATION_TTL_SECS;
|
||||||
let result: Option<String> = self
|
let result: Option<String> = self
|
||||||
@@ -629,6 +730,7 @@ impl MusicBrainzCache {
|
|||||||
("mb_artist_metadata", METADATA_TTL_SECS),
|
("mb_artist_metadata", METADATA_TTL_SECS),
|
||||||
("mb_scene_cache", SCENE_TTL_SECS),
|
("mb_scene_cache", SCENE_TTL_SECS),
|
||||||
("mb_qobuz_validation", QOBUZ_VALIDATION_TTL_SECS),
|
("mb_qobuz_validation", QOBUZ_VALIDATION_TTL_SECS),
|
||||||
|
("mb_qobuz_artist_match", QOBUZ_ARTIST_MATCH_TTL_SECS),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (table, ttl) in &tables_and_ttls {
|
for (table, ttl) in &tables_and_ttls {
|
||||||
@@ -662,6 +764,7 @@ impl MusicBrainzCache {
|
|||||||
DELETE FROM mb_artist_metadata;
|
DELETE FROM mb_artist_metadata;
|
||||||
DELETE FROM mb_scene_cache;
|
DELETE FROM mb_scene_cache;
|
||||||
DELETE FROM mb_qobuz_validation;
|
DELETE FROM mb_qobuz_validation;
|
||||||
|
DELETE FROM mb_qobuz_artist_match;
|
||||||
DELETE FROM resolved_tracks;
|
DELETE FROM resolved_tracks;
|
||||||
DELETE FROM resolved_artists;
|
DELETE FROM resolved_artists;
|
||||||
UPDATE cache_stats SET value = 0;
|
UPDATE cache_stats SET value = 0;
|
||||||
|
|||||||
@@ -236,6 +236,16 @@ pub fn compute_affinity_score(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build the scene cache key from location + seeds
|
/// Build the scene cache key from location + seeds
|
||||||
|
///
|
||||||
|
/// UNFIT — kept only so no out-of-tree caller breaks; it has zero callers in
|
||||||
|
/// this workspace. It carries **only** area + an ordered seed hash, while the
|
||||||
|
/// response also depends on the source MBID (that artist is excluded from its
|
||||||
|
/// own scene), the country, the page size and the blacklist. Two callers asking
|
||||||
|
/// for different page sizes against the same DB would share one entry. Use
|
||||||
|
/// [`build_scene_cache_key_v1`].
|
||||||
|
#[deprecated(
|
||||||
|
note = "unfit key: ignores source_mbid/country/page_size. Use build_scene_cache_key_v1."
|
||||||
|
)]
|
||||||
pub fn build_scene_cache_key(area_id: &str, seeds: &AffinitySeeds) -> String {
|
pub fn build_scene_cache_key(area_id: &str, seeds: &AffinitySeeds) -> String {
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
@@ -249,6 +259,170 @@ pub fn build_scene_cache_key(area_id: &str, seeds: &AffinitySeeds) -> String {
|
|||||||
format!("{}:{:x}", area_id, seed_hash)
|
format!("{}:{:x}", area_id, seed_hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Version tag baked into every scene cache key.
|
||||||
|
///
|
||||||
|
/// BUMP IT whenever the discovery pipeline changes what a stored
|
||||||
|
/// `LocationDiscoveryResponse` means — scoring weights, the broad-tag filter,
|
||||||
|
/// the per-genre limit, the `LocationCandidate` shape. Bumping is how old
|
||||||
|
/// entries get abandoned; there is no migration and none is wanted, since a
|
||||||
|
/// stale scene is indistinguishable from a fresh one on the wire.
|
||||||
|
pub const SCENE_CACHE_KEY_VERSION: &str = "v1";
|
||||||
|
|
||||||
|
/// Everything a scene discovery response actually depends on.
|
||||||
|
///
|
||||||
|
/// Every field here is load-bearing, which is the whole point — the previous
|
||||||
|
/// key had two of them:
|
||||||
|
/// - `source_mbid`: the source artist is *excluded* from their own scene, so
|
||||||
|
/// two artists from the same city with the same seeds produce different rows.
|
||||||
|
/// - `area_id`: the MB area id as the caller supplied it, falling back to the
|
||||||
|
/// area NAME when there is none. Deliberately the pre-resolution identity:
|
||||||
|
/// city → parent-subdivision resolution is a pure function of this id, so
|
||||||
|
/// keying on it lets the cache be consulted BEFORE those up-to-5 MB hops are
|
||||||
|
/// spent. Two cities in one subdivision therefore keep separate entries,
|
||||||
|
/// which is also what Tauri did.
|
||||||
|
/// - `country`: the MB query is `area:"<country>"` whenever a country is known,
|
||||||
|
/// so it, not the subdivision, is what narrows the search.
|
||||||
|
/// - the seeds: they pick the search genres AND drive the affinity scoring.
|
||||||
|
/// - `page_size`: a 30-row response must never be served to a 100-row caller.
|
||||||
|
/// - `catalog_scope`: the Qobuz account territory decides which candidates
|
||||||
|
/// validate. Currently `None` everywhere (no accessor exists on the core);
|
||||||
|
/// the slot is here so filling it later is a key change, not a schema change.
|
||||||
|
/// Until then, invalidate the scene cache on account switch.
|
||||||
|
pub struct SceneCacheKey<'a> {
|
||||||
|
pub source_mbid: &'a str,
|
||||||
|
pub area_id: &'a str,
|
||||||
|
pub country: Option<&'a str>,
|
||||||
|
pub seeds: &'a AffinitySeeds,
|
||||||
|
pub page_size: usize,
|
||||||
|
pub catalog_scope: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the versioned scene cache key.
|
||||||
|
///
|
||||||
|
/// The seed lists are hashed rather than inlined because they are unbounded;
|
||||||
|
/// everything else stays readable in the key so a human can eyeball the DB.
|
||||||
|
pub fn build_scene_cache_key_v1(key: &SceneCacheKey<'_>) -> String {
|
||||||
|
format!(
|
||||||
|
"{}|{}|{}|{}|{:016x}|{}|{}",
|
||||||
|
SCENE_CACHE_KEY_VERSION,
|
||||||
|
key.source_mbid,
|
||||||
|
key.area_id,
|
||||||
|
key.country.unwrap_or("-"),
|
||||||
|
seed_signature(key.seeds),
|
||||||
|
key.page_size,
|
||||||
|
key.catalog_scope.unwrap_or("-"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable 64-bit signature of the affinity seeds.
|
||||||
|
///
|
||||||
|
/// Two deliberate choices:
|
||||||
|
///
|
||||||
|
/// 1. **Order is preserved**, not sorted. It looks like sorting would give a
|
||||||
|
/// better hit rate, but the pipeline slices these lists — `genres.chain(
|
||||||
|
/// tags.take(2))`, and `take(3)` on the all-broad fallback — so a different
|
||||||
|
/// input order is a genuinely different query. Sorting would collapse two
|
||||||
|
/// different result sets onto one entry. Duplicates are dropped keeping the
|
||||||
|
/// FIRST occurrence, which is exactly what `take(n)` sees.
|
||||||
|
/// 2. **FNV-1a, not `DefaultHasher`.** `DefaultHasher`'s output is explicitly
|
||||||
|
/// not guaranteed stable across Rust releases; a toolchain bump would
|
||||||
|
/// silently orphan every cached scene. FNV-1a is eight lines and frozen.
|
||||||
|
///
|
||||||
|
/// Genres and tags are hashed as separate sections so `genres=[a] tags=[b]`
|
||||||
|
/// cannot collide with `genres=[a,b] tags=[]` — they search differently.
|
||||||
|
fn seed_signature(seeds: &AffinitySeeds) -> u64 {
|
||||||
|
let mut buf = String::new();
|
||||||
|
push_canonical_list(&mut buf, &seeds.genres);
|
||||||
|
buf.push('\u{1e}'); // record separator between the two sections
|
||||||
|
push_canonical_list(&mut buf, &seeds.tags);
|
||||||
|
fnv1a64(buf.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_canonical_list(buf: &mut String, list: &[String]) {
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
for raw in list {
|
||||||
|
let canonical = normalize_genre(raw);
|
||||||
|
if canonical.is_empty() || !seen.insert(canonical.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
buf.push_str(&canonical);
|
||||||
|
buf.push('\u{1f}'); // unit separator between entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FNV-1a, 64-bit. Deterministic across toolchains, forever, with no dependency.
|
||||||
|
fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||||
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
for byte in bytes {
|
||||||
|
hash ^= *byte as u64;
|
||||||
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod scene_key_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn seeds(genres: &[&str], tags: &[&str]) -> AffinitySeeds {
|
||||||
|
AffinitySeeds {
|
||||||
|
genres: genres.iter().map(|s| s.to_string()).collect(),
|
||||||
|
tags: tags.iter().map(|s| s.to_string()).collect(),
|
||||||
|
normalized_seeds: genres
|
||||||
|
.iter()
|
||||||
|
.chain(tags.iter())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(source_mbid: &str, page_size: usize, s: &AffinitySeeds) -> String {
|
||||||
|
build_scene_cache_key_v1(&SceneCacheKey {
|
||||||
|
source_mbid,
|
||||||
|
area_id: "area-1",
|
||||||
|
country: Some("United Kingdom"),
|
||||||
|
seeds: s,
|
||||||
|
page_size,
|
||||||
|
catalog_scope: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_size_is_part_of_the_key() {
|
||||||
|
// The whole reason R7 was re-decided: Slint asks for 30 rows and Qt
|
||||||
|
// asks for 100 against the same DB file.
|
||||||
|
let s = seeds(&["post-punk"], &[]);
|
||||||
|
assert_ne!(key("mbid-a", 30, &s), key("mbid-a", 100, &s));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_artist_is_part_of_the_key() {
|
||||||
|
// The source artist is excluded from their own scene, so two artists
|
||||||
|
// with identical area+seeds do NOT share a response.
|
||||||
|
let s = seeds(&["post-punk"], &[]);
|
||||||
|
assert_ne!(key("mbid-a", 100, &s), key("mbid-b", 100, &s));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seed_sections_do_not_collide() {
|
||||||
|
// genres=[a] tags=[b] searches differently from genres=[a,b] tags=[].
|
||||||
|
assert_ne!(
|
||||||
|
key("mbid-a", 100, &seeds(&["post-punk"], &["new wave"])),
|
||||||
|
key("mbid-a", 100, &seeds(&["post-punk", "new wave"], &[])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seed_order_is_significant_but_duplicates_are_not() {
|
||||||
|
let s = seeds(&["post-punk", "new wave"], &[]);
|
||||||
|
assert_ne!(key("mbid-a", 100, &s), key("mbid-a", 100, &seeds(&["new wave", "post-punk"], &[])));
|
||||||
|
assert_eq!(
|
||||||
|
key("mbid-a", 100, &s),
|
||||||
|
key("mbid-a", 100, &seeds(&["post-punk", "new wave", "post-punk"], &[])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Format a human-readable date from MB life_span
|
/// Format a human-readable date from MB life_span
|
||||||
pub fn format_life_span_date(life_span: &LifeSpan, _is_person: bool) -> Option<String> {
|
pub fn format_life_span_date(life_span: &LifeSpan, _is_person: bool) -> Option<String> {
|
||||||
let begin = life_span.begin.as_deref()?;
|
let begin = life_span.begin.as_deref()?;
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ fn main() {
|
|||||||
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
|
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
|
||||||
// modules and must NOT be listed — only files that declare a
|
// modules and must NOT be listed — only files that declare a
|
||||||
// #[cxx_qt::bridge] mod belong in this array.
|
// #[cxx_qt::bridge] mod belong in this array.
|
||||||
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs"],
|
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/scene_bridge.rs", "src/musician_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs"],
|
||||||
qml_files: &[
|
qml_files: &[
|
||||||
"qml/LoginScreen.qml",
|
"qml/LoginScreen.qml",
|
||||||
"qml/Main.qml",
|
"qml/Main.qml",
|
||||||
@@ -352,6 +352,11 @@ fn main() {
|
|||||||
"qml/views/AlbumView.qml",
|
"qml/views/AlbumView.qml",
|
||||||
"qml/views/CoverLightbox.qml",
|
"qml/views/CoverLightbox.qml",
|
||||||
"qml/views/ArtistReleasesView.qml",
|
"qml/views/ArtistReleasesView.qml",
|
||||||
|
"qml/views/ArtistSceneView.qml",
|
||||||
|
"qml/views/scene/SceneGenreFilter.qml",
|
||||||
|
"qml/views/scene/AlphaIndexRail.qml",
|
||||||
|
"qml/views/scene/ScenePanelMode.qml",
|
||||||
|
"qml/views/MusicianPageView.qml",
|
||||||
"qml/views/ArtistView.qml",
|
"qml/views/ArtistView.qml",
|
||||||
"qml/views/BlacklistManagerView.qml",
|
"qml/views/BlacklistManagerView.qml",
|
||||||
"qml/views/HomeView.qml",
|
"qml/views/HomeView.qml",
|
||||||
@@ -415,6 +420,7 @@ fn main() {
|
|||||||
"qml/shell/TrackInfoBody.qml",
|
"qml/shell/TrackInfoBody.qml",
|
||||||
"qml/shell/TrackInfoModal.qml",
|
"qml/shell/TrackInfoModal.qml",
|
||||||
"qml/shell/AlbumInfoModal.qml",
|
"qml/shell/AlbumInfoModal.qml",
|
||||||
|
"qml/shell/MusicianModal.qml",
|
||||||
"qml/shell/TransportControls.qml",
|
"qml/shell/TransportControls.qml",
|
||||||
"qml/views/local/LocalIconSelect.qml",
|
"qml/views/local/LocalIconSelect.qml",
|
||||||
"qml/views/local/LocalTip.qml",
|
"qml/views/local/LocalTip.qml",
|
||||||
|
|||||||
@@ -297,6 +297,8 @@ ApplicationWindow {
|
|||||||
QbzLibrary.boot()
|
QbzLibrary.boot()
|
||||||
QbzAlbum.boot()
|
QbzAlbum.boot()
|
||||||
QbzArtist.boot()
|
QbzArtist.boot()
|
||||||
|
QbzScene.boot()
|
||||||
|
QbzMusician.boot()
|
||||||
QbzLyrics.boot()
|
QbzLyrics.boot()
|
||||||
QbzCast.boot()
|
QbzCast.boot()
|
||||||
QbzBridge.boot()
|
QbzBridge.boot()
|
||||||
|
|||||||
@@ -83,11 +83,21 @@ Popup {
|
|||||||
QbzHome.openLabel(labelId)
|
QbzHome.openLabel(labelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Musician click — consumer #4 of six (contract §2.2).
|
||||||
|
//
|
||||||
|
// ORDER: dispatch, THEN close — the reverse of what stood here, and it
|
||||||
|
// matches the reference (AlbumCreditsModal.svelte:20-28). It matters now
|
||||||
|
// that the click has a destination: a `weak`/`none` result opens the
|
||||||
|
// global MusicianModal, and closing this one first hands focus back to the
|
||||||
|
// shell a moment before that modal takes it, which is precisely the
|
||||||
|
// unstable-focus window the contract flags (§5.2). The neighbouring
|
||||||
|
// openArtist/openLabel keep close-first because they navigate the shell
|
||||||
|
// underneath rather than summoning another overlay.
|
||||||
function openMusician(name, primaryRole) {
|
function openMusician(name, primaryRole) {
|
||||||
if (!name || name === "")
|
if (!name || name === "")
|
||||||
return
|
return
|
||||||
close()
|
|
||||||
QbzArtist.resolveMusician(name, primaryRole || "")
|
QbzArtist.resolveMusician(name, primaryRole || "")
|
||||||
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cover source: the custom override beats the remote best() URL, same
|
// Cover source: the custom override beats the remote best() URL, same
|
||||||
|
|||||||
@@ -703,6 +703,20 @@ Rectangle {
|
|||||||
MyQbzModals {
|
MyQbzModals {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
}
|
}
|
||||||
|
// MusicianModal — where a WEAK / NONE / resolve-error musician click lands.
|
||||||
|
// It is the majority branch, not an edge case: most credited sidemen are
|
||||||
|
// not Qobuz artists, and before this the click did nothing at all.
|
||||||
|
//
|
||||||
|
// Out here, and not in any of its six openers, for the strongest version of
|
||||||
|
// the reason its neighbours give: FOUR of those six openers are themselves
|
||||||
|
// modals or overlays (album credits, desktop track info, the immersive
|
||||||
|
// track-info panel), and each CLOSES ITSELF as it dispatches — so a modal
|
||||||
|
// parented into the opener would be destroyed by the very click that
|
||||||
|
// summoned it. Self-gates on QbzMusician.modalJson, so while closed it is
|
||||||
|
// an invisible, non-interactive Item.
|
||||||
|
MusicianModal {
|
||||||
|
anchors.fill: parent
|
||||||
|
}
|
||||||
// FolderModals — the "New folder" create panel, the full folder editor and
|
// FolderModals — the "New folder" create panel, the full folder editor and
|
||||||
// their delete confirm (contract D21). Out here for the same reason as its
|
// their delete confirm (contract D21). Out here for the same reason as its
|
||||||
// neighbours: it is opened from the SIDEBAR's "..." menu and row menu as
|
// neighbours: it is opened from the SIDEBAR's "..." menu and row menu as
|
||||||
|
|||||||
@@ -102,6 +102,18 @@ Item {
|
|||||||
// to "" and the pane goes blank with nothing logged, i.e. the
|
// to "" and the pane goes blank with nothing logged, i.e. the
|
||||||
// dead "See discography" in a new costume.
|
// dead "See discography" in a new costume.
|
||||||
: QbzShell.currentView === "artistreleases" ? "../views/ArtistReleasesView.qml"
|
: QbzShell.currentView === "artistreleases" ? "../views/ArtistReleasesView.qml"
|
||||||
|
// Artist page > Network > Origin > the location link, and the
|
||||||
|
// header ⋯ > "Artist Scene". Both doors call QbzScene.open(...),
|
||||||
|
// which records this id (artist_scene_qt.rs:430). Same two-file
|
||||||
|
// contract as everything else in this chain — and the same failure
|
||||||
|
// mode if the arm is forgotten: a blank pane, logged nowhere,
|
||||||
|
// recoverable with Back, i.e. indistinguishable from the dead
|
||||||
|
// click the whole feature exists to fix.
|
||||||
|
: QbzShell.currentView === "scene" ? "../views/ArtistSceneView.qml"
|
||||||
|
// A credited musician who did NOT resolve to a Qobuz artist:
|
||||||
|
// musician_qt.rs:362 records this on the `contextual` branch only.
|
||||||
|
// `weak`/`none` are a global modal, not a route — see AppShell.
|
||||||
|
: QbzShell.currentView === "musician" ? "../views/MusicianPageView.qml"
|
||||||
// For You > Qobuz Mixes. The whole chain existed —
|
// For You > Qobuz Mixes. The whole chain existed —
|
||||||
// HomeView's tile -> QbzHome.openMix -> foryou_qt -> nav_qt
|
// HomeView's tile -> QbzHome.openMix -> foryou_qt -> nav_qt
|
||||||
// records the "mix" view — and MixView.qml is written and
|
// records the "mix" view — and MixView.qml is written and
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
// Musician modal — QML port of Tauri's MusicianModal.svelte (322 lines, read in
|
||||||
|
// full). The surface a WEAK / NONE / resolve-error musician click lands on: it
|
||||||
|
// is NOT a page. Its own docblock is accurate — "provides context only", "does
|
||||||
|
// NOT show albums, tracks, or playlists", "does NOT allow deep navigation" —
|
||||||
|
// and it is the majority case, because most sidemen are not Qobuz artists.
|
||||||
|
//
|
||||||
|
// THE PAIR: this and views/MusicianPageView.qml COEXIST. A single dispatcher in
|
||||||
|
// Rust (musician_qt.rs) picks between them by confidence and publishes either
|
||||||
|
// `musicianJson` (page) or `modalJson` (this). QML never branches on
|
||||||
|
// confidence — 00-CONTRACT §13.2.
|
||||||
|
//
|
||||||
|
// MOUNT: a global overlay in the AppShell tree, `anchors.fill: parent`, the
|
||||||
|
// same self-gated shape KeyboardShortcutsModal / CustomizeShortcutsModal use.
|
||||||
|
// While `modalJson` is "" this is an invisible, non-interactive Item that
|
||||||
|
// parses nothing. z:3200 satisfies ADR-009 (>= 3000) and clears the port's
|
||||||
|
// in-pane modals at 3100, so it also paints above the immersive overlay — which
|
||||||
|
// is the required behaviour for consumer #6: a weak/none click inside the
|
||||||
|
// immersive Track-info panel must open THIS above the still-open overlay, while
|
||||||
|
// a contextual one navigates underneath it (00-CONTRACT §11/O6).
|
||||||
|
//
|
||||||
|
// Anatomy, every number from the .svelte:
|
||||||
|
// :129-139 backdrop rgba(0,0,0,0.7) full-window, click-outside dismisses
|
||||||
|
// :146-155 card surface-card, radius 16, width 90% max 420, max-height 85vh,
|
||||||
|
// its own scroller, shadow 0/24/48 rgba(0,0,0,0.4)
|
||||||
|
// :168-174 header padding 24/24/16, align-items flex-start, 1px bottom rule
|
||||||
|
// :182-192 48px circular icon, surface-elevated, User glyph 24 muted
|
||||||
|
// (the page's is 56/32 — the modal is one step smaller)
|
||||||
|
// :200-212 name h2 20/600 lh 1.2, role 14 muted CAPITALIZED
|
||||||
|
// :214-232 close 32x32 radius 8 surface-elevated, muted -> hover bg-hover +
|
||||||
|
// text-primary, X glyph 18
|
||||||
|
// :234-253 content padding 24; section margin-bottom 20; h3 11/600 UPPERCASE
|
||||||
|
// letter-spacing 0.5 muted, margin-bottom 12
|
||||||
|
// :255-272 "Known for" — a VERTICAL list (the page uses horizontal chips),
|
||||||
|
// gap 8, rows 14 primary with a 14px muted Music glyph
|
||||||
|
// :274-298 info box: row gap 12, padding 16, surface-elevated, radius 12;
|
||||||
|
// Info glyph 16 muted nudged 2px down; copy 13 / line-height 1.5
|
||||||
|
// :300-321 the CTA — see UNREACHABLE below
|
||||||
|
//
|
||||||
|
// LIVE GEOMETRY, which reading those three margins in isolation gets wrong
|
||||||
|
// (08-critique-findings, MINOR on §4.3): `bands` is empty in practice, so the
|
||||||
|
// info section is BOTH the first and the last child of .modal-content. Its
|
||||||
|
// 24px margin-top does not collapse against the container's 24px padding, so
|
||||||
|
// the box sits 48px below the header rule and 0px above the card bottom (a
|
||||||
|
// last-child margin-bottom of 0, then the container's own 24px padding). A port
|
||||||
|
// that places it at 24 draws a visibly shorter, top-heavy modal. Hence the
|
||||||
|
// content Column below is padded 24 and the info block carries its own 24.
|
||||||
|
//
|
||||||
|
// DELIBERATE DIVERGENCES
|
||||||
|
// - DV-12/§7 the three explanatory strings are hardcoded English in Tauri
|
||||||
|
// and translated here. The catalogue's msgid IS the English
|
||||||
|
// source string, so the English output is byte-identical and the
|
||||||
|
// 1:1 is fully preserved; the other seven locales gain a real
|
||||||
|
// translation. Same for the close tooltip.
|
||||||
|
// - ADR-008 Tauri's band rows are plain text with no container, so this
|
||||||
|
// surface has no pill to demote — unlike the page's chips (DV-19).
|
||||||
|
// - no entry animation. Tauri fades the backdrop in over 150ms and slides the
|
||||||
|
// card up 20px over 200ms. No modal in this port animates its
|
||||||
|
// entry (KeyboardShortcutsModal, CustomizeShortcutsModal,
|
||||||
|
// QbzConfirmModal, AlbumInfoModal, TrackInfoModal — none), and
|
||||||
|
// matching them keeps one behaviour across the shell. Recorded so
|
||||||
|
// it is a decision, not an omission.
|
||||||
|
// - no backdrop blur. `backdrop-filter: blur(4px)` needs a live blur of
|
||||||
|
// everything behind the scrim; the port's shadow/blur doctrine
|
||||||
|
// (TrackInfoModal.qml:17-25) is that such a change owes its own
|
||||||
|
// parity pass rather than riding along with a port.
|
||||||
|
// - the shadow is FAKED (a translucent rounded rect behind the card), the
|
||||||
|
// LoginScreen / QbzConfirmModal / KeyboardShortcutsModal
|
||||||
|
// precedent, for the same reason.
|
||||||
|
// - `user-select: text` on the card (app.css:1580-1584) has no cheap QML
|
||||||
|
// equivalent — a selectable Text is a TextEdit — so the copy is
|
||||||
|
// not selectable here. Flagged, not silently dropped.
|
||||||
|
//
|
||||||
|
// COLOURS: tokens only, per 00-CONTRACT §4.3. The two rgba() literals are
|
||||||
|
// written as Qt.rgba(r,g,b,a) with 0..1 floats and NEVER as 8-digit hex: CSS
|
||||||
|
// and Slint spell it #RRGGBBAA, Qt parses #AARRGGBB, so `#00000066` reads as
|
||||||
|
// FULLY TRANSPARENT black here. That trap has already landed twice in this
|
||||||
|
// port. The two 1px rules bind `borderSubtle` rather than `surfaceElevated`
|
||||||
|
// (Tauri styles them with --bg-tertiary, which is the same literal on dark):
|
||||||
|
// a divider's ROLE token is the border one, and using a surface fill as a
|
||||||
|
// border colour is what breaks in the light themes.
|
||||||
|
//
|
||||||
|
// ESCAPE / FOCUS (00-CONTRACT §5.2). Tauri dismisses on Escape, backdrop click
|
||||||
|
// and the X. Qt's central Escape stack (hotkeys_qt.rs:629-683,
|
||||||
|
// hotkeys_bridge.rs:370-401) does not know this modal and CONSUMES Escape even
|
||||||
|
// when it closes nothing, so Escape had to be owned explicitly. It is owned the
|
||||||
|
// way every other modal in this tree owns it, and no Rust registration is
|
||||||
|
// needed: an Item/FocusScope modal's Keys.onEscapePressed fires BEFORE the
|
||||||
|
// central stack and accepts the event, because the scope holds active focus and
|
||||||
|
// Qt walks the focus chain up to the AppShell root dispatcher, not down
|
||||||
|
// (KeyboardShortcutsModal.qml:43-51, "trap 4"). The grab is DEFERRED 30ms past
|
||||||
|
// the open edge — focusing synchronously races the visibility transition, and
|
||||||
|
// this modal is especially exposed because it opens asynchronously right after
|
||||||
|
// AlbumInfo or TrackInfo closes. On the close edge Qt strands activeFocus on
|
||||||
|
// the now-invisible scope, which kills the AppShell dispatcher until the next
|
||||||
|
// click, so every close path hands focus back to the shell root by duck-walking
|
||||||
|
// to `isQbzShellRoot` (QbzConfirmModal.qml:85-94). The restore is on the EDGE,
|
||||||
|
// not inside close(), so a close driven from Rust (the dispatcher opening the
|
||||||
|
// page instead, a language switch, a shutdown) restores too.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import com.blitzfc.qbz
|
||||||
|
import "../theme"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
QbzTheme { id: theme }
|
||||||
|
|
||||||
|
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||||
|
|
||||||
|
// "" = closed. The document IS the open flag (00-CONTRACT §13.2), so there
|
||||||
|
// is no second bridge property to keep in sync. `musicianRev` is a pure
|
||||||
|
// binding dependency, the QbzSession.tr(msgid, rev) idiom: the parse
|
||||||
|
// ignores it, reading it is what re-runs this binding when Rust republishes
|
||||||
|
// a byte-identical document.
|
||||||
|
readonly property string raw: QbzMusician.modalJson || ""
|
||||||
|
readonly property bool opened: root.raw !== ""
|
||||||
|
readonly property var doc: root._parse(root.raw, QbzMusician.musicianRev)
|
||||||
|
function _parse(s, rev) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s || "{}")
|
||||||
|
} catch (e) {
|
||||||
|
return ({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var bands: doc.bands || []
|
||||||
|
|
||||||
|
// CSS `text-transform: capitalize` — title-case every WORD and leave the
|
||||||
|
// rest of it alone. QML has no text-transform; see MusicianPageView.qml.
|
||||||
|
function _capitalize(s) {
|
||||||
|
if (!s)
|
||||||
|
return ""
|
||||||
|
return String(s).replace(/(^|\s)(\S)/g, function (m, sp, ch) {
|
||||||
|
return sp + ch.toUpperCase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// `explanatoryCopy` (:27-36). Three arms, and the third is the `default:`
|
||||||
|
// one — it fires for ANY confidence the switch does not name, so it is not
|
||||||
|
// dead the way the CTA is.
|
||||||
|
readonly property string explanatoryCopy: {
|
||||||
|
var c = root.doc.confidence || ""
|
||||||
|
if (c === "weak")
|
||||||
|
return root.t("This musician appears in album credits but has limited catalog information available in Qobuz.")
|
||||||
|
if (c === "none")
|
||||||
|
return root.t("Unable to verify this musician's catalog. The information shown is based on album credits.")
|
||||||
|
return root.t("This musician appears in album credits.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNREACHABLE BY CONSTRUCTION, and present anyway (:39-41, :117-123). The
|
||||||
|
// dispatcher only opens this modal for `confirmed` when the Qobuz id is
|
||||||
|
// absent or zero, so the CTA's own guard can never pass; Tauri is identical.
|
||||||
|
// `qobuzArtistId` is NOT a member of the frozen `modalJson` shape
|
||||||
|
// (00-CONTRACT §13.2), so this reads `undefined` and the gate is false —
|
||||||
|
// which is exactly the wanted behaviour, and lights the block up unchanged
|
||||||
|
// the day the Rust lane adds the field. Reading an absent key off a parsed
|
||||||
|
// JSON object is not an error in QML, and it is not a bridge member, so
|
||||||
|
// qml_singleton_xref has nothing to complain about.
|
||||||
|
readonly property bool showArtistCta: root.doc.confidence === "confirmed"
|
||||||
|
&& (root.doc.qobuzArtistId || 0) > 0
|
||||||
|
|
||||||
|
visible: root.opened
|
||||||
|
enabled: root.opened
|
||||||
|
// ADR-009 (>= 3000), above the port's in-pane modals at 3100 and above the
|
||||||
|
// immersive overlay — see the header note on consumer #6.
|
||||||
|
z: 3200
|
||||||
|
|
||||||
|
function _close() {
|
||||||
|
QbzMusician.closeModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// :43-48 — navigate, THEN close, in that order (Tauri's own ordering, and
|
||||||
|
// the same order §2.2 records for the two parent modals).
|
||||||
|
function _openArtist() {
|
||||||
|
if (!root.showArtistCta)
|
||||||
|
return
|
||||||
|
QbzArtist.openArtist(String(root.doc.qobuzArtistId))
|
||||||
|
root._close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus lifecycle on the OPEN/CLOSE EDGE — see the header.
|
||||||
|
onOpenedChanged: {
|
||||||
|
if (root.opened) {
|
||||||
|
focusGrab.restart()
|
||||||
|
} else {
|
||||||
|
var p = root
|
||||||
|
while (p.parent) {
|
||||||
|
if (p.parent.isQbzShellRoot === true) {
|
||||||
|
p.parent.forceActiveFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p = p.parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: focusGrab
|
||||||
|
interval: 30
|
||||||
|
repeat: false
|
||||||
|
onTriggered: keyScope.forceActiveFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The keyboard focus ring — the modal is keyboard-operable for the same
|
||||||
|
// reason the page is (§5.2): every Tauri control here is a native <button>.
|
||||||
|
component FocusRing: Rectangle {
|
||||||
|
property Item host: null
|
||||||
|
property real inset: 3
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -inset
|
||||||
|
color: "transparent"
|
||||||
|
border.width: 2
|
||||||
|
border.color: theme.focusRing
|
||||||
|
visible: host !== null && host.activeFocus
|
||||||
|
z: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusScope {
|
||||||
|
id: keyScope
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
// The modal's OWN Escape. Accepts, so the central §1.2 stack never
|
||||||
|
// double-fires on the same press.
|
||||||
|
Keys.onEscapePressed: function (event) {
|
||||||
|
root._close()
|
||||||
|
event.accepted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backdrop — rgba(0, 0, 0, 0.7). A click ON THE BACKDROP dismisses;
|
||||||
|
// Tauri gates that on `e.target === e.currentTarget` so a click that
|
||||||
|
// bubbled out of the card never closes. Here the card's own MouseArea
|
||||||
|
// swallows those before they can reach this one.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.7)
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: root._close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faked drop shadow (Tauri: 0 24px 48px rgba(0,0,0,0.4)) — see header.
|
||||||
|
Rectangle {
|
||||||
|
x: card.x
|
||||||
|
y: card.y + 12
|
||||||
|
width: card.width
|
||||||
|
height: card.height
|
||||||
|
radius: theme.radiusLg
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.4)
|
||||||
|
opacity: 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: card
|
||||||
|
width: Math.min(root.width * 0.9, 420)
|
||||||
|
height: Math.min(root.height * 0.85, bodyCol.implicitHeight)
|
||||||
|
x: Math.round((root.width - width) / 2)
|
||||||
|
y: Math.round((root.height - height) / 2)
|
||||||
|
radius: theme.radiusLg
|
||||||
|
color: theme.surfaceCard
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
// FIRST child: swallows clicks so they never reach the backdrop.
|
||||||
|
MouseArea { anchors.fill: parent }
|
||||||
|
|
||||||
|
// `.modal { overflow-y: auto; max-height: 85vh }` — the WHOLE card
|
||||||
|
// scrolls, header included; the header is not sticky here.
|
||||||
|
Flickable {
|
||||||
|
id: flick
|
||||||
|
anchors.fill: parent
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: bodyCol.implicitHeight
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: bodyCol
|
||||||
|
width: flick.width
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// ---- Header (:76-89) ----------------------------------
|
||||||
|
// padding 24 / 24 / 16, align-items FLEX-START (the close
|
||||||
|
// button sits at the top of the row, not centred on it).
|
||||||
|
Item {
|
||||||
|
id: headerBox
|
||||||
|
width: parent.width
|
||||||
|
height: headerRow.height + 40
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: headerRow
|
||||||
|
x: 24
|
||||||
|
y: 24
|
||||||
|
width: parent.width - 48
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// .musician-icon — 48px circle, User glyph 24.
|
||||||
|
Rectangle {
|
||||||
|
width: 48
|
||||||
|
height: 48
|
||||||
|
radius: 24
|
||||||
|
color: theme.surfaceElevated
|
||||||
|
QbzIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
name: "user"
|
||||||
|
width: 24
|
||||||
|
height: 24
|
||||||
|
tintName: "muted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .musician-details — column, gap 4.
|
||||||
|
Column {
|
||||||
|
width: parent.width - 48 - 16 - 32 - 16
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.doc.name || ""
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: 20
|
||||||
|
font.weight: theme.weightSemibold
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root._capitalize(root.doc.role || "")
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 14
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .close-btn — 32x32, radius 8, surface-elevated;
|
||||||
|
// muted glyph that goes text-primary on hover, and
|
||||||
|
// the fill goes bg-hover. (The page's back button
|
||||||
|
// was 36px and had no colour change — this one is
|
||||||
|
// its own control.)
|
||||||
|
Item {
|
||||||
|
id: closeCell
|
||||||
|
width: 32
|
||||||
|
height: 32
|
||||||
|
activeFocusOnTab: true
|
||||||
|
Keys.onPressed: function (event) {
|
||||||
|
if (event.key === Qt.Key_Return
|
||||||
|
|| event.key === Qt.Key_Enter
|
||||||
|
|| event.key === Qt.Key_Space) {
|
||||||
|
root._close()
|
||||||
|
event.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: theme.radiusSm
|
||||||
|
color: closeArea.containsMouse
|
||||||
|
? theme.bgHover : theme.surfaceElevated
|
||||||
|
QbzIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
name: "x"
|
||||||
|
width: 18
|
||||||
|
height: 18
|
||||||
|
tintName: closeArea.containsMouse
|
||||||
|
? "textPrimary" : "muted"
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: closeArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root._close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusRing {
|
||||||
|
host: closeCell
|
||||||
|
radius: theme.radiusSm + 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: theme.borderSubtle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Content (.modal-content, padding 24) -------------
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
leftPadding: 24
|
||||||
|
rightPadding: 24
|
||||||
|
topPadding: 24
|
||||||
|
bottomPadding: 24
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
readonly property real contentWidth: width - 48
|
||||||
|
|
||||||
|
// ==== Block 1 — "Known for" (:94-106) =============
|
||||||
|
// DEAD in Tauri (`bands` is hardcoded empty on both
|
||||||
|
// resolver return paths, in BOTH backends) and made
|
||||||
|
// real by DV-2. Absent, not empty, when there is
|
||||||
|
// nothing to show.
|
||||||
|
Column {
|
||||||
|
visible: root.bands.length > 0
|
||||||
|
width: parent.contentWidth
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.t("Known for")
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.weight: theme.weightSemibold
|
||||||
|
font.capitalization: Font.AllUppercase
|
||||||
|
font.letterSpacing: 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
// .bands-list — a VERTICAL list, gap 8. Not
|
||||||
|
// clickable: Tauri renders plain rows here (the
|
||||||
|
// horizontal chips on the PAGE are the clickable
|
||||||
|
// ones), so there is nothing to make live.
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.bands
|
||||||
|
delegate: Row {
|
||||||
|
required property var modelData
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
QbzIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: "music"
|
||||||
|
width: 14
|
||||||
|
height: 14
|
||||||
|
tintName: "muted"
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: modelData.name || ""
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== Block 2 — the explanatory box (:109-114) ====
|
||||||
|
// Always rendered. Its own 24px top margin, which does
|
||||||
|
// NOT collapse into the container's padding — see the
|
||||||
|
// LIVE GEOMETRY note in the header.
|
||||||
|
Column {
|
||||||
|
width: parent.contentWidth
|
||||||
|
topPadding: 24
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: infoRow.height + 32
|
||||||
|
radius: theme.radiusMd
|
||||||
|
color: theme.surfaceElevated
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: infoRow
|
||||||
|
x: 16
|
||||||
|
y: 16
|
||||||
|
width: parent.width - 32
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// The glyph is nudged 2px down so it sits
|
||||||
|
// on the first line of copy, not above it.
|
||||||
|
QbzIcon {
|
||||||
|
y: 2
|
||||||
|
name: "info"
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
tintName: "muted"
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width - 16 - 12
|
||||||
|
text: root.explanatoryCopy
|
||||||
|
color: theme.textSecondary
|
||||||
|
font.pixelSize: 13
|
||||||
|
lineHeight: 1.5
|
||||||
|
lineHeightMode: Text.ProportionalHeight
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== Block 3 — the CTA (:117-123) ================
|
||||||
|
// Present and unreachable — see showArtistCta.
|
||||||
|
Column {
|
||||||
|
visible: root.showArtistCta
|
||||||
|
width: parent.contentWidth
|
||||||
|
topPadding: 24
|
||||||
|
spacing: 20
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: theme.borderSubtle
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: ctaCell
|
||||||
|
width: parent.width
|
||||||
|
height: 44
|
||||||
|
activeFocusOnTab: true
|
||||||
|
Keys.onPressed: function (event) {
|
||||||
|
if (event.key === Qt.Key_Return
|
||||||
|
|| event.key === Qt.Key_Enter
|
||||||
|
|| event.key === Qt.Key_Space) {
|
||||||
|
root._openArtist()
|
||||||
|
event.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// width 100%, padding 12/20, radius 10 — a
|
||||||
|
// literal, not a token: 10 sits between
|
||||||
|
// Radius.sm (8) and Radius.md (12), and this is
|
||||||
|
// not a pill at 44px tall, so ADR-008 has
|
||||||
|
// nothing to say about it.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 10
|
||||||
|
color: ctaArea.containsMouse
|
||||||
|
? theme.accentHover : theme.accent
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.t("View Artist Page")
|
||||||
|
// The on-accent glyph selector, this
|
||||||
|
// port's owner-approved WCAG deviation
|
||||||
|
// (QbzTheme.qml:337) — Tauri hardcodes
|
||||||
|
// --btn-primary-text #ffffff, which is
|
||||||
|
// unreadable on the light accents some
|
||||||
|
// of the 30 themes carry.
|
||||||
|
color: theme.accentGlyphColor
|
||||||
|
font.pixelSize: 14
|
||||||
|
font.weight: theme.weightSemibold
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: ctaArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root._openArtist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusRing {
|
||||||
|
host: ctaCell
|
||||||
|
radius: 13
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QbzScrollBar {
|
||||||
|
target: flick
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 4
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,9 +115,20 @@ Popup {
|
|||||||
// TODO(qt-bridge): no label view in the Qt port (cards/LabelCard.qml has
|
// TODO(qt-bridge): no label view in the Qt port (cards/LabelCard.qml has
|
||||||
// the same hole) — the link is rendered 1:1 and inert.
|
// the same hole) — the link is rendered 1:1 and inert.
|
||||||
function openLabel(labelId) {}
|
function openLabel(labelId) {}
|
||||||
// TODO(qt-bridge): no musician navigation in the Qt port (Slint routes to
|
// Musician click — consumer #5 of six (contract §2.2). This was an empty
|
||||||
// NetworkSidebarActions.musician-clicked) — names hover 1:1, inert.
|
// stub: the name hovered like a link and did nothing.
|
||||||
function openMusician(name, roleRaw) {}
|
//
|
||||||
|
// CLOSE ORDER IS LOAD-BEARING, and it is the reverse of what reads
|
||||||
|
// naturally: the reference dispatches FIRST and closes AFTER
|
||||||
|
// (TrackInfoModal.svelte:20-27). Closing first would destroy this modal —
|
||||||
|
// and, for a `weak`/`none` result, the global MusicianModal is summoned
|
||||||
|
// by the very click that would have killed its opener. Dispatch, then close.
|
||||||
|
function openMusician(name, roleRaw) {
|
||||||
|
if (!name || name === "")
|
||||||
|
return
|
||||||
|
QbzArtist.resolveMusician(name, roleRaw || "")
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
background: Rectangle { color: "#bf000000" }
|
background: Rectangle { color: "#bf000000" }
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -237,6 +237,46 @@ Rectangle {
|
|||||||
readonly property var mbRelationships: network.relationships || ({})
|
readonly property var mbRelationships: network.relationships || ({})
|
||||||
readonly property var stories: artist.stories || []
|
readonly property var stories: artist.stories || []
|
||||||
|
|
||||||
|
// ---- Artist Scene: the gate BOTH doors share (contract §2.1) ---------
|
||||||
|
//
|
||||||
|
// Three terms, and dropping any one of them breaks a door:
|
||||||
|
//
|
||||||
|
// 1. `locationClickable` — computed in Rust (artist_qt.rs `map_origin`)
|
||||||
|
// so QML never re-derives the reference's unparenthesised guard. A
|
||||||
|
// country-only location has nothing to drill into.
|
||||||
|
// 2. `mbid !== ""` — and NOT `network.mbAvailable`, which is the trap.
|
||||||
|
// That flag is the user's MusicBrainz OPT-IN and nothing else
|
||||||
|
// (artist_qt.rs:982-983, whose doc comment used to claim otherwise);
|
||||||
|
// it is seeded SYNCHRONOUSLY with the first artist frame while `mbid`
|
||||||
|
// stays "" until the async publish_network lands. Gating on it alone
|
||||||
|
// lights both doors up on frame one with no id to hand them, and the
|
||||||
|
// click fires with an empty parameter.
|
||||||
|
// 3. `mbAvailable` — the MusicBrainz opt-in, which is now trustworthy at
|
||||||
|
// THIS point in time: the settings toggle republishes the open artist
|
||||||
|
// document (settings_qt.rs, "musicbrainz" arm -> republish_open_artist),
|
||||||
|
// so a stale `locationClickable: true` can no longer survive on a page
|
||||||
|
// the user navigates back to after switching the integration off.
|
||||||
|
readonly property bool sceneAvailable:
|
||||||
|
mbOrigin.locationClickable === true
|
||||||
|
&& (network.mbid || "") !== ""
|
||||||
|
&& network.mbAvailable === true
|
||||||
|
|
||||||
|
/// Both doors call THIS — never `QbzScene.open` directly — so they can
|
||||||
|
/// never drift apart in what they pass.
|
||||||
|
function openArtistScene() {
|
||||||
|
if (!sceneAvailable)
|
||||||
|
return
|
||||||
|
QbzScene.open(network.mbid || "",
|
||||||
|
mbOrigin.artistName || artist.name || "",
|
||||||
|
mbOrigin.locationAreaId || "",
|
||||||
|
mbOrigin.locationCity || "",
|
||||||
|
mbOrigin.locationCountry || "",
|
||||||
|
mbOrigin.locationCountryCode || "",
|
||||||
|
mbOrigin.locationPrecision || "",
|
||||||
|
(mbOrigin.seedGenres || []).join(","),
|
||||||
|
(mbOrigin.seedTags || []).join(","))
|
||||||
|
}
|
||||||
|
|
||||||
property var coverMap: ({})
|
property var coverMap: ({})
|
||||||
property string activeJumpTab: "popular-tracks"
|
property string activeJumpTab: "popular-tracks"
|
||||||
property string artistTab: "catalog"
|
property string artistTab: "catalog"
|
||||||
@@ -1489,8 +1529,14 @@ Rectangle {
|
|||||||
|
|
||||||
// One "KEY value" row of the MB Origin block.
|
// One "KEY value" row of the MB Origin block.
|
||||||
component OriginRow: Item {
|
component OriginRow: Item {
|
||||||
|
id: originRow
|
||||||
property string key: ""
|
property string key: ""
|
||||||
property string value: ""
|
property string value: ""
|
||||||
|
/// When true the value renders as a link and emits `activated`.
|
||||||
|
/// Only the location row ever sets it (door D1 to the Artist Scene);
|
||||||
|
/// born/founded/died/disbanded stay plain text, as in the reference.
|
||||||
|
property bool clickable: false
|
||||||
|
signal activated()
|
||||||
// The host section Column carries 14px of left+right padding, and a
|
// The host section Column carries 14px of left+right padding, and a
|
||||||
// QML Positioner does NOT shrink its children for it — a right-aligned
|
// QML Positioner does NOT shrink its children for it — a right-aligned
|
||||||
// value bound to the bare parent.width would run past the sidebar
|
// value bound to the bare parent.width would run past the sidebar
|
||||||
@@ -1508,16 +1554,38 @@ Rectangle {
|
|||||||
font.letterSpacing: 0.5
|
font.letterSpacing: 0.5
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
|
id: originValue
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.left: originKey.right
|
anchors.left: originKey.right
|
||||||
anchors.leftMargin: 8
|
anchors.leftMargin: 8
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: value
|
text: value
|
||||||
color: theme.textPrimary
|
// The reference's `.origin-location-link` is
|
||||||
|
// `background:none; border:none; padding:0; font-size:13px;
|
||||||
|
// color:var(--accent-primary)` with `text-decoration:underline;
|
||||||
|
// text-underline-offset:2px` on hover
|
||||||
|
// (ArtistDetailView.svelte:3365-3379). Same 13px as the plain
|
||||||
|
// rows, so only the colour and the underline change.
|
||||||
|
color: originRow.clickable ? theme.accent : theme.textPrimary
|
||||||
font.pixelSize: 13
|
font.pixelSize: 13
|
||||||
|
font.underline: originRow.clickable && originLink.containsMouse
|
||||||
horizontalAlignment: Text.AlignRight
|
horizontalAlignment: Text.AlignRight
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
}
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: originLink
|
||||||
|
// Only the text, not the whole row — the key ("BORN IN") is not
|
||||||
|
// part of the link in the reference either.
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: Math.min(originValue.implicitWidth, originValue.width)
|
||||||
|
enabled: originRow.clickable
|
||||||
|
visible: originRow.clickable
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: originRow.activated()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One MB relationship sub-group (Members & Former / Member Of /
|
// One MB relationship sub-group (Members & Former / Member Of /
|
||||||
@@ -1557,7 +1625,20 @@ Rectangle {
|
|||||||
// otherwise) — landing the user on a same-name artist is worse
|
// otherwise) — landing the user on a same-name artist is worse
|
||||||
// than the row doing nothing.
|
// than the row doing nothing.
|
||||||
navigable: true
|
navigable: true
|
||||||
onClicked: QbzArtist.resolveMusician(modelData.name, relGroup.roleKey || "")
|
// `modelData.role`, NOT `relGroup.roleKey`. Rust already built
|
||||||
|
// the right string per ROW: `group_relations` seeds each group
|
||||||
|
// with the reference's own default ("Band Member" / "Band" /
|
||||||
|
// "Collaborator") and then prefers that person's actual first
|
||||||
|
// credited role (artist_qt.rs `map_relationships`), which is
|
||||||
|
// exactly what the reference sends
|
||||||
|
// (ArtistDetailView.svelte:3015,3033,3046). Sending `roleKey`
|
||||||
|
// instead threw all of that away and shipped a per-GROUP
|
||||||
|
// lowercase MusicBrainz key shared by every row — and for the
|
||||||
|
// middle group it is not even a casing difference, it sent
|
||||||
|
// "member of" where the reference sends "Band". The role is
|
||||||
|
// echoed back on every appearance card, so it was visible.
|
||||||
|
onClicked: QbzArtist.resolveMusician(modelData.name,
|
||||||
|
modelData.role || relGroup.roleKey || "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
@@ -2854,11 +2935,13 @@ Rectangle {
|
|||||||
visible: network.originLoading !== true && (mbOrigin.locationDisplay || "") !== ""
|
visible: network.originLoading !== true && (mbOrigin.locationDisplay || "") !== ""
|
||||||
key: mbOrigin.isPerson ? QbzSession.tr("BORN IN", QbzSession.trRev)
|
key: mbOrigin.isPerson ? QbzSession.tr("BORN IN", QbzSession.trRev)
|
||||||
: QbzSession.tr("FOUNDED IN", QbzSession.trRev)
|
: QbzSession.tr("FOUNDED IN", QbzSession.trRev)
|
||||||
// POC-NOTE: the Slint row is clickable and opens
|
// DOOR D1 to the Artist Scene, and the reference's
|
||||||
// ArtistsByLocationView; that view has no port
|
// ONLY door (ArtistDetailView.svelte:2904-2935).
|
||||||
// here, so the affordance is left out rather than
|
// Was plain text under a POC-NOTE until the scene
|
||||||
// rendered dead.
|
// view existed to receive it.
|
||||||
value: mbOrigin.locationDisplay || ""
|
value: mbOrigin.locationDisplay || ""
|
||||||
|
clickable: root.sceneAvailable
|
||||||
|
onActivated: root.openArtistScene()
|
||||||
}
|
}
|
||||||
OriginRow {
|
OriginRow {
|
||||||
visible: network.originLoading !== true && (mbOrigin.endDate || "") !== ""
|
visible: network.originLoading !== true && (mbOrigin.endDate || "") !== ""
|
||||||
@@ -3163,7 +3246,14 @@ Rectangle {
|
|||||||
// and the menu keeps its shape.
|
// and the menu keeps its shape.
|
||||||
model: [
|
model: [
|
||||||
{ "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "disco", "live": true },
|
{ "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "disco", "live": true },
|
||||||
{ "label": QbzSession.tr("Artist Scene", QbzSession.trRev), "icon": "map-pin", "action": "stub", "live": false },
|
// DOOR D2. This row rendered at opacity 0.4 with
|
||||||
|
// `action: "stub"` — the track's named defect class, a
|
||||||
|
// control that renders, persists and drives nothing. It is
|
||||||
|
// a Slint-era invention with no counterpart in the visual
|
||||||
|
// reference (kept on the owner's explicit instruction,
|
||||||
|
// contract ruling R4), and it shares D1's gate exactly so
|
||||||
|
// the two doors can never disagree about availability.
|
||||||
|
{ "label": QbzSession.tr("Artist Scene", QbzSession.trRev), "icon": "map-pin", "action": "scene", "live": root.sceneAvailable },
|
||||||
{ "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "share", "live": true },
|
{ "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "share", "live": true },
|
||||||
{ "label": root.toggleState("artistPin", artist.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("artistPin", artist.isPinned) ? "pin-filled" : "pin", "action": "pin", "live": true },
|
{ "label": root.toggleState("artistPin", artist.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("artistPin", artist.isPinned) ? "pin-filled" : "pin", "action": "pin", "live": true },
|
||||||
]
|
]
|
||||||
@@ -3200,6 +3290,8 @@ Rectangle {
|
|||||||
if (modelData.action === "pin") {
|
if (modelData.action === "pin") {
|
||||||
root.setToggleState("artistPin", !root.toggleState("artistPin", artist.isPinned))
|
root.setToggleState("artistPin", !root.toggleState("artistPin", artist.isPinned))
|
||||||
QbzLibrary.togglePin("artist", artist.id, artist.name, "", artist.artUrl)
|
QbzLibrary.togglePin("artist", artist.id, artist.name, "", artist.artUrl)
|
||||||
|
} else if (modelData.action === "scene") {
|
||||||
|
root.openArtistScene()
|
||||||
} else if (modelData.action === "disco") {
|
} else if (modelData.action === "disco") {
|
||||||
// Discography Builder — ArtistPageView.slint
|
// Discography Builder — ArtistPageView.slint
|
||||||
// :505-511 `media-action("artist", id,
|
// :505-511 `media-action("artist", id,
|
||||||
|
|||||||
@@ -0,0 +1,750 @@
|
|||||||
|
// Musician page — QML port of Tauri's MusicianPageView.svelte (469 lines, read
|
||||||
|
// in full for this port). The surface a CONTEXTUAL musician click lands on: a
|
||||||
|
// name, a role, and a grid of Qobuz albums whose title/artist matched the name.
|
||||||
|
//
|
||||||
|
// THE REFERENCE IS TAURI, NOT SLINT, for this module only (00-CONTRACT §0, the
|
||||||
|
// owner's ruling). Slint's musician page is cited nowhere below; where the two
|
||||||
|
// disagree Tauri wins, and the Slint page's own defects (no error state, so a
|
||||||
|
// network failure renders "No album appearances found."; a downsized 18px name)
|
||||||
|
// are not reproduced.
|
||||||
|
//
|
||||||
|
// Layout, read off the .svelte (every number is that file's):
|
||||||
|
// :216-226 sticky header — padding 24/24/16 over a 56px row, 1px bottom
|
||||||
|
// border, background bg-primary
|
||||||
|
// :253-263 56x56 circular avatar, surface-elevated, User glyph 32, muted.
|
||||||
|
// There is NO photo, ever: ResolvedMusician has no image field
|
||||||
|
// (02-tauri-musician.md §1.3)
|
||||||
|
// :271-283 name h1 24/700, role 14 muted CAPITALIZED
|
||||||
|
// :285-311 content padding 24; section margin-bottom 32; section title
|
||||||
|
// 18/600 with a 14/400 muted "(N)" count
|
||||||
|
// :314-344 Bands & Projects — chips, flex-wrap, gap 10, padding 10/16
|
||||||
|
// :347-351 Appears On grid: repeat(auto-fill, minmax(160px, 1fr)), gap 20 —
|
||||||
|
// a STRETCHING grid, so the cell width is computed, not fixed
|
||||||
|
// :353-426 card: artwork 1:1 radius 8 (no shadow), title 14/500, artist
|
||||||
|
// 12 muted, meta row gap 8 margin-top 4 = date + role badge
|
||||||
|
// :429-440 the three state boxes: column, centred, gap 12, padding 48,
|
||||||
|
// muted 14
|
||||||
|
// :443-468 Load more: 10/24 padded button, surface-elevated, radius 8,
|
||||||
|
// disabled at 0.6
|
||||||
|
//
|
||||||
|
// WHAT THIS PAGE DELIBERATELY DIVERGES ON (00-CONTRACT §6, do not "fix" back):
|
||||||
|
// - DV-15 NO per-view back button. Tauri draws a 36px .back-btn at the head
|
||||||
|
// of its sticky header; in this port back/forward live permanently in
|
||||||
|
// the shell HeaderBar, mounted above the router. The header stack
|
||||||
|
// therefore starts at x=40 — Tauri's 24px padding plus the 16px gap
|
||||||
|
// that sat between the button and the avatar, with the 36px button
|
||||||
|
// itself removed. Same arithmetic ArtistReleasesView.qml:27-31 uses
|
||||||
|
// for its x=48 (32 + 16).
|
||||||
|
// - DV-19 the role badge (.role-badge, Tauri radius 10) drops to Radius.sm.
|
||||||
|
// Size, padding, type and colour are Tauri's to the pixel; only the
|
||||||
|
// corner changes. ADR-008 is Active and scoped to "any future UI
|
||||||
|
// surface", and a 10px label in a 2/8 box at radius 10 is that ADR's
|
||||||
|
// literal definition of a pill (owner ruling R5).
|
||||||
|
// - §5.4 `releaseDate` renders IN FULL — "1994-09-27", not "1994". Tauri's
|
||||||
|
// field is named `year` and its CSS class is `.year`, but the backend
|
||||||
|
// assigns `release_date_original` verbatim and the page prints it
|
||||||
|
// raw. Both backends do this deliberately. Truncating it would be
|
||||||
|
// inference from a field NAME, which this contract forbids.
|
||||||
|
// - §5.2 the page is FULLY KEYBOARD-OPERABLE. The Tauri spec's "no keyboard
|
||||||
|
// handling, no focus management" is true of its BINDINGS and false of
|
||||||
|
// its BEHAVIOUR: every control there is a native <button> and gets tab
|
||||||
|
// order, Enter/Space and a focus ring from the browser for free. QML
|
||||||
|
// grants none of it, so bare MouseAreas here would be a regression
|
||||||
|
// against the reference, not parity. Every control below is an
|
||||||
|
// activeFocusOnTab Item with an Enter/Space handler and a focus ring.
|
||||||
|
// Non-activation keys are left unaccepted so they keep bubbling up to
|
||||||
|
// the AppShell dispatcher (AppShell.qml:132-139) and the global
|
||||||
|
// hotkeys still fire under this page.
|
||||||
|
//
|
||||||
|
// INHERITED TAURI BEHAVIOUR, kept on purpose (02-tauri-musician.md §1.4, §3.2):
|
||||||
|
// - `roleOnAlbum` is the SAME string on every card — the backend echoes the
|
||||||
|
// role the user clicked with (`role.clone()` on every row). It is honest:
|
||||||
|
// per-album credit data does not exist.
|
||||||
|
// - `total` is the raw Qobuz album-SEARCH total, which routinely exceeds what
|
||||||
|
// pagination returns, so Load more can stay armed after the last real page.
|
||||||
|
// That is the reference's behaviour, not a defect introduced here.
|
||||||
|
//
|
||||||
|
// LOAD MORE is drawn inline rather than through controls/QbzLoadMore.qml: that
|
||||||
|
// control ships exactly two shapes (plain centred text, and Search's bordered
|
||||||
|
// pill — its own header says "the arms are `bordered` and nothing else"), and
|
||||||
|
// Tauri's is a third, a filled surface-elevated 10/24 button. Under a visual
|
||||||
|
// 1:1 mandate the button wins over the reuse.
|
||||||
|
//
|
||||||
|
// ANIMATION: the only motion on this page is the card's 150ms hover lift, a
|
||||||
|
// gesture-bounded Behavior, which is the explicitly allowed class (the GPU
|
||||||
|
// pulse law, GPU-COST-INVESTIGATION.md §9, and cards/ArtistCard.qml:170,231).
|
||||||
|
// There is NO spinner and NO skeleton here — Tauri's loading state is a single
|
||||||
|
// line of muted text, which happens to also be the cheapest possible answer.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import com.blitzfc.qbz
|
||||||
|
import "../controls"
|
||||||
|
import "../theme"
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||||
|
readonly property bool ambientOn: theme.ambientOn
|
||||||
|
radius: 12
|
||||||
|
|
||||||
|
QbzTheme { id: theme }
|
||||||
|
|
||||||
|
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||||
|
|
||||||
|
// ONE document, re-parsed on every publish. `musicianRev` is a pure binding
|
||||||
|
// dependency, exactly like the `rev` argument of QbzSession.tr(msgid, rev):
|
||||||
|
// the parse ignores it, reading it is what makes this binding re-run when
|
||||||
|
// Rust republishes a document that happens to be byte-identical.
|
||||||
|
readonly property var doc: root._parse(QbzMusician.musicianJson,
|
||||||
|
QbzMusician.musicianRev)
|
||||||
|
function _parse(s, rev) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s)
|
||||||
|
} catch (e) {
|
||||||
|
return ({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var appearances: doc.appearances || []
|
||||||
|
readonly property var bands: doc.bands || []
|
||||||
|
readonly property int total: doc.total || 0
|
||||||
|
|
||||||
|
// CSS `text-transform: capitalize` — title-case EVERY word, and leave the
|
||||||
|
// rest of each word alone (it is not "uppercase the first letter"). Tauri
|
||||||
|
// renders "lead guitar" as "Lead Guitar" and "member of" as "Member Of",
|
||||||
|
// and the role string is user-visible in two places here plus every badge.
|
||||||
|
// QML has no text-transform, so it happens on the string.
|
||||||
|
function _capitalize(s) {
|
||||||
|
if (!s)
|
||||||
|
return ""
|
||||||
|
return String(s).replace(/(^|\s)(\S)/g, function (m, sp, ch) {
|
||||||
|
return sp + ch.toUpperCase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body-state precedence, in Tauri's own branch order (:143-204): loading,
|
||||||
|
// then error, then empty, then the grid. `state` is the document's, so the
|
||||||
|
// three are mutually exclusive by construction.
|
||||||
|
readonly property bool isLoading: doc.state === "loading"
|
||||||
|
readonly property bool isError: doc.state === "error"
|
||||||
|
readonly property bool isEmpty: !isLoading && !isError && appearances.length === 0
|
||||||
|
|
||||||
|
// The keyboard focus ring. Drawn by the control, not by the theme: nothing
|
||||||
|
// in this tree had one before (grep: theme.focusRing had ONE consumer,
|
||||||
|
// LogViewerModal.qml:244's search field), and a control that can take Tab
|
||||||
|
// focus without showing it is worse than one that cannot take it at all.
|
||||||
|
component FocusRing: Rectangle {
|
||||||
|
property Item host: null
|
||||||
|
property real inset: 3
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -inset
|
||||||
|
color: "transparent"
|
||||||
|
border.width: 2
|
||||||
|
border.color: theme.focusRing
|
||||||
|
visible: host !== null && host.activeFocus
|
||||||
|
z: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ offline gate ============================
|
||||||
|
// Every page in this port carries it (ArtistReleasesView.qml:100-111 is the
|
||||||
|
// model) and this one is 100% network-fed: offline it could only ever paint
|
||||||
|
// its error state.
|
||||||
|
QbzOfflinePlaceholder {
|
||||||
|
visible: QbzSession.offline
|
||||||
|
anchors.centerIn: parent
|
||||||
|
showSettingsAction: true
|
||||||
|
onSettingsClicked: QbzShell.navigateTo("settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 0
|
||||||
|
visible: !QbzSession.offline
|
||||||
|
|
||||||
|
// --- Sticky header (.page-header, :216-226) ------------------------
|
||||||
|
// 24 top + 56 row + 16 bottom + the 1px rule = 97. Tauri makes it
|
||||||
|
// `position: sticky`; here it is simply a fixed sibling above the
|
||||||
|
// scroller, which is the same thing from scroll 0 onwards and is the
|
||||||
|
// shape every fixed-header view in this port uses.
|
||||||
|
//
|
||||||
|
// NOT ported: `data-tauri-drag-region="deep"`. Window dragging in this
|
||||||
|
// port belongs to the shell titlebar (HeaderBar), and no view header
|
||||||
|
// installs a DragHandler of its own — adding one here would be a new
|
||||||
|
// behaviour, not parity.
|
||||||
|
Item {
|
||||||
|
id: header
|
||||||
|
width: parent.width
|
||||||
|
height: 97
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
// A full-bleed pane child at y=0 rounds ITSELF: Qt's `clip` is
|
||||||
|
// a rectangular scissor that ignores `radius`, so the content
|
||||||
|
// pane's own rounding cannot reach this bar (AppShell.qml says
|
||||||
|
// "there is no mask that can do it for it here").
|
||||||
|
topLeftRadius: theme.radiusMd
|
||||||
|
topRightRadius: theme.radiusMd
|
||||||
|
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
|
||||||
|
}
|
||||||
|
|
||||||
|
// .musician-header: flex, align-items center, gap 16.
|
||||||
|
Row {
|
||||||
|
x: 40
|
||||||
|
y: 24
|
||||||
|
height: 56
|
||||||
|
width: Math.max(0, header.width - 40 - 24)
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// .musician-avatar — 56px circle, surface-elevated, muted User
|
||||||
|
// glyph at 32. No photo branch exists: there is no image field.
|
||||||
|
Rectangle {
|
||||||
|
width: 56
|
||||||
|
height: 56
|
||||||
|
radius: 28
|
||||||
|
color: theme.surfaceElevated
|
||||||
|
QbzIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
name: "user"
|
||||||
|
width: 32
|
||||||
|
height: 32
|
||||||
|
tintName: "muted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .musician-info — column, gap 4.
|
||||||
|
Column {
|
||||||
|
width: parent.width - 56 - 16
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.doc.name || ""
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: 24
|
||||||
|
font.weight: theme.weightBold
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
// SERVER TEXT, capitalized by CSS in the reference.
|
||||||
|
text: root._capitalize(root.doc.role || "")
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 14
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: theme.borderSubtle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scrolling content (.page-content, padding 24) -----------------
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: parent.height - header.height
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: flick
|
||||||
|
anchors.fill: parent
|
||||||
|
clip: true
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: page.implicitHeight
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: page
|
||||||
|
width: parent.width
|
||||||
|
leftPadding: 24
|
||||||
|
rightPadding: 24
|
||||||
|
topPadding: 24
|
||||||
|
// Tauri's own padding is a flat 24; the extra 76 is this
|
||||||
|
// port's shared bottom inset so the last row clears the
|
||||||
|
// NowPlayingBar (every view uses 100).
|
||||||
|
bottomPadding: 100
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
readonly property real contentWidth: width - 48
|
||||||
|
|
||||||
|
// ======== Section 1 — Bands & Projects (:112-132) ======
|
||||||
|
// DEAD in Tauri (`bands` is hardcoded empty on both return
|
||||||
|
// paths of the resolver, in BOTH backends) and made real
|
||||||
|
// here: DV-2 feeds it from the MusicBrainz relationship
|
||||||
|
// `groups`. The section is ABSENT, not empty, when there is
|
||||||
|
// nothing to show — no spinner, no empty frame, no error
|
||||||
|
// frame (00-CONTRACT §5.3's state table).
|
||||||
|
Column {
|
||||||
|
visible: root.bands.length > 0
|
||||||
|
width: page.contentWidth
|
||||||
|
spacing: 16
|
||||||
|
bottomPadding: 32
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.t("Bands & Projects")
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: theme.fontSection
|
||||||
|
font.weight: theme.weightSemibold
|
||||||
|
}
|
||||||
|
|
||||||
|
// .bands-grid — flex-wrap, gap 10.
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.bands
|
||||||
|
delegate: Item {
|
||||||
|
id: bandCell
|
||||||
|
required property var modelData
|
||||||
|
// Inert-but-styled when the group did not
|
||||||
|
// resolve to an MBID (00-CONTRACT §5.3):
|
||||||
|
// Tauri's chip only console.logs a TODO, so
|
||||||
|
// a dead chip is still 1:1 — a LIVE one is
|
||||||
|
// the improvement, and it needs an id.
|
||||||
|
readonly property bool live:
|
||||||
|
(modelData.mbid || "") !== ""
|
||||||
|
|
||||||
|
width: bandChip.width
|
||||||
|
height: bandChip.height
|
||||||
|
activeFocusOnTab: live
|
||||||
|
Keys.onPressed: function (event) {
|
||||||
|
if (bandCell.live
|
||||||
|
&& (event.key === Qt.Key_Return
|
||||||
|
|| event.key === Qt.Key_Enter
|
||||||
|
|| event.key === Qt.Key_Space)) {
|
||||||
|
bandCell.open()
|
||||||
|
event.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function open() {
|
||||||
|
if (bandCell.live)
|
||||||
|
QbzMusician.openBand(bandCell.modelData.mbid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// .band-card — 10/16 padding, gap 10,
|
||||||
|
// surface-elevated, 14px. Tauri's radius is
|
||||||
|
// 20 (a pill); DV-19 drops it to Radius.sm.
|
||||||
|
Rectangle {
|
||||||
|
id: bandChip
|
||||||
|
width: bandRow.width + 32
|
||||||
|
height: bandRow.height + 20
|
||||||
|
radius: theme.radiusSm
|
||||||
|
color: bandArea.containsMouse
|
||||||
|
? theme.bgHover : theme.surfaceElevated
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: bandRow
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
QbzIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: "music"
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
tintName: "muted"
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: bandCell.modelData.name || ""
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: 14
|
||||||
|
font.weight: theme.weightMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: bandArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: bandCell.live
|
||||||
|
? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
onClicked: bandCell.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusRing {
|
||||||
|
host: bandCell
|
||||||
|
radius: theme.radiusSm + 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== Section 2 — Appears On (:135-205) ============
|
||||||
|
// spacing 0, and the title carries its own 16px bottom
|
||||||
|
// margin: the four bodies and the Load-more block are
|
||||||
|
// SIBLINGS of the grid in the reference, with no gap of
|
||||||
|
// their own beyond their padding (`.load-more` is
|
||||||
|
// `padding: 24px 0`, not 24 on top of a flex gap).
|
||||||
|
Column {
|
||||||
|
width: page.contentWidth
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// .section-title + the 14/400 muted count, margin
|
||||||
|
// 0 0 16.
|
||||||
|
Row {
|
||||||
|
spacing: 8
|
||||||
|
bottomPadding: 16
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: appearsTitle
|
||||||
|
text: root.t("Appears On")
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: theme.fontSection
|
||||||
|
font.weight: theme.weightSemibold
|
||||||
|
}
|
||||||
|
// `.section-title` is `align-items: center`, so the
|
||||||
|
// count rides the title's vertical centre, not its
|
||||||
|
// baseline.
|
||||||
|
Text {
|
||||||
|
visible: root.total > 0
|
||||||
|
anchors.verticalCenter: appearsTitle.verticalCenter
|
||||||
|
text: "(" + root.total + ")"
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State 1: loading (:143-146) ------------------
|
||||||
|
// Text only — the reference mounts no spinner here.
|
||||||
|
// The msgid is the catalogue's "Loading…" (U+2026), not
|
||||||
|
// Tauri's ASCII "Loading...": 00-CONTRACT §7 rules that
|
||||||
|
// an existing msgid is reused rather than adding a
|
||||||
|
// near-twin to eight catalogues over one glyph.
|
||||||
|
Item {
|
||||||
|
visible: root.isLoading
|
||||||
|
width: parent.width
|
||||||
|
height: 48 * 2 + loadingLbl.height
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: loadingLbl
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.t("Loading…")
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State 2: error (:147-150) --------------------
|
||||||
|
// Tauri renders its `error` state variable, which it
|
||||||
|
// only ever sets to this one hardcoded English string.
|
||||||
|
// Here the document may carry a real message; the
|
||||||
|
// translated literal is the fallback so the box is
|
||||||
|
// never blank.
|
||||||
|
Item {
|
||||||
|
visible: root.isError
|
||||||
|
width: parent.width
|
||||||
|
height: 48 * 2 + errorLbl.height
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: errorLbl
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width
|
||||||
|
text: (root.doc.error || "") !== ""
|
||||||
|
? root.doc.error
|
||||||
|
: root.t("Failed to load appearances")
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 14
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State 3: empty (:151-155) --------------------
|
||||||
|
// Disc glyph 32 over the line, gap 12, padding 48.
|
||||||
|
// Msgid reused from the catalogue WITH its trailing
|
||||||
|
// period (Tauri's has none) — §7 again.
|
||||||
|
Column {
|
||||||
|
visible: root.isEmpty
|
||||||
|
width: parent.width
|
||||||
|
topPadding: 48
|
||||||
|
bottomPadding: 48
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
QbzIcon {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
name: "disc"
|
||||||
|
width: 32
|
||||||
|
height: 32
|
||||||
|
tintName: "muted"
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: root.t("No album appearances found.")
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The grid (:156-204) --------------------------
|
||||||
|
// `repeat(auto-fill, minmax(160px, 1fr))` with gap 20:
|
||||||
|
// as many 160px-minimum tracks as fit, then every track
|
||||||
|
// STRETCHES to share the row. So the column count and
|
||||||
|
// the cell width are computed, not fixed — this is not
|
||||||
|
// one of the port's fixed-pitch card grids.
|
||||||
|
Grid {
|
||||||
|
id: grid
|
||||||
|
visible: !root.isLoading && !root.isError
|
||||||
|
&& root.appearances.length > 0
|
||||||
|
width: parent.width
|
||||||
|
spacing: 20
|
||||||
|
columns: Math.max(1, Math.floor((width + spacing)
|
||||||
|
/ (160 + spacing)))
|
||||||
|
readonly property real cellW:
|
||||||
|
(width - (columns - 1) * spacing) / columns
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.appearances
|
||||||
|
delegate: Item {
|
||||||
|
id: cell
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: grid.cellW
|
||||||
|
height: cardCol.implicitHeight
|
||||||
|
activeFocusOnTab: true
|
||||||
|
Keys.onPressed: function (event) {
|
||||||
|
if (event.key === Qt.Key_Return
|
||||||
|
|| event.key === Qt.Key_Enter
|
||||||
|
|| event.key === Qt.Key_Space) {
|
||||||
|
cell.open()
|
||||||
|
event.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function open() {
|
||||||
|
var id = cell.modelData.albumId || ""
|
||||||
|
if (id !== "")
|
||||||
|
QbzMusician.openAlbum(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// .album-card — column, gap 12, hover
|
||||||
|
// translateY(-4px) over 150ms. A Behavior
|
||||||
|
// bounded by a pointer gesture is the
|
||||||
|
// allowed class under the pulse law; the
|
||||||
|
// lift is on the CONTENT, not on the cell,
|
||||||
|
// because the Grid owns the cell's y.
|
||||||
|
Column {
|
||||||
|
id: cardCol
|
||||||
|
width: parent.width
|
||||||
|
y: cardArea.containsMouse ? -4 : 0
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Behavior on y {
|
||||||
|
NumberAnimation { duration: 150 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// .album-artwork — 1:1, radius 8, on a
|
||||||
|
// surface-elevated bed. NO shadow.
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: width
|
||||||
|
radius: theme.radiusSm
|
||||||
|
color: theme.surfaceElevated
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
// `artPath`, NOT `artworkUrl`.
|
||||||
|
// The remote url is a Rust-side
|
||||||
|
// key; the cover that exists on
|
||||||
|
// disk is what an Image can draw,
|
||||||
|
// and musician_qt.rs:100-109 says
|
||||||
|
// in as many words that a delegate
|
||||||
|
// binding `artworkUrl` into a
|
||||||
|
// source "draws nothing". The
|
||||||
|
// field is ADDITIVE to the frozen
|
||||||
|
// §13.2 shape and its two-pass
|
||||||
|
// fill needs no QML plumbing.
|
||||||
|
RoundedImage {
|
||||||
|
id: cover
|
||||||
|
anchors.fill: parent
|
||||||
|
source: cell.modelData.artPath || ""
|
||||||
|
radius: theme.radiusSm
|
||||||
|
}
|
||||||
|
// The placeholder branch is LIVE,
|
||||||
|
// not defensive: album_artwork is
|
||||||
|
// `.unwrap_or_default()`, so an
|
||||||
|
// empty cover is an expected value.
|
||||||
|
// It doubles as the pre-download
|
||||||
|
// state, which is what Tauri's
|
||||||
|
// `<img loading="lazy">` shows
|
||||||
|
// there anyway. `ready` is the
|
||||||
|
// shared handover signal — true
|
||||||
|
// only once THIS source is on
|
||||||
|
// screen (RoundedImage.qml:247-259).
|
||||||
|
QbzIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: !cover.ready
|
||||||
|
name: "disc"
|
||||||
|
width: 24
|
||||||
|
height: 24
|
||||||
|
tintName: "muted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .album-info — column, gap 4.
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: cell.modelData.title || ""
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: 14
|
||||||
|
font.weight: theme.weightMedium
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: cell.modelData.artistName || ""
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 12
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
// .album-meta — row, gap 8,
|
||||||
|
// margin-top 4.
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
topPadding: 4
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
// The FULL ISO date, verbatim
|
||||||
|
// (§5.4). Not a year.
|
||||||
|
Text {
|
||||||
|
visible: (cell.modelData.releaseDate || "") !== ""
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: cell.modelData.releaseDate || ""
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
// .role-badge — ALWAYS drawn,
|
||||||
|
// 10px on 2/8 padding. Tauri's
|
||||||
|
// radius 10 -> Radius.sm
|
||||||
|
// (DV-19). Same string on every
|
||||||
|
// card by design.
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: badgeLbl.implicitWidth + 16
|
||||||
|
height: badgeLbl.implicitHeight + 4
|
||||||
|
radius: theme.radiusSm
|
||||||
|
color: theme.surfaceElevated
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: badgeLbl
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root._capitalize(
|
||||||
|
cell.modelData.roleOnAlbum || "")
|
||||||
|
color: theme.textSecondary
|
||||||
|
font.pixelSize: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: cardArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: cell.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusRing {
|
||||||
|
host: cell
|
||||||
|
radius: theme.radiusSm + 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Load more (:193-203) ------------------------
|
||||||
|
// Only when hasMore. Disabled while a page is in
|
||||||
|
// flight, label swaps to the busy msgid.
|
||||||
|
Item {
|
||||||
|
visible: root.doc.hasMore === true
|
||||||
|
&& !root.isLoading && !root.isError
|
||||||
|
width: parent.width
|
||||||
|
height: 24 * 2 + 40
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: moreCell
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: moreBtn.width
|
||||||
|
height: moreBtn.height
|
||||||
|
activeFocusOnTab: root.doc.loadingMore !== true
|
||||||
|
Keys.onPressed: function (event) {
|
||||||
|
if (root.doc.loadingMore !== true
|
||||||
|
&& (event.key === Qt.Key_Return
|
||||||
|
|| event.key === Qt.Key_Enter
|
||||||
|
|| event.key === Qt.Key_Space)) {
|
||||||
|
QbzMusician.loadMore()
|
||||||
|
event.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: moreBtn
|
||||||
|
width: moreLbl.implicitWidth + 48
|
||||||
|
height: moreLbl.implicitHeight + 20
|
||||||
|
radius: theme.radiusSm
|
||||||
|
opacity: root.doc.loadingMore === true ? 0.6 : 1.0
|
||||||
|
color: moreArea.containsMouse
|
||||||
|
&& root.doc.loadingMore !== true
|
||||||
|
? theme.bgHover : theme.surfaceElevated
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: moreLbl
|
||||||
|
anchors.centerIn: parent
|
||||||
|
// Both msgids already exist in all
|
||||||
|
// eight catalogues; Tauri's "Load More"
|
||||||
|
// capital M is not worth a ninth entry
|
||||||
|
// (§7).
|
||||||
|
text: root.doc.loadingMore === true
|
||||||
|
? root.t("Loading…") : root.t("Load more")
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: 14
|
||||||
|
font.weight: theme.weightMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: moreArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: root.doc.loadingMore === true
|
||||||
|
? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
if (root.doc.loadingMore !== true)
|
||||||
|
QbzMusician.loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusRing {
|
||||||
|
host: moreCell
|
||||||
|
radius: theme.radiusSm + 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QbzScrollBar {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 4
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
target: flick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// ArtistScene › the A-Z jump rail — 1:1 with Tauri's `.alpha-index`
|
||||||
|
// (ArtistsByLocationView.svelte markup :699-711, CSS :1372-1413), per
|
||||||
|
// 01-tauri-artist-scene.md §11.
|
||||||
|
//
|
||||||
|
// NOT controls/QbzAlphaStrip.qml. That one is the Library/Local rail: an 18px
|
||||||
|
// gutter of 15px rows in 9px muted type, driven by a { letter, index } list of
|
||||||
|
// the jumps that EXIST. This one is Tauri's: a fixed 27-slot pill — `#` first,
|
||||||
|
// then A-Z, every slot always drawn — 20×20 letters at 11/600 on a translucent
|
||||||
|
// black card, where a letter with no members stays visible at opacity 0.2.
|
||||||
|
// Different alphabet contract, different metrics, different host; the
|
||||||
|
// reference has two components as well.
|
||||||
|
//
|
||||||
|
// STATIC, NOT STICKY. §11 and §18 of the spec both call this rail "sticky"
|
||||||
|
// (`position:sticky; top:0`), and 08-critique-findings settles that the sticky
|
||||||
|
// never engages: `.alpha-index` is a flex child of a row that does not scroll,
|
||||||
|
// and the real scroller is a descendant of its SIBLING. What pins it to the
|
||||||
|
// top is `align-self: flex-start`. So: the host anchors this item's TOP and
|
||||||
|
// lets it keep its implicit height. Do not wire it to a Flickable's contentY.
|
||||||
|
//
|
||||||
|
// DELIBERATE DIVERGENCE (recorded, small): Tauri styles a memberless letter
|
||||||
|
// `.disabled` — opacity 0.2, `cursor: default` — but leaves the <button> live
|
||||||
|
// and early-returns inside `scrollToGroup` (V:310). Here the letter is
|
||||||
|
// genuinely inert: no hover paint, no pointer cursor, no signal. Same
|
||||||
|
// user-visible result with less surprise, and one fewer no-op code path.
|
||||||
|
//
|
||||||
|
// MOUNTING — the root IS the 28px pill, and `.alpha-index`'s 4px `margin-left`
|
||||||
|
// is the HOST's (ArtistSceneView.qml:1123 already writes it as
|
||||||
|
// `x: gridPane.width + 4`). Do not bake it in here as well.
|
||||||
|
//
|
||||||
|
// AlphaIndexRail {
|
||||||
|
// x: gridPane.width + 4 // the CSS margin-left
|
||||||
|
// y: 0 // align-self: flex-start
|
||||||
|
// activeKeys: root.alphaActiveKeys // ["#","A","D",…] or {"A": true}
|
||||||
|
// onJumped: function (letter) { … } // host scrolls its own view
|
||||||
|
// }
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import com.blitzfc.qbz
|
||||||
|
import "../../theme"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// --- the frozen mount API (00-CONTRACT.md, scene lane) ---------------
|
||||||
|
/// Which of the 27 slots have members. Accepts an array (["#","A","D"])
|
||||||
|
/// or a set-shaped object ({"A": true}) — the host may hold either.
|
||||||
|
property var activeKeys: []
|
||||||
|
signal jumped(string letter)
|
||||||
|
|
||||||
|
QbzTheme { id: theme }
|
||||||
|
|
||||||
|
// `ALPHA_LETTERS = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')` (V:271) —
|
||||||
|
// 27 entries, `#` FIRST. Built once, never re-derived: this is a constant.
|
||||||
|
readonly property var letters: [
|
||||||
|
"#", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
|
||||||
|
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"
|
||||||
|
]
|
||||||
|
|
||||||
|
function hasMembers(letter) {
|
||||||
|
var k = root.activeKeys
|
||||||
|
if (!k)
|
||||||
|
return false
|
||||||
|
if (Array.isArray(k))
|
||||||
|
return k.indexOf(letter) >= 0
|
||||||
|
return k[letter] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20px letters inside 4px of side padding — `padding: 6px 4px`.
|
||||||
|
implicitWidth: 28
|
||||||
|
implicitHeight: pill.height
|
||||||
|
width: implicitWidth
|
||||||
|
height: implicitHeight
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: pill
|
||||||
|
width: 28
|
||||||
|
height: col.height + 2 * 6
|
||||||
|
radius: 10
|
||||||
|
// `background: rgba(0,0,0,0.2)`. Qt.rgba with 0..1 floats, NEVER an
|
||||||
|
// 8-digit hex — Qt reads #AARRGGBB where CSS writes #RRGGBBAA, so
|
||||||
|
// "#00000033" would be a fully transparent black here.
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.2)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: col
|
||||||
|
x: 4
|
||||||
|
y: 6
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.letters
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: slot
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool live: root.hasMembers(slot.modelData)
|
||||||
|
|
||||||
|
width: 20
|
||||||
|
height: 20
|
||||||
|
radius: 4
|
||||||
|
color: (slot.live && slotArea.containsMouse) ? theme.surfaceElevated
|
||||||
|
: "transparent"
|
||||||
|
// `.disabled { opacity: 0.2 }` / `opacity: 0.9` -> 1 on
|
||||||
|
// hover. `transition: all 100ms ease` — hover-bounded, the
|
||||||
|
// one animation class the pulse law leaves alone.
|
||||||
|
opacity: !slot.live ? 0.2 : (slotArea.containsMouse ? 1.0 : 0.9)
|
||||||
|
Behavior on opacity { NumberAnimation { duration: 100 } }
|
||||||
|
Behavior on color { ColorAnimation { duration: 100 } }
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
text: slot.modelData
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.weight: theme.weightSemibold
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: slotArea
|
||||||
|
anchors.fill: parent
|
||||||
|
// Inert when the letter has no members: an enabled
|
||||||
|
// MouseArea would still eat the press and paint a
|
||||||
|
// cursor for a jump that cannot happen.
|
||||||
|
enabled: slot.live
|
||||||
|
hoverEnabled: slot.live
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.jumped(String(slot.modelData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
// ArtistScene › the genre-filter popup — 1:1 with Tauri's `.genre-popup`
|
||||||
|
// (ArtistsByLocationView.svelte markup :495-549, CSS :1128-1347), per
|
||||||
|
// 00-CONTRACT.md §0 (Tauri wins on every number) and 01-tauri-artist-scene.md
|
||||||
|
// §6, which is normative here.
|
||||||
|
//
|
||||||
|
// NOT the same component as controls/GenreFilterPopup.qml. That one is the
|
||||||
|
// Discover/Library overlay: 470px wide, driven end-to-end by
|
||||||
|
// QbzBridge.genreFilterJson, with a remember/advanced toggle pair and a
|
||||||
|
// 3-level genre TREE. This one is a dumb, self-contained box over the genre
|
||||||
|
// strings the scene's own candidates carry — no bridge, no tree, no
|
||||||
|
// persistence, and a fixed 3-column grid of 36px cards. Same job title, two
|
||||||
|
// different surfaces; the reference has two files as well.
|
||||||
|
//
|
||||||
|
// ⚠ TWO COMPONENTS NOW SHARE THE NAME `GenreFilterPopup`, and ArtistSceneView
|
||||||
|
// imports BOTH directories ("../controls" and "scene"). QML resolves a type by
|
||||||
|
// walking the imports in reverse, so "scene" — imported last — wins today and
|
||||||
|
// the right box is drawn. It is still one reordered import away from silently
|
||||||
|
// mounting the 470px Discover tree instead. The fix belongs to the integration
|
||||||
|
// lane and is three tokens: `import "scene" as Scene`, then `Scene.` on the
|
||||||
|
// three mounts. Flagged in this lane's report.
|
||||||
|
//
|
||||||
|
// MOUNTING — the root IS the 530px card, positioned by the host, which is what
|
||||||
|
// ArtistSceneView.qml:1236-1242 already does:
|
||||||
|
//
|
||||||
|
// GenreFilterPopup {
|
||||||
|
// z: 200
|
||||||
|
// open: root.genrePopupOpen
|
||||||
|
// x: … genreBtn.x + genreBtn.width - width // `right: 0`
|
||||||
|
// y: … genreBtn.y + genreBtn.height + 6 // `calc(100% + 6px)`
|
||||||
|
// genres: root.availableGenres // ["post-punk", …]
|
||||||
|
// selected: root.activeGenres // array OR {g: true}
|
||||||
|
// onToggled: function (g) { … }
|
||||||
|
// onCleared: function () { … }
|
||||||
|
// onCloseRequested: root.genrePopupOpen = false
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Tauri anchors the card to the 36×36 funnel button (`.genre-filter-container
|
||||||
|
// {position:relative}` :1129-1131, which 01-…md §6 omits and 08-critique
|
||||||
|
// records), i.e. `top: calc(100% + 6px); right: 0` of THAT button — NOT of the
|
||||||
|
// nav bar and NOT of the view.
|
||||||
|
//
|
||||||
|
// OUTSIDE CLICK IS THE HOST'S. Tauri closes on a document-level handler
|
||||||
|
// (V:240-246); the QML equivalent is a full-surface scrim, which this card
|
||||||
|
// cannot be and still be positioned as a 530px box. ArtistSceneView already
|
||||||
|
// owns that scrim (`outsideScrim`, :1168-1177, one scrim for this popup and the
|
||||||
|
// group menu — its DV-4 fix). What this component contributes is the swallow
|
||||||
|
// MouseArea below, without which a press INSIDE the card would fall through to
|
||||||
|
// that scrim and close the popup on every genre click.
|
||||||
|
//
|
||||||
|
// SELECTION IS THE HOST'S. This box only reports `toggled(genre)` and
|
||||||
|
// `cleared()`; the AND semantics of the filter chain (V:250-262 — an artist is
|
||||||
|
// kept only when it carries EVERY selected genre, case-sensitive) live in the
|
||||||
|
// consumer.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import com.blitzfc.qbz
|
||||||
|
import "../../theme"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// --- the frozen mount API (00-CONTRACT.md, scene lane) ---------------
|
||||||
|
/// Every genre string the scene's candidates carry, already deduped by
|
||||||
|
/// the host. Order is the host's; this box does not sort.
|
||||||
|
property var genres: []
|
||||||
|
/// The active selection. Accepts an array (["post-punk"]) or a
|
||||||
|
/// set-shaped object ({"post-punk": true}) — Tauri's is a JS Set and the
|
||||||
|
/// host may hold either.
|
||||||
|
property var selected: []
|
||||||
|
/// Visibility. Named `open` to match the mount contract.
|
||||||
|
property bool open: false
|
||||||
|
|
||||||
|
signal toggled(string genre)
|
||||||
|
signal cleared()
|
||||||
|
signal closeRequested()
|
||||||
|
|
||||||
|
QbzTheme { id: theme }
|
||||||
|
|
||||||
|
// `width: 530px; max-height: 440px` (:1136-1137). The card is a flex
|
||||||
|
// column whose only flexible band is the grid, so it is as tall as its
|
||||||
|
// content up to 440.
|
||||||
|
readonly property int headerH: 52
|
||||||
|
readonly property int searchH: 41
|
||||||
|
readonly property int footerH: 57
|
||||||
|
width: 530
|
||||||
|
height: Math.min(440, root.headerH + (root.searchShown ? root.searchH : 0)
|
||||||
|
+ gridBand.contentH + root.footerH)
|
||||||
|
|
||||||
|
// Tauri's z-index:200 (`.genre-popup` :1145). This is a view-local popup,
|
||||||
|
// not a modal, so ADR-009's ≥3000 floor does not apply — that floor is for
|
||||||
|
// surfaces that must clear the shell chrome, and this one must not. The
|
||||||
|
// host sets it too; this is the default for any other mount.
|
||||||
|
z: 200
|
||||||
|
visible: root.open
|
||||||
|
enabled: root.open
|
||||||
|
|
||||||
|
// The search field is rendered only above 9 genres (V:508) — below that
|
||||||
|
// the 3-column grid shows everything without scrolling and the row would
|
||||||
|
// be dead chrome.
|
||||||
|
readonly property bool searchShown: (root.genres || []).length > 9
|
||||||
|
|
||||||
|
/// Selection test that survives both shapes of `selected`.
|
||||||
|
function isSelected(g) {
|
||||||
|
var s = root.selected
|
||||||
|
if (!s)
|
||||||
|
return false
|
||||||
|
if (Array.isArray(s))
|
||||||
|
return s.indexOf(g) >= 0
|
||||||
|
return s[g] === true
|
||||||
|
}
|
||||||
|
readonly property int selectedCount: {
|
||||||
|
var s = root.selected
|
||||||
|
if (!s)
|
||||||
|
return 0
|
||||||
|
if (Array.isArray(s))
|
||||||
|
return s.length
|
||||||
|
var n = 0
|
||||||
|
for (var k in s)
|
||||||
|
if (s[k] === true)
|
||||||
|
n++
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// `.genre-name::first-letter { text-transform: capitalize }` (:1276-1278)
|
||||||
|
// — the FIRST LETTER ONLY, not CSS `capitalize`, which would title-case
|
||||||
|
// every word ("post punk" -> "Post punk", never "Post Punk"). QML has no
|
||||||
|
// text-transform at all, so it happens here.
|
||||||
|
function capFirst(s) {
|
||||||
|
var t = String(s || "")
|
||||||
|
return t.length === 0 ? t : t.charAt(0).toUpperCase() + t.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing resets the needle, as Tauri does on every close path
|
||||||
|
// (V:242-245, V:519, V:542).
|
||||||
|
onOpenChanged: if (!root.open) searchInput.text = ""
|
||||||
|
|
||||||
|
readonly property var shownGenres: {
|
||||||
|
var all = root.genres || []
|
||||||
|
var q = searchInput.text.toLowerCase()
|
||||||
|
if (!root.searchShown || q === "")
|
||||||
|
return all
|
||||||
|
var out = []
|
||||||
|
for (var i = 0; i < all.length; i++)
|
||||||
|
if (String(all[i]).toLowerCase().indexOf(q) >= 0)
|
||||||
|
out.push(all[i])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== the card =============================
|
||||||
|
// `box-shadow: 0 8px 32px rgba(0,0,0,0.4)` (:1144), approximated by four
|
||||||
|
// concentric frames that fade outward. Declared BEFORE the card so the
|
||||||
|
// card's own fill covers them — a negative-z CHILD would not work: Qt
|
||||||
|
// Quick draws an item's own geometry before any child, whatever its z, so
|
||||||
|
// the frames would have painted dark bars across the card's top edge.
|
||||||
|
//
|
||||||
|
// Qt.rgba with 0..1 floats, NEVER an 8-digit hex: Qt reads #AARRGGBB
|
||||||
|
// where CSS and Slint write #RRGGBBAA, so "#00000066" is a fully
|
||||||
|
// TRANSPARENT black here. That trap has landed twice in this port.
|
||||||
|
Repeater {
|
||||||
|
model: 4
|
||||||
|
delegate: Rectangle {
|
||||||
|
required property int index
|
||||||
|
x: -(index + 1) * 2
|
||||||
|
y: 8 - (index + 1) * 2
|
||||||
|
width: root.width + (index + 1) * 4
|
||||||
|
height: root.height + (index + 1) * 4
|
||||||
|
radius: card.radius + (index + 1) * 2
|
||||||
|
color: "transparent"
|
||||||
|
border.width: 2
|
||||||
|
border.color: Qt.rgba(0, 0, 0, 0.4 / (index + 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: card
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 10
|
||||||
|
color: theme.surfaceMain
|
||||||
|
border.width: 1
|
||||||
|
border.color: theme.borderSubtle
|
||||||
|
|
||||||
|
// Swallow presses on the card's own chrome (Tauri's
|
||||||
|
// `onclick={e => e.stopPropagation()}`, V:498). Declared FIRST so the
|
||||||
|
// content below keeps its own clicks.
|
||||||
|
MouseArea { anchors.fill: parent }
|
||||||
|
|
||||||
|
// ---------------------------- header -----------------------------
|
||||||
|
Item {
|
||||||
|
id: header
|
||||||
|
width: parent.width
|
||||||
|
height: root.headerH
|
||||||
|
|
||||||
|
Row {
|
||||||
|
x: 16
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
QbzIcon {
|
||||||
|
name: "sliders-horizontal"
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
tintName: "textPrimary"
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: QbzSession.tr("Filter by genre", QbzSession.trRev)
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: 14
|
||||||
|
font.weight: theme.weightSemibold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width - width - 16
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 28
|
||||||
|
height: 28
|
||||||
|
radius: 6
|
||||||
|
color: closeArea.containsMouse ? theme.bgHover : "transparent"
|
||||||
|
// Hover-only, gesture-bounded — the one animation class the
|
||||||
|
// pulse law leaves alone (cards/ArtistCard.qml:170).
|
||||||
|
Behavior on color { ColorAnimation { duration: 150 } }
|
||||||
|
QbzIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
name: "x"
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: closeArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.closeRequested()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
y: parent.height - 1
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: theme.borderSubtle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------- search row ---------------------------
|
||||||
|
Item {
|
||||||
|
id: searchRow
|
||||||
|
visible: root.searchShown
|
||||||
|
y: header.height
|
||||||
|
width: parent.width
|
||||||
|
height: root.searchH
|
||||||
|
|
||||||
|
Row {
|
||||||
|
x: 16
|
||||||
|
width: parent.width - 32
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
QbzIcon {
|
||||||
|
name: "search"
|
||||||
|
width: 14
|
||||||
|
height: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
tintName: "muted"
|
||||||
|
}
|
||||||
|
TextInput {
|
||||||
|
id: searchInput
|
||||||
|
width: parent.width - 22 - (clearBtn.visible ? clearBtn.width + 8 : 0)
|
||||||
|
height: 21
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: 13
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
clip: true
|
||||||
|
// Reusing the catalogue's existing needle msgid rather
|
||||||
|
// than Tauri's "Search..." — 00-CONTRACT.md §7: reuse the
|
||||||
|
// translated entry unless the owner asks otherwise.
|
||||||
|
Text {
|
||||||
|
visible: searchInput.text === ""
|
||||||
|
anchors.fill: parent
|
||||||
|
text: QbzSession.tr("Search…", QbzSession.trRev)
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 13
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
id: clearBtn
|
||||||
|
visible: searchInput.text !== ""
|
||||||
|
width: 20
|
||||||
|
height: 20
|
||||||
|
radius: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: clearArea.containsMouse ? theme.bgHover : theme.surfaceElevated
|
||||||
|
Behavior on color { ColorAnimation { duration: 150 } }
|
||||||
|
QbzIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
name: "x"
|
||||||
|
width: 12
|
||||||
|
height: 12
|
||||||
|
tintName: clearArea.containsMouse ? "textPrimary" : "muted"
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: clearArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: searchInput.text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
y: parent.height - 1
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: theme.borderSubtle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------ grid ------------------------------
|
||||||
|
// `repeat(3, 1fr)`, gap 6, padding 12 — a FIXED 3 columns with no
|
||||||
|
// breakpoint (:1228-1235), so the cell width is arithmetic, not a
|
||||||
|
// layout negotiation.
|
||||||
|
Item {
|
||||||
|
id: gridBand
|
||||||
|
|
||||||
|
readonly property int pad: 12
|
||||||
|
readonly property int gap: 6
|
||||||
|
readonly property int cellW: Math.floor((root.width - 2 * gridBand.pad
|
||||||
|
- 2 * gridBand.gap) / 3)
|
||||||
|
readonly property int contentH: 2 * gridBand.pad
|
||||||
|
+ (root.shownGenres.length > 0 ? grid.height : emptyLabel.height)
|
||||||
|
|
||||||
|
y: header.height + (root.searchShown ? searchRow.height : 0)
|
||||||
|
width: parent.width
|
||||||
|
height: root.height - y - root.footerH
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
anchors.fill: parent
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: gridBand.contentH
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
id: grid
|
||||||
|
visible: root.shownGenres.length > 0
|
||||||
|
x: gridBand.pad
|
||||||
|
y: gridBand.pad
|
||||||
|
columns: 3
|
||||||
|
columnSpacing: gridBand.gap
|
||||||
|
rowSpacing: gridBand.gap
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.shownGenres
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: genreCard
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool picked: root.isSelected(genreCard.modelData)
|
||||||
|
|
||||||
|
width: gridBand.cellW
|
||||||
|
height: 36
|
||||||
|
radius: 6
|
||||||
|
border.width: 1
|
||||||
|
color: genreCard.picked
|
||||||
|
? (cardArea.containsMouse ? theme.accentHover : theme.accent)
|
||||||
|
: (cardArea.containsMouse ? theme.bgHover : theme.surfaceElevated)
|
||||||
|
border.color: genreCard.picked
|
||||||
|
? (cardArea.containsMouse ? theme.accentHover : theme.accent)
|
||||||
|
: (cardArea.containsMouse ? theme.textMuted : theme.borderSubtle)
|
||||||
|
// `transition: color/background-color/border-color
|
||||||
|
// 150ms` (:1244-1247). Both arms of this colour are
|
||||||
|
// gesture-bounded — hover, and a click on THIS card
|
||||||
|
// (or the footer's Clear) is the only thing that can
|
||||||
|
// flip `picked`. It is not a data feed in the sense
|
||||||
|
// the pulse law forbids.
|
||||||
|
Behavior on color { ColorAnimation { duration: 150 } }
|
||||||
|
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 10
|
||||||
|
width: parent.width - 10 - 14 - 10 - 8
|
||||||
|
height: parent.height
|
||||||
|
text: root.capFirst(genreCard.modelData)
|
||||||
|
// On the accent fill the label is the measured
|
||||||
|
// on-accent colour, not Tauri's
|
||||||
|
// `--btn-primary-text`: the same call the
|
||||||
|
// sibling popup already makes
|
||||||
|
// (controls/GenreFilterPopup.qml:429-430) —
|
||||||
|
// accent-text on 34 of the 35 palettes, and a
|
||||||
|
// legible colour on the one where it is not.
|
||||||
|
color: genreCard.picked ? theme.accentGlyphColor : theme.textPrimary
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.weight: theme.weightMedium
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
// `.check-circle` (:1284-1310) — 14px ring that
|
||||||
|
// fills white and grows a CSS-drawn tick. The tick
|
||||||
|
// is a 4×7 box wearing only its right and bottom
|
||||||
|
// borders, rotated 45°: two Rectangles here,
|
||||||
|
// because QML has no border-width shorthand and an
|
||||||
|
// icon glyph would not be the same mark.
|
||||||
|
Item {
|
||||||
|
id: checkCircle
|
||||||
|
x: parent.width - 10 - width
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 14
|
||||||
|
height: 14
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 7
|
||||||
|
// INVERTED host: the disc is the card's
|
||||||
|
// foreground punched out of the accent
|
||||||
|
// fill and the tick inside it is `accent`,
|
||||||
|
// so the pair that has to clear the
|
||||||
|
// contrast floor is (accent, this fill) —
|
||||||
|
// exactly the swap
|
||||||
|
// controls/GenreFilterPopup.qml:436-455
|
||||||
|
// documents. Tauri paints it literal white.
|
||||||
|
color: genreCard.picked ? theme.accentGlyphColor : "transparent"
|
||||||
|
border.width: 1.5
|
||||||
|
border.color: genreCard.picked
|
||||||
|
? theme.accentGlyphColor : theme.textMuted
|
||||||
|
Behavior on color { ColorAnimation { duration: 150 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// `transform: translate(-50%,-60%) rotate(45deg)`
|
||||||
|
// off `top:50%; left:50%` — the translate is of
|
||||||
|
// the tick's OWN 4×7 box, the rotation is about
|
||||||
|
// its centre.
|
||||||
|
Item {
|
||||||
|
visible: genreCard.picked
|
||||||
|
x: parent.width * 0.5 - width * 0.5
|
||||||
|
y: parent.height * 0.5 - height * 0.6
|
||||||
|
width: 4
|
||||||
|
height: 7
|
||||||
|
rotation: 45
|
||||||
|
transformOrigin: Item.Center
|
||||||
|
// border-width: 0 1.5px 1.5px 0 — right
|
||||||
|
// edge…
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width - 1.5
|
||||||
|
width: 1.5
|
||||||
|
height: parent.height
|
||||||
|
color: theme.accent
|
||||||
|
}
|
||||||
|
// …and bottom edge.
|
||||||
|
Rectangle {
|
||||||
|
y: parent.height - 1.5
|
||||||
|
width: parent.width
|
||||||
|
height: 1.5
|
||||||
|
color: theme.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: cardArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.toggled(String(genreCard.modelData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `.genre-popup-empty` (:1341-1347) — spans the whole grid,
|
||||||
|
// centred, 12px muted, 16px of air above and below.
|
||||||
|
Text {
|
||||||
|
id: emptyLabel
|
||||||
|
visible: root.shownGenres.length === 0
|
||||||
|
x: gridBand.pad
|
||||||
|
y: gridBand.pad
|
||||||
|
width: root.width - 2 * gridBand.pad
|
||||||
|
height: 16 + 16 + 16
|
||||||
|
text: QbzSession.tr("No results found", QbzSession.trRev)
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 12
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------- footer -----------------------------
|
||||||
|
Item {
|
||||||
|
id: footer
|
||||||
|
y: root.height - root.footerH
|
||||||
|
width: parent.width
|
||||||
|
height: root.footerH
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: theme.borderSubtle
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
x: 16
|
||||||
|
y: 12
|
||||||
|
width: parent.width - 32
|
||||||
|
height: 32
|
||||||
|
radius: 6
|
||||||
|
// `:disabled { opacity: 0.4 }` (:1337-1339).
|
||||||
|
opacity: root.selectedCount > 0 ? 1.0 : 0.4
|
||||||
|
color: (clearFilterArea.containsMouse && root.selectedCount > 0)
|
||||||
|
? theme.bgHover : theme.surfaceElevated
|
||||||
|
Behavior on color { ColorAnimation { duration: 150 } }
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: QbzSession.tr("Clear filter", QbzSession.trRev)
|
||||||
|
color: (clearFilterArea.containsMouse && root.selectedCount > 0)
|
||||||
|
? theme.textPrimary : theme.textSecondary
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.weight: theme.weightMedium
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: clearFilterArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: root.selectedCount > 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
// Tauri clears AND closes (V:542).
|
||||||
|
onClicked: {
|
||||||
|
if (root.selectedCount === 0)
|
||||||
|
return
|
||||||
|
root.cleared()
|
||||||
|
root.closeRequested()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,961 @@
|
|||||||
|
// ArtistScene › sidepanel mode — the two-column artist→albums body, 1:1 with
|
||||||
|
// Tauri's `.artist-two-column-layout` (ArtistsByLocationView.svelte markup
|
||||||
|
// :715-817, CSS :1415-1509; the rail is VirtualizedFavoritesArtistList.svelte),
|
||||||
|
// per 01-tauri-artist-scene.md §9 and §13.
|
||||||
|
//
|
||||||
|
// EDGE TO EDGE. Tauri's layout carries `margin: 0 -8px 0 -18px`, which exactly
|
||||||
|
// cancels `.scene-view`'s 18px/8px padding, so this body starts at the pane's
|
||||||
|
// left edge and the 18px re-appears INSIDE the rail as its own padding-left.
|
||||||
|
// The host must therefore mount this component full-bleed — negating its own
|
||||||
|
// padding if it has any — or the rail lands 18px too far right and the right
|
||||||
|
// column loses its 8px tail.
|
||||||
|
//
|
||||||
|
// NOT views/library/LibraryArtistsPanel.qml, and deliberately so
|
||||||
|
// (00-CONTRACT.md §4.4). That panel is bound to QbzLibrary and a Library host,
|
||||||
|
// with a 300px rail, 56px rows, 40px portraits and 200×246 AlbumCards carrying
|
||||||
|
// follow / play / pin / context-menu affordances the scene must not have.
|
||||||
|
// Tauri's scene needs 240 / 64 / 48 and a 220px card with two actions. That is
|
||||||
|
// far more than a 10 % variation, which is the justification TRACK-RULE 5 asks
|
||||||
|
// for; the artwork-window mechanics ARE reused, verbatim, from that file.
|
||||||
|
//
|
||||||
|
// WINDOWED, because the reference is not. Tauri requests 500 albums, and the
|
||||||
|
// nearest Qt panel walks every album url the moment the document lands
|
||||||
|
// (LibraryArtistsPanel.qml:182-193) and instantiates every card through nested
|
||||||
|
// Repeaters (:383-445) — 500 live delegates and 500 artwork downloads for a
|
||||||
|
// pane showing eight. Here BOTH columns are single ListViews over a flat entry
|
||||||
|
// model (the LocalArtistsTab / LibraryArtistsPanel rail shape, extended to
|
||||||
|
// carry grid ROWS), and covers are requested only for the mounted band plus
|
||||||
|
// one screen of buffer, capped at `coverBatchCap` urls per report. Geometry and
|
||||||
|
// ordering are Tauri's; only the instantiation is bounded.
|
||||||
|
//
|
||||||
|
// PULSE LAW: the only continuous animation on this surface is the rail's name
|
||||||
|
// ticker, and it rides QbzShell.pulseMs through a Connections that is DISABLED
|
||||||
|
// while the row is not hovered — a row at rest writes nothing at all. There is
|
||||||
|
// no local Timer, no NumberAnimation and no Behavior on a data-fed property;
|
||||||
|
// the hover fades are gesture-bounded, which is the one class the law allows
|
||||||
|
// (cards/ArtistCard.qml:170).
|
||||||
|
//
|
||||||
|
// NO SPINNER for the loading state: QbzSpinner owns an infinite
|
||||||
|
// RotationAnimation at display rate and QbzSkeleton a repeating 900ms Timer,
|
||||||
|
// and 00-CONTRACT.md §4.5 closes that door for new code. The three-dot
|
||||||
|
// QbzLoadingDots is host-driven by design, so its phase is cycled here off the
|
||||||
|
// shell pulse — three writes a second, only while the pane is actually loading.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import com.blitzfc.qbz
|
||||||
|
import "../../controls"
|
||||||
|
import "../../theme"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// --- the frozen mount API (00-CONTRACT.md §13.1, scene lane) ---------
|
||||||
|
/// `sceneJson.panel` — { state, artistId, error, discography, epsSingles,
|
||||||
|
/// liveAlbums }.
|
||||||
|
property var doc: ({})
|
||||||
|
/// `sceneJson.artists` — the FILTERED list the host is showing. The rail
|
||||||
|
/// is always A-Z grouped over it, whatever the view's own grouping toggle
|
||||||
|
/// says (Tauri passes `groupArtists(filteredArtists)` unconditionally,
|
||||||
|
/// V:718, which is why its Group select is hidden in this mode).
|
||||||
|
property var artists: []
|
||||||
|
/// The selected artist's Qobuz id, "" for none.
|
||||||
|
property string selectedId: ""
|
||||||
|
|
||||||
|
signal artistSelected(string qobuzId)
|
||||||
|
signal albumActivated(string albumId)
|
||||||
|
signal albumPlayed(string albumId)
|
||||||
|
|
||||||
|
QbzTheme { id: theme }
|
||||||
|
|
||||||
|
// ------------------------------ metrics ------------------------------
|
||||||
|
// Every number is Tauri's; none is a theme token, because the theme's type
|
||||||
|
// scale (fontBody 15, fontLegal 13) does not line up with this surface's
|
||||||
|
// 14/13/12/11 and the contract makes Tauri normative for geometry.
|
||||||
|
readonly property int railW: 240 // .artist-column width
|
||||||
|
readonly property int railPadLeft: 18 // …its padding-left
|
||||||
|
readonly property int rowH: 64 // ROW_HEIGHT
|
||||||
|
readonly property int groupHeadH: 32 // HEADER_HEIGHT
|
||||||
|
readonly property int cardW: 220 // AlbumCardLite .card width
|
||||||
|
readonly property int coverS: 220 // …and its square cover
|
||||||
|
// 220 cover + the card's 4px column gap + the meta row (a 14/1.3 title
|
||||||
|
// over a 13/1.3 artist, 2px apart — 38 — against a 30px quality badge).
|
||||||
|
readonly property int cardH: 262
|
||||||
|
readonly property int gridGap: 22 // .artist-albums-grid gap
|
||||||
|
readonly property int trackMin: 210 // …minmax(210px, 1fr)
|
||||||
|
// Section header: a 16/600 title over 12px of air, a 1px rule, and 16px of
|
||||||
|
// margin under it.
|
||||||
|
readonly property int sectionHeadH: 51
|
||||||
|
/// Hard bound on urls asked for in one report — see the windowing note.
|
||||||
|
readonly property int coverBatchCap: 48
|
||||||
|
|
||||||
|
// ========================= artwork, url-keyed =========================
|
||||||
|
// The mechanism LibraryArtistsPanel.qml:158-193 already uses for its right
|
||||||
|
// pane and views/ArtistView.qml for its sidebar: ask QbzShell for a list of
|
||||||
|
// urls, take (url, path) pairs back on QbzLibrary.libraryArtworkReady.
|
||||||
|
// Arrivals are coalesced into ONE rebind per frame — per-arrival rebinding
|
||||||
|
// is O(n²) over a big discography.
|
||||||
|
//
|
||||||
|
// `_asked` is what makes this bounded rather than merely lazy:
|
||||||
|
// sidebar_artwork_window (main.rs:968) DOWNLOADS every url it is handed
|
||||||
|
// that is not already on disk, so re-reporting the same band on every
|
||||||
|
// scroll pixel would re-queue the same fetches. Never pruned — a cache
|
||||||
|
// path that has already been resolved costs one map entry, and dropping it
|
||||||
|
// would only make the cover flicker back to the placeholder.
|
||||||
|
property var coverMap: ({})
|
||||||
|
property var _coverInbox: ({})
|
||||||
|
property var _asked: ({})
|
||||||
|
Timer {
|
||||||
|
id: coverFlush
|
||||||
|
interval: 16
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
var m = Object.assign({}, root.coverMap, root._coverInbox)
|
||||||
|
root._coverInbox = ({})
|
||||||
|
root.coverMap = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: QbzLibrary
|
||||||
|
function onLibraryArtworkReady(key, path) {
|
||||||
|
root._coverInbox[key] = path
|
||||||
|
if (!coverFlush.running)
|
||||||
|
coverFlush.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function requestCovers(urls) {
|
||||||
|
var out = []
|
||||||
|
for (var i = 0; i < urls.length && out.length < root.coverBatchCap; i++) {
|
||||||
|
var u = urls[i]
|
||||||
|
if (u === "" || root._asked[u] === true || root.coverMap[u])
|
||||||
|
continue
|
||||||
|
root._asked[u] = true
|
||||||
|
out.push(u)
|
||||||
|
}
|
||||||
|
if (out.length > 0)
|
||||||
|
QbzShell.sidebarArtworkWindow(JSON.stringify(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- field readers (the lane seam) -------------------
|
||||||
|
// 00-CONTRACT.md §13.1 freezes the panel's SHAPE but not the album row's
|
||||||
|
// field NAMES. The Rust lane landed them as `albumId` / `artworkUrl` /
|
||||||
|
// `year` (4-digit string, already sliced) / `qualityTier` /
|
||||||
|
// `qualityDetail` — src/artist_scene_qt.rs:233-279. The alternates below
|
||||||
|
// are kept as one-token insurance across that seam: an `id`/`artUrl` row
|
||||||
|
// from any other producer still draws, instead of a grid of blank cards
|
||||||
|
// with no error anywhere.
|
||||||
|
/// The artist's display name, whatever the producer called it.
|
||||||
|
///
|
||||||
|
/// Same seam as the album readers above, and it has already bitten once:
|
||||||
|
/// the swap to the standard ArtistCard (owner ruling R8) moved the artist
|
||||||
|
/// row's `name` to the house `title`, which silently collapsed the host
|
||||||
|
/// grid's A-Z grouping into a single "#" bucket — every step stayed a
|
||||||
|
/// legal operation on an empty string, so nothing errored.
|
||||||
|
function artistNameOf(a) { return String((a && (a.title || a.name)) || "") }
|
||||||
|
function albumIdOf(a) { return String(a.albumId || a.id || "") }
|
||||||
|
function albumArtOf(a) { return String(a.artworkUrl || a.artUrl || a.imageUrl || "") }
|
||||||
|
function albumYearOf(a) {
|
||||||
|
if (a.year !== undefined && a.year !== null && String(a.year) !== "")
|
||||||
|
return String(a.year)
|
||||||
|
// Tauri: `Number(release_date_original?.slice(0,4)) || undefined`.
|
||||||
|
var d = String(a.releaseDate || "")
|
||||||
|
return d.length >= 4 ? d.slice(0, 4) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== the rail ==============================
|
||||||
|
// Entry model — { t: 0, label } | { t: 1, item } — so ONE ListView windows
|
||||||
|
// both the letter headers and the rows.
|
||||||
|
property var railEntries: []
|
||||||
|
/// Group keys that have members, for the host's alpha rail if it wants
|
||||||
|
/// them. `alphaGroupKey(name)` is Tauri's: the first character uppercased,
|
||||||
|
/// kept when /[A-Z]/, else '#' — so accented and non-Latin initials all
|
||||||
|
/// collapse into '#' (V:271-276). That is design, not defect: the contract
|
||||||
|
/// lists it under "deliberately NOT diverged".
|
||||||
|
function alphaKey(name) {
|
||||||
|
var c = String(name || "").charAt(0).toUpperCase()
|
||||||
|
return (c >= "A" && c <= "Z") ? c : "#"
|
||||||
|
}
|
||||||
|
function rebuildRail() {
|
||||||
|
var src = (root.artists || []).slice()
|
||||||
|
// `groupArtists` sorts a COPY by localeCompare, buckets by key, then
|
||||||
|
// orders the keys with '#' forced first (V:278-296).
|
||||||
|
src.sort(function (a, b) {
|
||||||
|
return root.artistNameOf(a).localeCompare(root.artistNameOf(b))
|
||||||
|
})
|
||||||
|
var buckets = {}
|
||||||
|
var order = []
|
||||||
|
for (var i = 0; i < src.length; i++) {
|
||||||
|
var k = root.alphaKey(root.artistNameOf(src[i]))
|
||||||
|
if (buckets[k] === undefined) {
|
||||||
|
buckets[k] = []
|
||||||
|
order.push(k)
|
||||||
|
}
|
||||||
|
buckets[k].push(src[i])
|
||||||
|
}
|
||||||
|
order.sort(function (a, b) {
|
||||||
|
if (a === "#") return -1
|
||||||
|
if (b === "#") return 1
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
var out = []
|
||||||
|
for (var g = 0; g < order.length; g++) {
|
||||||
|
out.push({ "t": 0, "label": order[g] })
|
||||||
|
var rows = buckets[order[g]]
|
||||||
|
for (var r = 0; r < rows.length; r++)
|
||||||
|
out.push({ "t": 1, "item": rows[r] })
|
||||||
|
}
|
||||||
|
root.railEntries = out
|
||||||
|
}
|
||||||
|
onArtistsChanged: { root.rebuildRail(); root.reportRailSoon() }
|
||||||
|
Component.onCompleted: { root.rebuildRail(); root.reportRailSoon() }
|
||||||
|
|
||||||
|
function reportRail() {
|
||||||
|
if (!root.visible || root.railEntries.length === 0)
|
||||||
|
return
|
||||||
|
var first = railList.indexAt(4, railList.contentY + 1)
|
||||||
|
var last = railList.indexAt(4, railList.contentY + Math.max(1, railList.height) - 1)
|
||||||
|
if (first < 0)
|
||||||
|
first = 0
|
||||||
|
if (last < 0)
|
||||||
|
last = Math.min(root.railEntries.length - 1, first + 12)
|
||||||
|
first = Math.max(0, first - 4)
|
||||||
|
last = Math.min(root.railEntries.length - 1, last + 4)
|
||||||
|
var urls = []
|
||||||
|
for (var i = first; i <= last; i++) {
|
||||||
|
var e = root.railEntries[i]
|
||||||
|
if (e.t === 1 && String(e.item.imageUrl || "") !== "")
|
||||||
|
urls.push(String(e.item.imageUrl))
|
||||||
|
}
|
||||||
|
root.requestCovers(urls)
|
||||||
|
}
|
||||||
|
/// Report now, then again once the ListView has laid out — `indexAt`
|
||||||
|
/// answers -1 before that, which is the state of a rail that has just been
|
||||||
|
/// rebuilt or just been mounted (views/local/LocalArtistsTab.qml:330).
|
||||||
|
Timer {
|
||||||
|
id: railSettle
|
||||||
|
interval: 50
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.reportRail()
|
||||||
|
}
|
||||||
|
function reportRailSoon() {
|
||||||
|
root.reportRail()
|
||||||
|
railSettle.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: rail
|
||||||
|
width: root.railW
|
||||||
|
height: parent.height
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: railList
|
||||||
|
x: root.railPadLeft
|
||||||
|
width: parent.width - root.railPadLeft
|
||||||
|
height: parent.height
|
||||||
|
// `.virtual-container { padding: 4px 12px 4px 0 }` — the 12px is
|
||||||
|
// taken off the DELEGATE width below, not off the Flickable's
|
||||||
|
// rightMargin, which on a vertical list only moves the horizontal
|
||||||
|
// content bounds.
|
||||||
|
topMargin: 4
|
||||||
|
bottomMargin: 4
|
||||||
|
clip: true
|
||||||
|
cacheBuffer: root.rowH * 6
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
model: root.railEntries
|
||||||
|
|
||||||
|
onContentYChanged: root.reportRail()
|
||||||
|
onModelChanged: root.reportRail()
|
||||||
|
onHeightChanged: root.reportRail()
|
||||||
|
onVisibleChanged: if (visible) root.reportRailSoon()
|
||||||
|
|
||||||
|
delegate: Loader {
|
||||||
|
required property var modelData
|
||||||
|
width: railList.width - 12
|
||||||
|
height: modelData.t === 0 ? root.groupHeadH : root.rowH
|
||||||
|
sourceComponent: modelData.t === 0 ? groupHeadComp : artistRowComp
|
||||||
|
|
||||||
|
// `.group-header` — 11/600 uppercase muted, letter-spacing
|
||||||
|
// 0.5, padding 12px 8px 4px, and NO count (the grid's header
|
||||||
|
// has one; this one does not).
|
||||||
|
Component {
|
||||||
|
id: groupHeadComp
|
||||||
|
Item {
|
||||||
|
Text {
|
||||||
|
x: 8
|
||||||
|
y: parent.height - 4 - height
|
||||||
|
text: String(modelData.label).toUpperCase()
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.weight: theme.weightSemibold
|
||||||
|
font.letterSpacing: 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: artistRowComp
|
||||||
|
Rectangle {
|
||||||
|
id: artistRow
|
||||||
|
|
||||||
|
readonly property var item: modelData.item
|
||||||
|
readonly property string qobuzId: String(artistRow.item.qobuzId || "")
|
||||||
|
readonly property bool picked: root.selectedId !== ""
|
||||||
|
&& root.selectedId === artistRow.qobuzId
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
height: root.rowH
|
||||||
|
radius: 8
|
||||||
|
color: artistRow.picked ? theme.surfaceElevated
|
||||||
|
: (rowArea.containsMouse ? theme.bgHover : "transparent")
|
||||||
|
// `transition: background-color 150ms ease`. Both arms
|
||||||
|
// are gesture-bounded — hover, and a click on this very
|
||||||
|
// row is the only thing that moves the selection.
|
||||||
|
Behavior on color { ColorAnimation { duration: 150 } }
|
||||||
|
|
||||||
|
// --- ticker state, all of it local to the hovered row
|
||||||
|
// `--ticker-offset: -(overflow + 16)px`,
|
||||||
|
// `--ticker-duration: (overflow + 16) / 40` seconds.
|
||||||
|
readonly property real overflow:
|
||||||
|
Math.max(0, nameText.implicitWidth - nameClip.width)
|
||||||
|
readonly property bool ticking: rowArea.containsMouse && artistRow.overflow > 0
|
||||||
|
readonly property real tickerOffset: -(artistRow.overflow + 16)
|
||||||
|
readonly property real tickerMs: (artistRow.overflow + 16) * 25
|
||||||
|
/// Loop phase 0..1, advanced on the SHELL PULSE. Must
|
||||||
|
/// match settings_qt::shell_pulse_ms (default 33) — a
|
||||||
|
/// mismatch only mis-speeds the ticker, never the
|
||||||
|
/// present rate (immersive/EqualizerBars.qml:55).
|
||||||
|
readonly property int _tickMs: 33
|
||||||
|
property real phase: 0
|
||||||
|
property real tickerX: 0
|
||||||
|
|
||||||
|
// Disabled while the row is not ticking, so a row at
|
||||||
|
// rest writes NOTHING — an invisible or frozen
|
||||||
|
// component that keeps assigning still dirties the
|
||||||
|
// whole window, which is the entire GPU bill on this
|
||||||
|
// shell.
|
||||||
|
Connections {
|
||||||
|
target: QbzShell
|
||||||
|
enabled: artistRow.ticking
|
||||||
|
function onPulseMsChanged() {
|
||||||
|
artistRow.phase = (artistRow.phase
|
||||||
|
+ artistRow._tickMs / artistRow.tickerMs) % 1.0
|
||||||
|
artistRow.tickerX = artistRow.tickerAt(artistRow.phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onTickingChanged: if (!artistRow.ticking) {
|
||||||
|
// One write on the way out, bounded by the gesture.
|
||||||
|
artistRow.phase = 0
|
||||||
|
artistRow.tickerX = 0
|
||||||
|
}
|
||||||
|
// `@keyframes artist-name-ticker`, linear:
|
||||||
|
// 0%,20% -> 0 · 70%,80% -> offset · 90%,100% -> 0.
|
||||||
|
function tickerAt(u) {
|
||||||
|
var o = artistRow.tickerOffset
|
||||||
|
if (u < 0.2)
|
||||||
|
return 0
|
||||||
|
if (u < 0.7)
|
||||||
|
return o * (u - 0.2) / 0.5
|
||||||
|
if (u < 0.8)
|
||||||
|
return o
|
||||||
|
if (u < 0.9)
|
||||||
|
return o * (1.0 - (u - 0.8) / 0.1)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// 48px circle, `MicVocal size={20}` placeholder.
|
||||||
|
Rectangle {
|
||||||
|
width: 48
|
||||||
|
height: 48
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
radius: 24
|
||||||
|
color: theme.surfaceElevated
|
||||||
|
clip: true
|
||||||
|
QbzIcon {
|
||||||
|
visible: portrait.source === ""
|
||||||
|
anchors.centerIn: parent
|
||||||
|
name: "mic-vocal"
|
||||||
|
width: 20
|
||||||
|
height: 20
|
||||||
|
tintName: "muted"
|
||||||
|
}
|
||||||
|
RoundedImage {
|
||||||
|
id: portrait
|
||||||
|
anchors.fill: parent
|
||||||
|
// `artUrl` since the card swap (owner ruling R8): the row is
|
||||||
|
// published in the house card shape now, and the old
|
||||||
|
// `imageUrl` no longer exists. The `|| imageUrl` tail is
|
||||||
|
// the same one-token seam insurance this file documents
|
||||||
|
// for the album row's field names — a row from any other
|
||||||
|
// producer still draws instead of a blank rail.
|
||||||
|
source: root.coverMap[String(artistRow.item.artUrl
|
||||||
|
|| artistRow.item.imageUrl || "")] || ""
|
||||||
|
radius: 24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: nameClip
|
||||||
|
width: parent.width - 48 - 12
|
||||||
|
height: parent.height
|
||||||
|
clip: true
|
||||||
|
Text {
|
||||||
|
id: nameText
|
||||||
|
x: artistRow.tickerX
|
||||||
|
width: artistRow.ticking ? implicitWidth : nameClip.width
|
||||||
|
height: parent.height
|
||||||
|
text: root.artistNameOf(artistRow.item)
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: 14
|
||||||
|
font.weight: theme.weightMedium
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
// `text-overflow: clip` while ticking.
|
||||||
|
elide: artistRow.ticking ? Text.ElideNone : Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: rowArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.artistSelected(artistRow.qobuzId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `border-right: 1px solid var(--bg-tertiary)` — the reference's rule is
|
||||||
|
// the ELEVATED surface, not a border token.
|
||||||
|
Rectangle {
|
||||||
|
x: root.railW
|
||||||
|
width: 1
|
||||||
|
height: parent.height
|
||||||
|
color: theme.surfaceElevated
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================= the albums column =========================
|
||||||
|
Item {
|
||||||
|
id: pane
|
||||||
|
|
||||||
|
// `.artist-albums-column { padding: 0 8px 0 24px }` plus the scroll
|
||||||
|
// area's own `padding-right: 8px`.
|
||||||
|
readonly property int padLeft: 24
|
||||||
|
readonly property int padRight: 16
|
||||||
|
readonly property int gridW: Math.max(root.trackMin, pane.width - pane.padLeft - pane.padRight)
|
||||||
|
readonly property int columns: Math.max(
|
||||||
|
1, Math.floor((pane.gridW + root.gridGap) / (root.trackMin + root.gridGap)))
|
||||||
|
// `auto-fill minmax(210px, 1fr)`: the TRACK stretches to 1fr, while the
|
||||||
|
// card stays 220 wide and may overflow it. 00-CONTRACT.md §6 lists that
|
||||||
|
// mismatch under "deliberately NOT diverged" — reproduce it, do not fix
|
||||||
|
// it silently.
|
||||||
|
readonly property real trackW: (pane.gridW - (pane.columns - 1) * root.gridGap) / pane.columns
|
||||||
|
|
||||||
|
// NOT `state`: every Item already owns a `state` string for QML states,
|
||||||
|
// and redeclaring it is a hard parse error.
|
||||||
|
readonly property string panelState: String(root.doc.state || "idle")
|
||||||
|
readonly property bool showIdle: root.selectedId === "" || pane.panelState === "idle"
|
||||||
|
readonly property bool showLoading: !pane.showIdle && pane.panelState === "loading"
|
||||||
|
readonly property bool showError: !pane.showIdle && pane.panelState === "error"
|
||||||
|
readonly property bool showEmpty: !pane.showIdle && pane.panelState !== "loading"
|
||||||
|
&& pane.panelState !== "error" && root.albumEntries.length === 0
|
||||||
|
readonly property bool showList: !pane.showIdle && pane.panelState !== "loading"
|
||||||
|
&& pane.panelState !== "error" && root.albumEntries.length > 0
|
||||||
|
|
||||||
|
x: root.railW + 1
|
||||||
|
y: 0
|
||||||
|
width: parent.width - x
|
||||||
|
height: parent.height
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
// --- 1 · nothing selected (`MicVocal size={48}`) ------------------
|
||||||
|
// The four pre-list states share `.artist-albums-*`: a centred column,
|
||||||
|
// gap 12, 14px muted.
|
||||||
|
Column {
|
||||||
|
visible: pane.showIdle
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 12
|
||||||
|
QbzIcon {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
name: "mic-vocal"
|
||||||
|
width: 48
|
||||||
|
height: 48
|
||||||
|
tintName: "muted"
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: QbzSession.tr("Select an artist to see their albums", QbzSession.trRev)
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2 · loading --------------------------------------------------
|
||||||
|
Column {
|
||||||
|
visible: pane.showLoading
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 12
|
||||||
|
QbzLoadingDots {
|
||||||
|
id: dots
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
// Cycled off the shell pulse instead of a local Timer, and only
|
||||||
|
// while this column is up: ~300ms per step, three property
|
||||||
|
// writes a second, zero when idle.
|
||||||
|
property int accMs: 0
|
||||||
|
Connections {
|
||||||
|
target: QbzShell
|
||||||
|
enabled: pane.showLoading
|
||||||
|
function onPulseMsChanged() {
|
||||||
|
dots.accMs += 33
|
||||||
|
if (dots.accMs >= 300) {
|
||||||
|
dots.accMs = 0
|
||||||
|
dots.phase = (dots.phase + 1) % 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: QbzSession.tr("Loading albums...", QbzSession.trRev)
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3 · error, with the raw detail under it ----------------------
|
||||||
|
Column {
|
||||||
|
visible: pane.showError
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 12
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: QbzSession.tr("Failed to load albums", QbzSession.trRev)
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
// `.error-detail { font-size: 12px; opacity: 0.7 }` — the raw
|
||||||
|
// String(err), untranslated by construction.
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
width: Math.min(implicitWidth, pane.width - 48)
|
||||||
|
text: String(root.doc.error || "")
|
||||||
|
color: theme.textMuted
|
||||||
|
opacity: 0.7
|
||||||
|
font.pixelSize: 12
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4 · loaded, nothing to show (`Disc3 size={48}`) --------------
|
||||||
|
Column {
|
||||||
|
visible: pane.showEmpty
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 12
|
||||||
|
QbzIcon {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
name: "disc-3"
|
||||||
|
width: 48
|
||||||
|
height: 48
|
||||||
|
tintName: "muted"
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: QbzSession.tr("No albums found", QbzSession.trRev)
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5 · the three sections ---------------------------------------
|
||||||
|
ListView {
|
||||||
|
id: albumList
|
||||||
|
visible: pane.showList
|
||||||
|
x: pane.padLeft
|
||||||
|
width: pane.gridW
|
||||||
|
height: parent.height
|
||||||
|
clip: true
|
||||||
|
cacheBuffer: root.cardH
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
model: root.albumEntries
|
||||||
|
|
||||||
|
onContentYChanged: root.reportAlbums()
|
||||||
|
onModelChanged: root.reportAlbums()
|
||||||
|
onHeightChanged: root.reportAlbums()
|
||||||
|
onVisibleChanged: if (visible) root.reportAlbumsSoon()
|
||||||
|
|
||||||
|
delegate: Loader {
|
||||||
|
required property var modelData
|
||||||
|
width: albumList.width
|
||||||
|
height: modelData.h
|
||||||
|
sourceComponent: modelData.t === 0 ? sectionHeadComp : albumRowComp
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: sectionHeadComp
|
||||||
|
Item {
|
||||||
|
// `align-items: baseline; gap: 12px`, then 12px of air,
|
||||||
|
// a 1px rule and 16px of margin.
|
||||||
|
Text {
|
||||||
|
id: secTitle
|
||||||
|
y: 0
|
||||||
|
text: modelData.title
|
||||||
|
color: theme.textPrimary
|
||||||
|
font.pixelSize: 16
|
||||||
|
font.weight: theme.weightSemibold
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.left: secTitle.right
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.baseline: secTitle.baseline
|
||||||
|
text: modelData.count
|
||||||
|
color: theme.textMuted
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
y: secTitle.height + 12
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: theme.surfaceElevated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: albumRowComp
|
||||||
|
Item {
|
||||||
|
Repeater {
|
||||||
|
model: modelData.albums
|
||||||
|
delegate: SceneAlbumCard {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
x: index * (pane.trackW + root.gridGap)
|
||||||
|
albumId: root.albumIdOf(modelData)
|
||||||
|
title: String(modelData.title || "")
|
||||||
|
artist: String(modelData.artist || modelData.artistName || "")
|
||||||
|
genre: String(modelData.genre || "")
|
||||||
|
year: root.albumYearOf(modelData)
|
||||||
|
artSource: root.coverMap[root.albumArtOf(modelData)] || ""
|
||||||
|
bitDepth: Number(modelData.bitDepth || 0)
|
||||||
|
samplingRate: Number(modelData.samplingRate || 0)
|
||||||
|
qualityTier: String(modelData.qualityTier || "")
|
||||||
|
qualityDetail: String(modelData.qualityDetail || "")
|
||||||
|
onActivated: root.albumActivated(albumId)
|
||||||
|
onPlayed: root.albumPlayed(albumId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QbzScrollBar {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 4
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
target: albumList
|
||||||
|
visible: albumList.visible && albumList.contentHeight > albumList.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== the album entry model =========================
|
||||||
|
// Flat rows so ONE ListView windows headers AND grid rows:
|
||||||
|
// { t: 0, title, count, h } | { t: 1, albums: [...], h }
|
||||||
|
// The trailing margins live in `h` rather than in ListView.spacing,
|
||||||
|
// because the three gaps differ: 22 between grid rows, 32 under a section,
|
||||||
|
// 16 under the LAST one.
|
||||||
|
property var albumEntries: []
|
||||||
|
function rebuildAlbums() {
|
||||||
|
var secs = [
|
||||||
|
{ "title": QbzSession.tr("Discography", QbzSession.trRev),
|
||||||
|
"albums": root.doc.discography || [] },
|
||||||
|
{ "title": QbzSession.tr("EPs & Singles", QbzSession.trRev),
|
||||||
|
"albums": root.doc.epsSingles || [] },
|
||||||
|
{ "title": QbzSession.tr("Live Albums", QbzSession.trRev),
|
||||||
|
"albums": root.doc.liveAlbums || [] }
|
||||||
|
]
|
||||||
|
// Fixed order, each rendered only when non-empty (V:748-813).
|
||||||
|
var live = []
|
||||||
|
for (var s = 0; s < secs.length; s++)
|
||||||
|
if (secs[s].albums.length > 0)
|
||||||
|
live.push(secs[s])
|
||||||
|
|
||||||
|
var out = []
|
||||||
|
var cols = pane.columns
|
||||||
|
for (s = 0; s < live.length; s++) {
|
||||||
|
out.push({ "t": 0, "title": live[s].title,
|
||||||
|
"count": live[s].albums.length, "h": root.sectionHeadH })
|
||||||
|
var al = live[s].albums
|
||||||
|
var rows = Math.ceil(al.length / cols)
|
||||||
|
for (var r = 0; r < rows; r++) {
|
||||||
|
var lastRow = (r === rows - 1)
|
||||||
|
var tail = lastRow ? (s === live.length - 1 ? 16 : 32) : root.gridGap
|
||||||
|
out.push({ "t": 1, "albums": al.slice(r * cols, (r + 1) * cols),
|
||||||
|
"h": root.cardH + tail })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.albumEntries = out
|
||||||
|
}
|
||||||
|
onDocChanged: {
|
||||||
|
// A new document is a new artist: forget which covers were asked for,
|
||||||
|
// or the next pane starts with an `_asked` map full of the previous
|
||||||
|
// artist's urls and re-requests nothing it needs.
|
||||||
|
root._asked = ({})
|
||||||
|
root.rebuildAlbums()
|
||||||
|
root.reportAlbumsSoon()
|
||||||
|
}
|
||||||
|
// The column count is width-derived, so a resize re-slices the rows.
|
||||||
|
Connections {
|
||||||
|
target: pane
|
||||||
|
function onColumnsChanged() { root.rebuildAlbums() }
|
||||||
|
}
|
||||||
|
// A live language switch must re-slice too. `rebuildAlbums` calls
|
||||||
|
// `QbzSession.tr(...)` for the three section titles and stores the RESULT
|
||||||
|
// in `albumEntries`, and the delegate renders that snapshot verbatim — so
|
||||||
|
// the strings are data, not bindings, and `trRev` cannot reach them. Its
|
||||||
|
// two other triggers (a new document, a resize) may never fire again after
|
||||||
|
// the user changes language, which would leave the previous language's
|
||||||
|
// headings on screen indefinitely. Same shape as the house pattern that
|
||||||
|
// threads the revision into a builder (NavFlyout.qml:111,
|
||||||
|
// ArtistView.qml:1363).
|
||||||
|
Connections {
|
||||||
|
target: QbzSession
|
||||||
|
function onTrRevChanged() { root.rebuildAlbums() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportAlbums() {
|
||||||
|
if (!root.visible || !albumList.visible || root.albumEntries.length === 0)
|
||||||
|
return
|
||||||
|
var first = albumList.indexAt(4, albumList.contentY + 1)
|
||||||
|
var last = albumList.indexAt(4, albumList.contentY + Math.max(1, albumList.height) - 1)
|
||||||
|
if (first < 0)
|
||||||
|
first = 0
|
||||||
|
if (last < 0)
|
||||||
|
last = Math.min(root.albumEntries.length - 1, first + 3)
|
||||||
|
// One screen of buffer either side, which is what keeps a fast scroll
|
||||||
|
// from showing placeholders — and the cap above is what keeps a fast
|
||||||
|
// scroll from queueing the whole discography.
|
||||||
|
first = Math.max(0, first - 1)
|
||||||
|
last = Math.min(root.albumEntries.length - 1, last + 1)
|
||||||
|
var urls = []
|
||||||
|
for (var i = first; i <= last; i++) {
|
||||||
|
var e = root.albumEntries[i]
|
||||||
|
if (e.t !== 1)
|
||||||
|
continue
|
||||||
|
for (var a = 0; a < e.albums.length; a++) {
|
||||||
|
var u = root.albumArtOf(e.albums[a])
|
||||||
|
if (u !== "")
|
||||||
|
urls.push(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.requestCovers(urls)
|
||||||
|
}
|
||||||
|
Timer {
|
||||||
|
id: albumSettle
|
||||||
|
interval: 50
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.reportAlbums()
|
||||||
|
}
|
||||||
|
function reportAlbumsSoon() {
|
||||||
|
root.reportAlbums()
|
||||||
|
albumSettle.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ the album card =========================
|
||||||
|
// AlbumCardLite, not cards/AlbumCard.qml: 220 wide over a 220×220 cover at
|
||||||
|
// radius 6, a 4px gap, then a meta row of the title/artist stack against
|
||||||
|
// the quality badge. The shared card is 200×246 with a pin badge, a heart,
|
||||||
|
// an award ribbon, a source badge and a nine-entry context menu — none of
|
||||||
|
// which this surface has.
|
||||||
|
//
|
||||||
|
// Declared as an inline component, which QML requires at the file's top
|
||||||
|
// level, and it reads NOTHING off an outer id: every input arrives as a
|
||||||
|
// property (the rule rows/TrackRow.qml:359-363 records).
|
||||||
|
component SceneAlbumCard: Item {
|
||||||
|
id: acard
|
||||||
|
|
||||||
|
property string albumId: ""
|
||||||
|
property string title: ""
|
||||||
|
property string artist: ""
|
||||||
|
property string genre: ""
|
||||||
|
property string year: ""
|
||||||
|
property string artSource: ""
|
||||||
|
property int bitDepth: 0
|
||||||
|
property real samplingRate: 0
|
||||||
|
/// The tier the RUST side already resolved (artist_scene_qt.rs), not a
|
||||||
|
/// number for QML to re-derive. See the badge mount below.
|
||||||
|
property string qualityTier: ""
|
||||||
|
property string qualityDetail: ""
|
||||||
|
|
||||||
|
signal activated()
|
||||||
|
signal played()
|
||||||
|
|
||||||
|
QbzTheme { id: cardTheme }
|
||||||
|
|
||||||
|
width: 220
|
||||||
|
height: 262
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: cover
|
||||||
|
width: 220
|
||||||
|
height: 220
|
||||||
|
radius: 6
|
||||||
|
color: cardTheme.surfaceElevated
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
RoundedImage {
|
||||||
|
anchors.fill: parent
|
||||||
|
source: acard.artSource
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
|
||||||
|
// `.cover-wrap::after` — a full scrim at 0.6, 150ms.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 6
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.6)
|
||||||
|
opacity: coverArea.containsMouse ? 1.0 : 0.0
|
||||||
|
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// `.meta` — top-left, and rendered ONLY when there is something to
|
||||||
|
// say (`{#if genre || releaseLabel}`); a card with neither shows no
|
||||||
|
// overlay text at all.
|
||||||
|
Column {
|
||||||
|
visible: acard.genre !== "" || acard.year !== ""
|
||||||
|
x: 12
|
||||||
|
y: 12
|
||||||
|
width: parent.width * 0.7
|
||||||
|
spacing: 2
|
||||||
|
opacity: coverArea.containsMouse ? 1.0 : 0.0
|
||||||
|
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||||
|
Text {
|
||||||
|
visible: acard.genre !== ""
|
||||||
|
width: parent.width
|
||||||
|
text: acard.genre
|
||||||
|
// Fixed light-on-scrim by design, like every other overlay
|
||||||
|
// in this port: the host is a 0.6 black wash under every
|
||||||
|
// theme (theme/QbzIcon.qml, "FIXED (theme-independent)").
|
||||||
|
color: Qt.rgba(1, 1, 1, 0.92)
|
||||||
|
font.pixelSize: 14
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
visible: acard.year !== ""
|
||||||
|
width: parent.width
|
||||||
|
text: acard.year
|
||||||
|
color: Qt.rgba(1, 1, 1, 0.92)
|
||||||
|
opacity: 0.8
|
||||||
|
font.pixelSize: 13
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card-open + hover detector, before the buttons so they win the
|
||||||
|
// pointer (cards/ArtistCard.qml:172-174).
|
||||||
|
MouseArea {
|
||||||
|
id: coverArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: acard.activated()
|
||||||
|
}
|
||||||
|
|
||||||
|
// `.actions` — bottom-centre, `bottom: 44px`, so the 44px row's top
|
||||||
|
// sits at 220 - 44 - 44.
|
||||||
|
//
|
||||||
|
// ONE button, where Tauri draws three. Its Heart is inert by
|
||||||
|
// construction (this view never passes `onFavorite`), and its kebab
|
||||||
|
// opens AlbumQuickMenu with exactly one live item, "Go to album" —
|
||||||
|
// which is what a click on the card already does. The frozen mount
|
||||||
|
// API for this component carries no favourite and no menu signal,
|
||||||
|
// and 00-CONTRACT.md's DV-2 rationale ("dead UI is neither pretty
|
||||||
|
// nor functional") settles the rest. Recorded as a deviation.
|
||||||
|
CardOverlayRow {
|
||||||
|
y: 132
|
||||||
|
width: parent.width
|
||||||
|
shown: coverArea.containsMouse
|
||||||
|
CardOverlayButton {
|
||||||
|
name: "play-fill"
|
||||||
|
primary: true
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
onClicked: acard.played()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `.meta-row { align-items: center; gap: 8px }` over
|
||||||
|
// `.text-stack { gap: 2px }`, 4px under the cover.
|
||||||
|
Item {
|
||||||
|
y: 224
|
||||||
|
width: parent.width
|
||||||
|
height: 38
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: textStack
|
||||||
|
// The badge hides itself on an unknown tier (DV-9), and an
|
||||||
|
// invisible Item still HAS a width — so the 8px gap and the
|
||||||
|
// badge's own box only come off the stack when it is drawn.
|
||||||
|
width: parent.width - (badge.visible ? badge.width + 8 : 0)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 2
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: acard.title
|
||||||
|
color: cardTheme.textPrimary
|
||||||
|
font.pixelSize: 14
|
||||||
|
font.weight: cardTheme.weightMedium
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: acard.artist
|
||||||
|
color: cardTheme.textMuted
|
||||||
|
font.pixelSize: 13
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DV-9: Tauri passes no quality at all here, so QualityBadgeStatic
|
||||||
|
// falls through its tier ladder and EVERY scene album card claims
|
||||||
|
// CD. Misinformation about audio quality is load-bearing in this
|
||||||
|
// app, so the real numbers ride the row and the badge hides itself
|
||||||
|
// when the depth is unknown (the home_qt.rs:1183-1200 rule, which
|
||||||
|
// QualityBadge already implements through its own
|
||||||
|
// `visible: resolvedTier !== ""` — do NOT override that binding).
|
||||||
|
QualityBadge {
|
||||||
|
id: badge
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
// THE TIER COMES FROM RUST. Passing only the raw numbers let
|
||||||
|
// QualityBadge re-derive it in QML, and its ladder ends in
|
||||||
|
// `if (samplingRate >= 44.1) return "cd"` — so every album
|
||||||
|
// whose depth was unknown fell out as CD. That is precisely
|
||||||
|
// the reference defect DV-9 exists to remove: Tauri passed no
|
||||||
|
// quality at all and stamped a CD badge on the entire grid.
|
||||||
|
// `artist_scene_qt.rs` resolves the tier from the real depth
|
||||||
|
// and BLANKS it when the depth is unknown, and the badge hides
|
||||||
|
// itself on an empty tier — so an album we cannot vouch for
|
||||||
|
// now shows nothing rather than a confident lie.
|
||||||
|
//
|
||||||
|
// Same shape as every other card in the app
|
||||||
|
// (cards/AlbumCard.qml:687, local/LocalAlbumRow.qml:162,
|
||||||
|
// library/FeedListRow.qml:278, cards/TrackCard.qml:223).
|
||||||
|
tierOverride: acard.qualityTier
|
||||||
|
label: acard.qualityDetail
|
||||||
|
// Kept for the hover tooltip only, never for tier resolution.
|
||||||
|
bitDepth: acard.bitDepth
|
||||||
|
samplingRate: acard.samplingRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,16 @@ pub struct StoryRow {
|
|||||||
/// MusicBrainz Origin block (artist.rs `MbOrigin`). `hasData` is the gate the
|
/// MusicBrainz Origin block (artist.rs `MbOrigin`). `hasData` is the gate the
|
||||||
/// sidebar uses so an MB-matched artist with no life-span/location renders
|
/// sidebar uses so an MB-matched artist with no life-span/location renders
|
||||||
/// nothing at all instead of an empty heading.
|
/// nothing at all instead of an empty heading.
|
||||||
|
///
|
||||||
|
/// The `location*` / `seed*` / `artistName` fields below exist so the Artist
|
||||||
|
/// Scene can be opened WITHOUT a second MusicBrainz round-trip: `map_origin`
|
||||||
|
/// already receives the whole `ArtistMetadata`, and until 2026-08-14 it threw
|
||||||
|
/// all of this away and kept only `location_display`. Everything
|
||||||
|
/// `discover_artists_by_location` needs is therefore carried on the artist
|
||||||
|
/// document itself. (The reference solves the same problem with a process-wide
|
||||||
|
/// `static LOCATION_PARAMS` mutex, `crates/qbz/src/artist.rs:1509-1543`; a
|
||||||
|
/// document field is the Qt-shaped answer and cannot go stale against the
|
||||||
|
/// artist being displayed.)
|
||||||
#[derive(Clone, Default, Serialize)]
|
#[derive(Clone, Default, Serialize)]
|
||||||
pub struct MbOriginJson {
|
pub struct MbOriginJson {
|
||||||
#[serde(rename = "isPerson")]
|
#[serde(rename = "isPerson")]
|
||||||
@@ -166,6 +176,52 @@ pub struct MbOriginJson {
|
|||||||
pub location_display: String,
|
pub location_display: String,
|
||||||
#[serde(rename = "hasData")]
|
#[serde(rename = "hasData")]
|
||||||
pub has_data: bool,
|
pub has_data: bool,
|
||||||
|
|
||||||
|
// ---- Artist Scene payload ------------------------------------------
|
||||||
|
/// MusicBrainz area id, "" when absent. `area_id` on the discovery call.
|
||||||
|
#[serde(rename = "locationAreaId")]
|
||||||
|
pub location_area_id: String,
|
||||||
|
/// City, "" when absent. The discovery call prefers this over the display
|
||||||
|
/// name for `area_name` (ArtistsByLocationView.svelte:364).
|
||||||
|
#[serde(rename = "locationCity")]
|
||||||
|
pub location_city: String,
|
||||||
|
/// Country NAME, "" when absent. Also the hero title fallback.
|
||||||
|
#[serde(rename = "locationCountry")]
|
||||||
|
pub location_country: String,
|
||||||
|
/// ISO country code, "" when absent. This is what selects the flag, and
|
||||||
|
/// without it the hero simply has no flag element.
|
||||||
|
#[serde(rename = "locationCountryCode")]
|
||||||
|
pub location_country_code: String,
|
||||||
|
/// "city" | "state" | "country" | "" — feeds `locationClickable` only.
|
||||||
|
#[serde(rename = "locationPrecision")]
|
||||||
|
pub location_precision: String,
|
||||||
|
/// Affinity seeds: the discovery query's genres, and the hero subtitle's
|
||||||
|
/// fallback when the backend returns no `genre_summary`.
|
||||||
|
#[serde(rename = "seedGenres")]
|
||||||
|
pub seed_genres: Vec<String>,
|
||||||
|
#[serde(rename = "seedTags")]
|
||||||
|
pub seed_tags: Vec<String>,
|
||||||
|
/// The MUSICBRAINZ name, not the Qobuz one — it is what the scene hero's
|
||||||
|
/// "Based on {artist}" prints (ArtistDetailView.svelte:2915).
|
||||||
|
#[serde(rename = "artistName")]
|
||||||
|
pub artist_name: String,
|
||||||
|
/// Whether the location text is a link. Computed HERE so QML never
|
||||||
|
/// re-derives it: Tauri's guard is written without parentheses
|
||||||
|
/// (`ArtistDetailView.svelte:2904`) and JS `&&` binds tighter than `||`,
|
||||||
|
/// so it reads `(handler && precision != 'country') || city`. The second
|
||||||
|
/// arm forgets the handler check and is harmless there only because the
|
||||||
|
/// call is optional-chained. This is the INTENDED rule:
|
||||||
|
///
|
||||||
|
/// clickable = has_location && (precision != Country || city.is_some())
|
||||||
|
///
|
||||||
|
/// A country-only location has nothing to drill into. The reference
|
||||||
|
/// reached the same rule independently (`crates/qbz/src/artist.rs:1561`).
|
||||||
|
///
|
||||||
|
/// NOTE: this is necessary but NOT sufficient to enable either door — both
|
||||||
|
/// also require a non-empty `mbid` and a live MusicBrainz-enabled check.
|
||||||
|
/// `mb_available` is NOT that check; see its own doc below.
|
||||||
|
#[serde(rename = "locationClickable")]
|
||||||
|
pub location_clickable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One Members / Member-Of / Collaborators row (artist.rs `MbRelationshipRow`).
|
/// One Members / Member-Of / Collaborators row (artist.rs `MbRelationshipRow`).
|
||||||
@@ -200,8 +256,17 @@ pub struct MbDiscoveryJson {
|
|||||||
/// (which ride the Qobuz artist page). Mirrors Slint's `NetworkSidebarState`.
|
/// (which ride the Qobuz artist page). Mirrors Slint's `NetworkSidebarState`.
|
||||||
#[derive(Clone, Default, Serialize)]
|
#[derive(Clone, Default, Serialize)]
|
||||||
pub struct ArtistNetwork {
|
pub struct ArtistNetwork {
|
||||||
/// MusicBrainz is enabled AND this artist resolved to an MBID. False =
|
/// The user's MusicBrainz OPT-IN, and ONLY that. False = every MB-driven
|
||||||
/// every MB-driven section is ABSENT (opt-in rule: no error, no frame).
|
/// section is ABSENT (opt-in rule: no error, no frame).
|
||||||
|
///
|
||||||
|
/// ⚠ This doc used to claim "MusicBrainz is enabled AND this artist
|
||||||
|
/// resolved to an MBID". It never did that: it is assigned from
|
||||||
|
/// `musicbrainz_is_enabled()` alone (see `data.network.mb_available`
|
||||||
|
/// below), seeded SYNCHRONOUSLY with the first artist frame, while `mbid`
|
||||||
|
/// stays "" until the async `publish_network` lands. Anything that needs
|
||||||
|
/// "this artist actually has an MBID" must test `mbid` itself — gating an
|
||||||
|
/// affordance on this flag makes it live from frame one with no id to
|
||||||
|
/// hand it, which is exactly how the Artist Scene doors would break.
|
||||||
#[serde(rename = "mbAvailable")]
|
#[serde(rename = "mbAvailable")]
|
||||||
pub mb_available: bool,
|
pub mb_available: bool,
|
||||||
pub mbid: String,
|
pub mbid: String,
|
||||||
@@ -1314,7 +1379,7 @@ async fn load_mb_metadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn map_origin(meta: &qbz_integrations::musicbrainz::ArtistMetadata) -> MbOriginJson {
|
fn map_origin(meta: &qbz_integrations::musicbrainz::ArtistMetadata) -> MbOriginJson {
|
||||||
use qbz_integrations::musicbrainz::ArtistType;
|
use qbz_integrations::musicbrainz::{ArtistType, LocationPrecision};
|
||||||
|
|
||||||
let is_person = matches!(meta.artist_type, ArtistType::Person);
|
let is_person = matches!(meta.artist_type, ArtistType::Person);
|
||||||
let begin_date = meta
|
let begin_date = meta
|
||||||
@@ -1334,12 +1399,40 @@ fn map_origin(meta: &qbz_integrations::musicbrainz::ArtistMetadata) -> MbOriginJ
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let has_data =
|
let has_data =
|
||||||
!begin_date.is_empty() || !end_date.is_empty() || !location_display.is_empty();
|
!begin_date.is_empty() || !end_date.is_empty() || !location_display.is_empty();
|
||||||
|
|
||||||
|
// ---- Artist Scene payload, from the SAME metadata this call already has.
|
||||||
|
// No extra fetch: `meta` carries the whole location and the affinity
|
||||||
|
// seeds, and every one of these was being discarded here.
|
||||||
|
let loc = meta.location.as_ref();
|
||||||
|
let opt = |s: &Option<String>| s.clone().unwrap_or_default();
|
||||||
|
let location_precision = loc
|
||||||
|
.map(|l| match l.precision {
|
||||||
|
LocationPrecision::City => "city",
|
||||||
|
LocationPrecision::State => "state",
|
||||||
|
LocationPrecision::Country => "country",
|
||||||
|
})
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
// The INTENDED form of Tauri's guard — see the field's doc.
|
||||||
|
let location_clickable = loc.is_some_and(|l| {
|
||||||
|
!matches!(l.precision, LocationPrecision::Country) || l.city.is_some()
|
||||||
|
});
|
||||||
|
|
||||||
MbOriginJson {
|
MbOriginJson {
|
||||||
is_person,
|
is_person,
|
||||||
begin_date,
|
begin_date,
|
||||||
end_date,
|
end_date,
|
||||||
location_display,
|
location_display,
|
||||||
has_data,
|
has_data,
|
||||||
|
location_area_id: loc.map(|l| opt(&l.area_id)).unwrap_or_default(),
|
||||||
|
location_city: loc.map(|l| opt(&l.city)).unwrap_or_default(),
|
||||||
|
location_country: loc.map(|l| opt(&l.country)).unwrap_or_default(),
|
||||||
|
location_country_code: loc.map(|l| opt(&l.country_code)).unwrap_or_default(),
|
||||||
|
location_precision,
|
||||||
|
seed_genres: meta.affinity_seeds.genres.clone(),
|
||||||
|
seed_tags: meta.affinity_seeds.tags.clone(),
|
||||||
|
artist_name: meta.name.clone(),
|
||||||
|
location_clickable,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -378,6 +378,25 @@ fn emit_changed(kind: &str, id: &str, value: bool) {
|
|||||||
fn republish_all() {
|
fn republish_all() {
|
||||||
publish();
|
publish();
|
||||||
crate::publish_settings();
|
crate::publish_settings();
|
||||||
|
// Drop the 30-day Artist Scene cache (contract B3, requirement 3).
|
||||||
|
//
|
||||||
|
// THIS IS A CORRECTNESS CALL, NOT HOUSEKEEPING. Scene discovery caches a
|
||||||
|
// validated candidate list, and a cache hit short-circuits the whole
|
||||||
|
// validation loop — so a post-filter on the way out can hide a
|
||||||
|
// newly-blocked artist, but it can NEVER undo a substitution. Concretely:
|
||||||
|
// while artist X was blacklisted, validation resolved that MusicBrainz
|
||||||
|
// candidate to a DIFFERENT same-name Qobuz artist and cached that. Un-block
|
||||||
|
// X and the post-filter has nothing to restore — the row simply is not X
|
||||||
|
// any more, and stays wrong for the full TTL. Only dropping the entry
|
||||||
|
// re-runs validation.
|
||||||
|
//
|
||||||
|
// Placed in this funnel rather than at the four artist-axis mutators
|
||||||
|
// because every mutation already routes through here and a fifth one added
|
||||||
|
// later would silently miss the invalidation. The ALBUM-axis mutators trip
|
||||||
|
// it too, which is over-invalidation — a scene lists artists, not albums.
|
||||||
|
// That is an accepted cost: the penalty is one re-fetch, and the
|
||||||
|
// alternative is a rule someone has to remember.
|
||||||
|
crate::app().core().invalidate_scene_cache();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -379,6 +379,10 @@ impl qbz_hotkeys::QbzHotkeys {
|
|||||||
link_resolver_open: false,
|
link_resolver_open: false,
|
||||||
customize_open: *self.customize_open(),
|
customize_open: *self.customize_open(),
|
||||||
cheatsheet_open: *self.cheatsheet_open(),
|
cheatsheet_open: *self.cheatsheet_open(),
|
||||||
|
// Sampled from the OWNER of the state, like cortinilla/immersive
|
||||||
|
// below: the modal is an always-instantiated, self-gated global
|
||||||
|
// overlay, so no other surface can observe whether it is up.
|
||||||
|
musician_modal_open: crate::musician_qt::modal_open(),
|
||||||
cortinilla_open: crate::search_qt::cortinilla_open(),
|
cortinilla_open: crate::search_qt::cortinilla_open(),
|
||||||
immersive_open: crate::immersive_bridge::is_open(),
|
immersive_open: crate::immersive_bridge::is_open(),
|
||||||
// §4.6 seam: the QML-reported mirror (a view's multiSelectOn
|
// §4.6 seam: the QML-reported mirror (a view's multiSelectOn
|
||||||
@@ -390,6 +394,7 @@ impl qbz_hotkeys::QbzHotkeys {
|
|||||||
match crate::hotkeys_qt::escape_target(&state) {
|
match crate::hotkeys_qt::escape_target(&state) {
|
||||||
EscapeTarget::Customize => self.as_mut().set_customize_open(false),
|
EscapeTarget::Customize => self.as_mut().set_customize_open(false),
|
||||||
EscapeTarget::Cheatsheet => self.as_mut().set_cheatsheet_open(false),
|
EscapeTarget::Cheatsheet => self.as_mut().set_cheatsheet_open(false),
|
||||||
|
EscapeTarget::MusicianModal => crate::musician_qt::close_modal(),
|
||||||
EscapeTarget::Cortinilla => crate::search_qt::dismiss(),
|
EscapeTarget::Cortinilla => crate::search_qt::dismiss(),
|
||||||
EscapeTarget::Immersive => crate::immersive_bridge::close(),
|
EscapeTarget::Immersive => crate::immersive_bridge::close(),
|
||||||
EscapeTarget::MultiSelect => {
|
EscapeTarget::MultiSelect => {
|
||||||
|
|||||||
@@ -634,6 +634,13 @@ pub struct EscapeState {
|
|||||||
pub link_resolver_open: bool,
|
pub link_resolver_open: bool,
|
||||||
pub customize_open: bool,
|
pub customize_open: bool,
|
||||||
pub cheatsheet_open: bool,
|
pub cheatsheet_open: bool,
|
||||||
|
/// The global musician modal (z 3200). Sits ABOVE `immersive_open` in this
|
||||||
|
/// order on purpose: it paints above the immersive overlay by design —
|
||||||
|
/// consumer #6 opens it from the immersive Track-info panel and the
|
||||||
|
/// overlay deliberately stays up underneath — so Escape must take the
|
||||||
|
/// modal first, or the one keypress would close the thing behind it and
|
||||||
|
/// leave the modal floating over a dismissed view.
|
||||||
|
pub musician_modal_open: bool,
|
||||||
pub cortinilla_open: bool,
|
pub cortinilla_open: bool,
|
||||||
pub immersive_open: bool,
|
pub immersive_open: bool,
|
||||||
/// §4.6 seam: no Qt multi-select exit invokable exists yet (B2 adds
|
/// §4.6 seam: no Qt multi-select exit invokable exists yet (B2 adds
|
||||||
@@ -647,6 +654,7 @@ pub enum EscapeTarget {
|
|||||||
LinkResolver,
|
LinkResolver,
|
||||||
Customize,
|
Customize,
|
||||||
Cheatsheet,
|
Cheatsheet,
|
||||||
|
MusicianModal,
|
||||||
Cortinilla,
|
Cortinilla,
|
||||||
Immersive,
|
Immersive,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
@@ -668,6 +676,9 @@ pub fn escape_target(s: &EscapeState) -> EscapeTarget {
|
|||||||
if s.cheatsheet_open {
|
if s.cheatsheet_open {
|
||||||
return EscapeTarget::Cheatsheet;
|
return EscapeTarget::Cheatsheet;
|
||||||
}
|
}
|
||||||
|
if s.musician_modal_open {
|
||||||
|
return EscapeTarget::MusicianModal;
|
||||||
|
}
|
||||||
if s.cortinilla_open {
|
if s.cortinilla_open {
|
||||||
return EscapeTarget::Cortinilla;
|
return EscapeTarget::Cortinilla;
|
||||||
}
|
}
|
||||||
@@ -908,28 +919,57 @@ mod tests {
|
|||||||
// --- Escape stack order (§1.2) -----------------------------------------
|
// --- Escape stack order (§1.2) -----------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn escape_stack_walks_the_seven_surfaces_in_order() {
|
fn escape_stack_walks_the_eight_surfaces_in_order() {
|
||||||
let s = |link, cust, cheat, cort, imm, multi, queue| EscapeState {
|
#[allow(clippy::too_many_arguments)]
|
||||||
link_resolver_open: link,
|
fn s(
|
||||||
customize_open: cust,
|
link: bool,
|
||||||
cheatsheet_open: cheat,
|
cust: bool,
|
||||||
cortinilla_open: cort,
|
cheat: bool,
|
||||||
immersive_open: imm,
|
musician: bool,
|
||||||
multi_select_active: multi,
|
cort: bool,
|
||||||
queue_open: queue,
|
imm: bool,
|
||||||
};
|
multi: bool,
|
||||||
|
queue: bool,
|
||||||
|
) -> EscapeState {
|
||||||
|
EscapeState {
|
||||||
|
link_resolver_open: link,
|
||||||
|
customize_open: cust,
|
||||||
|
cheatsheet_open: cheat,
|
||||||
|
musician_modal_open: musician,
|
||||||
|
cortinilla_open: cort,
|
||||||
|
immersive_open: imm,
|
||||||
|
multi_select_active: multi,
|
||||||
|
queue_open: queue,
|
||||||
|
}
|
||||||
|
}
|
||||||
// Everything open → the link-resolver arm wins (order position 1).
|
// Everything open → the link-resolver arm wins (order position 1).
|
||||||
assert_eq!(escape_target(&s(true, true, true, true, true, true, true)), EscapeTarget::LinkResolver);
|
assert_eq!(escape_target(&s(true, true, true, true, true, true, true, true)), EscapeTarget::LinkResolver);
|
||||||
// Then, in order, each surface beats every LATER one.
|
// Then, in order, each surface beats every LATER one.
|
||||||
assert_eq!(escape_target(&s(false, true, true, true, true, true, true)), EscapeTarget::Customize);
|
assert_eq!(escape_target(&s(false, true, true, true, true, true, true, true)), EscapeTarget::Customize);
|
||||||
assert_eq!(escape_target(&s(false, false, true, true, true, true, true)), EscapeTarget::Cheatsheet);
|
assert_eq!(escape_target(&s(false, false, true, true, true, true, true, true)), EscapeTarget::Cheatsheet);
|
||||||
assert_eq!(escape_target(&s(false, false, false, true, true, true, true)), EscapeTarget::Cortinilla);
|
assert_eq!(escape_target(&s(false, false, false, true, true, true, true, true)), EscapeTarget::MusicianModal);
|
||||||
assert_eq!(escape_target(&s(false, false, false, false, true, true, true)), EscapeTarget::Immersive);
|
assert_eq!(escape_target(&s(false, false, false, false, true, true, true, true)), EscapeTarget::Cortinilla);
|
||||||
assert_eq!(escape_target(&s(false, false, false, false, false, true, true)), EscapeTarget::MultiSelect);
|
assert_eq!(escape_target(&s(false, false, false, false, false, true, true, true)), EscapeTarget::Immersive);
|
||||||
assert_eq!(escape_target(&s(false, false, false, false, false, false, true)), EscapeTarget::Queue);
|
assert_eq!(escape_target(&s(false, false, false, false, false, false, true, true)), EscapeTarget::MultiSelect);
|
||||||
assert_eq!(escape_target(&s(false, false, false, false, false, false, false)), EscapeTarget::None);
|
assert_eq!(escape_target(&s(false, false, false, false, false, false, false, true)), EscapeTarget::Queue);
|
||||||
// Multi-select beats the queue (order position 6 vs 7).
|
assert_eq!(escape_target(&s(false, false, false, false, false, false, false, false)), EscapeTarget::None);
|
||||||
assert_eq!(escape_target(&s(false, false, false, false, false, true, true)), EscapeTarget::MultiSelect);
|
// Multi-select beats the queue (order position 7 vs 8).
|
||||||
|
assert_eq!(escape_target(&s(false, false, false, false, false, false, true, true)), EscapeTarget::MultiSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The musician modal is opened FROM the immersive Track-info panel
|
||||||
|
/// (consumer #6) and paints above it at z 3200, while the immersive
|
||||||
|
/// overlay deliberately stays up underneath. So with both up, Escape must
|
||||||
|
/// take the MODAL — dismissing immersive first would leave the modal
|
||||||
|
/// floating over a view the user never asked to close.
|
||||||
|
#[test]
|
||||||
|
fn musician_modal_beats_immersive() {
|
||||||
|
let state = EscapeState {
|
||||||
|
musician_modal_open: true,
|
||||||
|
immersive_open: true,
|
||||||
|
..EscapeState::default()
|
||||||
|
};
|
||||||
|
assert_eq!(escape_target(&state), EscapeTarget::MusicianModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Capture: Escape-cancels vs conflict-keeps-recording ---------------
|
// --- Capture: Escape-cancels vs conflict-keeps-recording ---------------
|
||||||
|
|||||||
+106
-18
@@ -48,6 +48,8 @@ mod local_bridge;
|
|||||||
mod library_bridge;
|
mod library_bridge;
|
||||||
mod album_bridge;
|
mod album_bridge;
|
||||||
mod artist_bridge;
|
mod artist_bridge;
|
||||||
|
mod musician_bridge;
|
||||||
|
mod scene_bridge;
|
||||||
mod cast_bridge;
|
mod cast_bridge;
|
||||||
// MyQBZ splits across THREE singletons rather than one, because the two
|
// MyQBZ splits across THREE singletons rather than one, because the two
|
||||||
// modals are global overlays with their own lifetime: QbzMyQbz carries the
|
// modals are global overlays with their own lifetime: QbzMyQbz carries the
|
||||||
@@ -184,6 +186,8 @@ mod artist_prefs;
|
|||||||
// onto QbzArtist.artistReleasesJson (artist_bridge.rs) and records its own nav
|
// onto QbzArtist.artistReleasesJson (artist_bridge.rs) and records its own nav
|
||||||
// entry, the label_qt shape.
|
// entry, the label_qt shape.
|
||||||
mod artist_releases_qt;
|
mod artist_releases_qt;
|
||||||
|
mod artist_scene_qt;
|
||||||
|
mod musician_qt;
|
||||||
// Shared in-app toast publisher (the port of qbz-slint-common's toast.rs).
|
// Shared in-app toast publisher (the port of qbz-slint-common's toast.rs).
|
||||||
// A plain module, NOT a bridge — it publishes onto QbzShell.toastJson, and
|
// A plain module, NOT a bridge — it publishes onto QbzShell.toastJson, and
|
||||||
// `controls/QbzToast.qml` owns the auto-hide timer.
|
// `controls/QbzToast.qml` owns the auto-hide timer.
|
||||||
@@ -590,12 +594,31 @@ fn on_session_entered() {
|
|||||||
|
|
||||||
// Restore the persisted player volume so audio starts at the SAVED level
|
// Restore the persisted player volume so audio starts at the SAVED level
|
||||||
// (1:1 with qbz/src/main.rs:220-227). Without it every launch started at
|
// (1:1 with qbz/src/main.rs:220-227). Without it every launch started at
|
||||||
// 100% — on an exclusive-mode DAC that is not a cosmetic default. The poll
|
// 100% — on an exclusive-mode DAC that is not a cosmetic default.
|
||||||
// loop mirrors it onto the bar's slider from the engine, so nothing else
|
|
||||||
// has to publish it.
|
|
||||||
let restored = crate::settings_qt::read_pref_f32("volume").unwrap_or(1.0).clamp(0.0, 1.0);
|
let restored = crate::settings_qt::read_pref_f32("volume").unwrap_or(1.0).clamp(0.0, 1.0);
|
||||||
let rt = app();
|
let rt = app();
|
||||||
spawn(async move { playback_qt::set_volume(&rt, restored).await });
|
spawn(async move { playback_qt::set_volume(&rt, restored).await });
|
||||||
|
// ...and seed the UI model with the SAME value, or the engine and the
|
||||||
|
// slider disagree from the first frame.
|
||||||
|
//
|
||||||
|
// This line used to be absent, under a comment claiming "the poll loop
|
||||||
|
// mirrors it onto the bar's slider from the engine, so nothing else has to
|
||||||
|
// publish it". No such mirror exists on the local path:
|
||||||
|
// `now_playing::set_volume` has exactly two callers — the Cast echo
|
||||||
|
// (`cast_qt.rs:783`) and the QConnect PEER branch
|
||||||
|
// (`playback_qt.rs:2167`) — and neither runs in a normal local session. So
|
||||||
|
// the model kept its idle default of `volume: 1.0` (`now_playing.rs:94`,
|
||||||
|
// "no track, full volume") and every launch drew the slider at 100 % while
|
||||||
|
// the audio was correctly at the saved level. Owner-reported 2026-08-14.
|
||||||
|
//
|
||||||
|
// One call covers every surface: the NPB (`PlayerBar.qml:585`), the small
|
||||||
|
// bar, the immersive bar (`ImmersivePlayerBar.qml:282`) and the miniplayer
|
||||||
|
// all bind `QbzPlayer.npVolume`, which `now_playing::publish` writes.
|
||||||
|
// (The kiosk shell has no volume control at all — checked, not assumed.)
|
||||||
|
//
|
||||||
|
// Ordering is safe: `set_volume` only mutates the model and republishes, so
|
||||||
|
// it does not race the async engine call above.
|
||||||
|
crate::now_playing::set_volume(restored);
|
||||||
|
|
||||||
// Qobuz Connect service wiring (2026-08-01 contract §11.5), in order.
|
// Qobuz Connect service wiring (2026-08-01 contract §11.5), in order.
|
||||||
// This fn is MULTI-ENTRY (login, session restore AND start_offline all
|
// This fn is MULTI-ENTRY (login, session restore AND start_offline all
|
||||||
@@ -1830,22 +1853,26 @@ pub(crate) fn large_cycle_spectrum() {
|
|||||||
shell_bridge::ui(move |mut b| b.as_mut().set_large_spectrum_mode(mode));
|
shell_bridge::ui(move |mut b| b.as_mut().set_large_spectrum_mode(mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Artist-network row click: resolve a musician NAME to a Qobuz artist and open
|
/// Artist-network row click: resolve a musician NAME and open the right
|
||||||
/// their page. Only a CONFIRMED match navigates — a guess would drop the user on
|
/// surface for them.
|
||||||
/// the wrong artist, which is worse than the row doing nothing.
|
///
|
||||||
|
/// This used to BE the whole feature, and it implemented exactly ONE of the
|
||||||
|
/// reference's five branches: a Confirmed match with a Qobuz id navigated, and
|
||||||
|
/// every other confidence logged a line and left the user staring at a row that
|
||||||
|
/// did nothing. Since the majority of credited session musicians resolve to
|
||||||
|
/// `contextual` or `weak`, the common case was a dead click.
|
||||||
|
///
|
||||||
|
/// The five-way dispatch now lives in `musician_qt` so that ALL SIX call sites
|
||||||
|
/// — the three artist-network groups, the album-credits modal, the desktop
|
||||||
|
/// track-info modal and the immersive track-info panel — behave identically and
|
||||||
|
/// no QML anywhere branches on confidence. See that module's header for the
|
||||||
|
/// branch table and for why `Some(0)` is not a valid id.
|
||||||
|
///
|
||||||
|
/// The function survives as a forwarder because `artist_bridge.rs:210` calls
|
||||||
|
/// `crate::resolve_musician`; replacing the body is a smaller blast radius than
|
||||||
|
/// re-pointing that bridge.
|
||||||
pub(crate) fn resolve_musician(name: String, role: String) {
|
pub(crate) fn resolve_musician(name: String, role: String) {
|
||||||
let runtime = app();
|
musician_qt::resolve_and_open(name, role);
|
||||||
spawn(async move {
|
|
||||||
match runtime.core().musicbrainz_resolve_musician(&name, &role).await {
|
|
||||||
Ok(r) => match (r.confidence, r.qobuz_artist_id) {
|
|
||||||
(qbz_integrations::musicbrainz::MusicianConfidence::Confirmed, Some(id)) => {
|
|
||||||
open_artist(id.to_string());
|
|
||||||
}
|
|
||||||
_ => log::info!("[qbz-qt] musician '{name}' not confidently resolved; not navigating"),
|
|
||||||
},
|
|
||||||
Err(e) => log::warn!("[qbz-qt] musician resolve failed for '{name}': {e}"),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Artist-card overlay play (ArtistGridCard): Popular tracks with the
|
/// Artist-card overlay play (ArtistGridCard): Popular tracks with the
|
||||||
@@ -2038,6 +2065,18 @@ pub(crate) fn apply_language(code: String) {
|
|||||||
// living inside a JSON document, and LAST_DETAIL only ever latches "album"
|
// living inside a JSON document, and LAST_DETAIL only ever latches "album"
|
||||||
// or "artist" — it cannot reach this page. No-op when nothing is open.
|
// or "artist" — it cannot reach this page. No-op when nothing is open.
|
||||||
artist_releases_qt::republish();
|
artist_releases_qt::republish();
|
||||||
|
// Same class again: the musician page publishes a Rust-translated error
|
||||||
|
// line INSIDE its JSON document (`musician_qt.rs:679,781`), and `trRev`
|
||||||
|
// cannot reach a string that travels as data. LAST_DETAIL only ever
|
||||||
|
// latches "album" or "artist", so this page is unreachable through the
|
||||||
|
// match below. No-op when nothing is open.
|
||||||
|
//
|
||||||
|
// The Artist Scene deliberately has NO counterpart here: it translates
|
||||||
|
// nothing in Rust — every string on that view is a QML
|
||||||
|
// `QbzSession.tr(..., trRev)`, including the discovery phase labels and the
|
||||||
|
// card's (always empty) subtitle — so it re-translates by itself and a
|
||||||
|
// republish would be dead code.
|
||||||
|
musician_qt::republish();
|
||||||
let (view, id) = LAST_DETAIL.lock().unwrap().clone();
|
let (view, id) = LAST_DETAIL.lock().unwrap().clone();
|
||||||
if !id.is_empty() {
|
if !id.is_empty() {
|
||||||
match view.as_str() {
|
match view.as_str() {
|
||||||
@@ -2062,6 +2101,26 @@ fn republish_album(album_id: String) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Republish whichever detail page is open, if it is the artist page.
|
||||||
|
///
|
||||||
|
/// Exists for the MusicBrainz toggle. Every MB-derived field on the artist
|
||||||
|
/// document — `mbAvailable`, the whole Origin block, the relationship groups,
|
||||||
|
/// and `origin.locationClickable`, which is the gate on BOTH Artist Scene
|
||||||
|
/// doors — is baked at document-build time. Flipping the setting updates only
|
||||||
|
/// the core client, so without this a page the user navigates back to keeps
|
||||||
|
/// offering an affordance the client can no longer serve, and the discovery
|
||||||
|
/// call would run against a disabled MusicBrainz and report a false "no
|
||||||
|
/// artists found" (the shared core swallows the disabled-client error).
|
||||||
|
///
|
||||||
|
/// No-op when the open detail is not an artist, and `republish_artist` is
|
||||||
|
/// itself a no-op offline.
|
||||||
|
pub(crate) fn republish_open_artist() {
|
||||||
|
let (view, id) = LAST_DETAIL.lock().unwrap().clone();
|
||||||
|
if view == "artist" && !id.is_empty() {
|
||||||
|
republish_artist(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn republish_artist(artist_id: String) {
|
fn republish_artist(artist_id: String) {
|
||||||
if offline_fwd::engine().status().is_offline() {
|
if offline_fwd::engine().status().is_offline() {
|
||||||
return;
|
return;
|
||||||
@@ -2717,6 +2776,35 @@ fn main() {
|
|||||||
} else {
|
} else {
|
||||||
log::warn!("[qbz-qt] runtime has no visualizer tap; the Large dock band stays empty");
|
log::warn!("[qbz-qt] runtime has no visualizer tap; the Large dock band stays empty");
|
||||||
}
|
}
|
||||||
|
// MusicBrainz cache — a SQLite store at
|
||||||
|
// <data-dir>/qbz/cache/musicbrainz_cache.db so artist metadata,
|
||||||
|
// relationships and scene discovery persist across sessions.
|
||||||
|
//
|
||||||
|
// THIS WAS MISSING ENTIRELY. `set_musicbrainz_cache` had exactly one
|
||||||
|
// caller in the tree — the Slint binary (crates/qbz/src/main.rs:9011) —
|
||||||
|
// so every MusicBrainz call this build made went uncached, on every
|
||||||
|
// artist page, against an API with a strict rate limit. It is the same
|
||||||
|
// path and the same filename the reference uses, so a user switching
|
||||||
|
// between the two builds keeps one warm cache.
|
||||||
|
//
|
||||||
|
// Failure to open only degrades to direct network calls: the core's
|
||||||
|
// methods skip the cache when none is set.
|
||||||
|
if let Some(data_dir) = dirs::data_dir() {
|
||||||
|
let cache_dir = data_dir.join("qbz").join("cache");
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&cache_dir) {
|
||||||
|
log::warn!("[qbz-qt] MB cache dir create failed: {e}");
|
||||||
|
} else {
|
||||||
|
let db_path = cache_dir.join("musicbrainz_cache.db");
|
||||||
|
match qbz_integrations::musicbrainz::cache::MusicBrainzCache::new(&db_path) {
|
||||||
|
Ok(cache) => {
|
||||||
|
runtime.core().set_musicbrainz_cache(cache);
|
||||||
|
log::info!("[qbz-qt] MB cache opened at {db_path:?}");
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("[qbz-qt] MB cache open failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _ = APP.set(runtime);
|
let _ = APP.set(runtime);
|
||||||
|
|
||||||
// BOTH must run before QGuiApplication: the renderer envs are read when
|
// BOTH must run before QGuiApplication: the renderer envs are read when
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! QbzMusician — the musician domain bridge (contract §13.2).
|
||||||
|
//!
|
||||||
|
//! Two documents and a publish counter, five invokables. The controller is
|
||||||
|
//! `musician_qt.rs`; this file is glue and nothing else, exactly like
|
||||||
|
//! `artist_bridge.rs`.
|
||||||
|
//!
|
||||||
|
//! The module is `qbz_musician_bridge`, not `qbz_musician`: bridge modules are
|
||||||
|
//! never named after a workspace crate (the `qbz_library` collision is the
|
||||||
|
//! documented precedent). The QML type name comes from the QObject
|
||||||
|
//! (`QbzMusician`), not from the module.
|
||||||
|
//!
|
||||||
|
//! # A new bridge is SEVEN artefacts, not one file
|
||||||
|
//!
|
||||||
|
//! This file · its `build.rs` `rust_files` entry · the `main.rs` `mod` line ·
|
||||||
|
//! the QML singleton registration (which IS the build.rs entry — `#[qml_element]
|
||||||
|
//! #[qml_singleton]` only takes effect for files listed there) · `Main.qml`'s
|
||||||
|
//! `boot()` call · `ContentRouter.qml`'s `musician` arm · the global modal
|
||||||
|
//! mount. Five of those are integration-owned files; this lane ships the two it
|
||||||
|
//! owns and hands the rest over as snippets. A bridge missing from `build.rs`
|
||||||
|
//! does not fail the build: its QML singleton simply does not exist, and every
|
||||||
|
//! `QbzMusician.foo()` becomes a runtime ReferenceError that `cargo check`
|
||||||
|
//! cannot see. `qml_singleton_xref.py` is the gate that catches it.
|
||||||
|
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use cxx_qt::CxxQtThread;
|
||||||
|
use cxx_qt::Threading as _;
|
||||||
|
use cxx_qt_lib::QString;
|
||||||
|
|
||||||
|
#[cxx_qt::bridge]
|
||||||
|
pub mod qbz_musician_bridge {
|
||||||
|
extern "C++" {
|
||||||
|
include!("cxx-qt-lib/qstring.h");
|
||||||
|
type QString = cxx_qt_lib::QString;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[auto_cxx_name]
|
||||||
|
extern "RustQt" {
|
||||||
|
#[qobject]
|
||||||
|
#[qml_element]
|
||||||
|
#[qml_singleton]
|
||||||
|
/// The MusicianPage document (route `musician`) — state, header,
|
||||||
|
/// bands, the paged "Appears On" grid. Shape frozen in contract
|
||||||
|
/// §13.2.
|
||||||
|
#[qproperty(QString, musician_json)]
|
||||||
|
/// The MusicianModal document. `""` — NOT `"{}"` — is the closed
|
||||||
|
/// state, so the QML host tests the raw string before parsing it.
|
||||||
|
/// Everything else on the bridge defaults to `"{}"` precisely so a
|
||||||
|
/// `JSON.parse` on frame one cannot throw; this one is the deliberate
|
||||||
|
/// exception and the host must honour it.
|
||||||
|
#[qproperty(QString, modal_json)]
|
||||||
|
/// Publish counter, bumped by every write to either document above.
|
||||||
|
/// QML re-parses on the QString change already; this exists so a view
|
||||||
|
/// can force a re-read when the text happens to be byte-identical
|
||||||
|
/// (resolving the same musician twice), the same trick
|
||||||
|
/// `QbzSession.trRev` plays.
|
||||||
|
#[qproperty(i32, musician_rev)]
|
||||||
|
|
||||||
|
type QbzMusician = super::QbzMusicianRust;
|
||||||
|
|
||||||
|
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||||
|
/// singleton; only QbzSession.boot also fires crate::on_boot).
|
||||||
|
#[qinvokable]
|
||||||
|
fn boot(self: Pin<&mut QbzMusician>);
|
||||||
|
|
||||||
|
/// THE DISPATCHER — the one entry all six musician call sites use
|
||||||
|
/// (artist network members / member-of / collaborators, album-credits
|
||||||
|
/// performer, track-info performer, immersive track-info performer).
|
||||||
|
///
|
||||||
|
/// Resolve the NAME (musicians carry no catalog id), then open the
|
||||||
|
/// artist page, this page, or the modal according to confidence. The
|
||||||
|
/// branch table and its two traps live on `musician_qt.rs`. **QML never
|
||||||
|
/// branches on confidence** — a QML copy of that switch would be six
|
||||||
|
/// copies of it.
|
||||||
|
///
|
||||||
|
/// `role` is the caller's own: `roles[0] || "Band Member"` /
|
||||||
|
/// literal `"Band"` / `roles[0] || "Collaborator"` /
|
||||||
|
/// `roles[0] || "Performer"`. It is echoed verbatim into every
|
||||||
|
/// appearance card's role badge, so it must be the per-row instrument
|
||||||
|
/// where one exists — pass `modelData.role`, never the per-group key.
|
||||||
|
///
|
||||||
|
/// `#[auto_cxx_name]` makes the QML name
|
||||||
|
/// `QbzMusician.resolveAndOpen(name, role)`.
|
||||||
|
#[qinvokable]
|
||||||
|
fn resolve_and_open(self: Pin<&mut QbzMusician>, name: QString, role: QString);
|
||||||
|
|
||||||
|
/// The page's "Load more" button. Every guard lives in Rust so a burst
|
||||||
|
/// of clicks cannot queue two pages.
|
||||||
|
#[qinvokable]
|
||||||
|
fn load_more(self: Pin<&mut QbzMusician>);
|
||||||
|
|
||||||
|
/// An "Appears On" card — straight through to the shell's album
|
||||||
|
/// router, which records its own nav entry.
|
||||||
|
#[qinvokable]
|
||||||
|
fn open_album(self: Pin<&mut QbzMusician>, album_id: QString);
|
||||||
|
|
||||||
|
/// A "Bands & Projects" chip. Takes the group's MBID; the controller
|
||||||
|
/// looks the NAME up in the open document and runs it back through the
|
||||||
|
/// dispatcher, because no MBID -> Qobuz resolver exists anywhere in the
|
||||||
|
/// tree.
|
||||||
|
#[qinvokable]
|
||||||
|
fn open_band(self: Pin<&mut QbzMusician>, mbid: QString);
|
||||||
|
|
||||||
|
/// All four modal dismissal paths (Escape, the X, the backdrop, a
|
||||||
|
/// parent modal closing) land here. Clears `modalJson` only — the page
|
||||||
|
/// document is left intact.
|
||||||
|
#[qinvokable]
|
||||||
|
fn close_modal(self: Pin<&mut QbzMusician>);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl cxx_qt::Threading for QbzMusician {}
|
||||||
|
}
|
||||||
|
|
||||||
|
use qbz_musician_bridge::QbzMusician;
|
||||||
|
|
||||||
|
/// Rust side of the musician bridge (plain storage, phase-1 pattern).
|
||||||
|
pub struct QbzMusicianRust {
|
||||||
|
musician_json: QString,
|
||||||
|
modal_json: QString,
|
||||||
|
musician_rev: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for QbzMusicianRust {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// "{}" and not "": the page JSON.parses this straight, and an empty
|
||||||
|
// string throws into the catch arm on the very first frame.
|
||||||
|
musician_json: QString::from("{}"),
|
||||||
|
// "" IS the closed state for the modal — see the property doc.
|
||||||
|
modal_json: QString::from(""),
|
||||||
|
musician_rev: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The UI hop (bridges/mod.rs pattern)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static QT_THREAD: OnceLock<CxxQtThread<QbzMusician>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Queue a musician-bridge mutation onto the Qt event loop (no-op before boot
|
||||||
|
/// registers the thread).
|
||||||
|
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzMusician>) + Send + 'static) {
|
||||||
|
if let Some(thread) = QT_THREAD.get() {
|
||||||
|
let _ = thread.queue(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl qbz_musician_bridge::QbzMusician {
|
||||||
|
pub fn boot(self: Pin<&mut Self>) {
|
||||||
|
if QT_THREAD.set(self.qt_thread()).is_err() {
|
||||||
|
log::warn!("[qbz-qt] musician Qt thread already registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-line delegates into `musician_qt`, with no `crate::` forwarder in
|
||||||
|
// main.rs: that indirection exists for paths that mutate main.rs's own state
|
||||||
|
// (LAST_DETAIL, the nav latch). This domain owns its state in its own module
|
||||||
|
// and records its own nav entry, so there is nothing for main.rs to
|
||||||
|
// coordinate. `open_album` is the one exception and it delegates onward to
|
||||||
|
// main.rs's router itself.
|
||||||
|
|
||||||
|
pub fn resolve_and_open(self: Pin<&mut Self>, name: QString, role: QString) {
|
||||||
|
crate::musician_qt::resolve_and_open(name.to_string(), role.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_more(self: Pin<&mut Self>) {
|
||||||
|
crate::musician_qt::load_more();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_album(self: Pin<&mut Self>, album_id: QString) {
|
||||||
|
crate::musician_qt::open_album(album_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_band(self: Pin<&mut Self>, mbid: QString) {
|
||||||
|
crate::musician_qt::open_band(mbid.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_modal(self: Pin<&mut Self>) {
|
||||||
|
crate::musician_qt::close_modal();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,800 @@
|
|||||||
|
//! Musician surfaces — the resolver/dispatcher plus the two documents it
|
||||||
|
//! feeds: the full MusicianPage (`musicianJson`, route `musician`) and the
|
||||||
|
//! global MusicianModal (`modalJson`).
|
||||||
|
//!
|
||||||
|
//! Contract: `qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/`
|
||||||
|
//! `00-CONTRACT.md` §2.2 (the dispatcher), §5.3 (bands), §5.4/§5.5 (the two
|
||||||
|
//! Tauri data facts that look like bugs) and §13.2 (the frozen document
|
||||||
|
//! shapes). Visual reference is Tauri's `MusicianPageView.svelte` +
|
||||||
|
//! `MusicianModal.svelte`; `crates/qbz/src/musician.rs` supplied the shared-core
|
||||||
|
//! CALL sequence and nothing else (its `MusicianState.bands` is never written —
|
||||||
|
//! Slint defect S1).
|
||||||
|
//!
|
||||||
|
//! # THE DISPATCHER LIVES HERE, NOT IN QML
|
||||||
|
//!
|
||||||
|
//! Six call sites reach a musician by NAME (artist network members / member-of /
|
||||||
|
//! collaborators, album-credits performer, track-info performer, immersive
|
||||||
|
//! track-info performer). All six call ONE invokable — `resolveAndOpen` — so
|
||||||
|
//! all six behave identically and **QML never branches on confidence**. Tauri
|
||||||
|
//! branches in `+page.svelte:2178-2220`; a QML copy of that switch would be six
|
||||||
|
//! copies of it.
|
||||||
|
//!
|
||||||
|
//! The five-way dispatch, with the two traps that draft 1 of the contract
|
||||||
|
//! walked into:
|
||||||
|
//!
|
||||||
|
//! | resolve result | surface |
|
||||||
|
//! |---|---|
|
||||||
|
//! | `Confirmed` + `qobuz_artist_id > 0` | the Qobuz artist page |
|
||||||
|
//! | `Confirmed` + id absent-or-ZERO | the modal |
|
||||||
|
//! | `Contextual` | this page (route `musician`) |
|
||||||
|
//! | `Weak` / `None` / anything else | the modal |
|
||||||
|
//! | the resolve call failed | error toast + modal, synthetic `none` |
|
||||||
|
//!
|
||||||
|
//! **Trap 1 — `Some(0)` is not an id.** `qobuz_artist_id` is
|
||||||
|
//! `i64::try_from(artist.id).ok()` (`qbz-core/src/core.rs:2707`) over
|
||||||
|
//! `Artist.id: u64` declared `#[serde(default)]`
|
||||||
|
//! (`qbz-models/src/types.rs:545-546`), so an absent id in the Qobuz search
|
||||||
|
//! payload arrives as `Some(0)`. JS reads `0` as falsy and opens the modal;
|
||||||
|
//! a Rust `(Confirmed, Some(id))` arm would navigate to artist "0" and land the
|
||||||
|
//! user on a broken page. Hence the `if id > 0` guard, which is the ONLY reason
|
||||||
|
//! the modal's `confirmed` copy is reachable at all.
|
||||||
|
//!
|
||||||
|
//! **Trap 2 — the catch-all arm is a real branch.** Tauri's switch ends
|
||||||
|
//! `case 'weak': case 'none': default:` (`+page.svelte:2201-2206`), so any
|
||||||
|
//! confidence the frontend does not recognise opens the modal rather than
|
||||||
|
//! falling through silently. The `_` arm below is that `default:`.
|
||||||
|
//!
|
||||||
|
//! # What is deliberately NOT "fixed"
|
||||||
|
//!
|
||||||
|
//! * `role_on_album` is `role.clone()` on every row (`core.rs:2761`), so every
|
||||||
|
//! card's badge shows the same string — the one the user clicked. Honest:
|
||||||
|
//! per-album credit data does not exist. Contract §5.4.
|
||||||
|
//! * `releaseDate` carries the FULL value the backend returns — a complete ISO
|
||||||
|
//! date like `"1994-09-27"`, not a year. Both backends pass
|
||||||
|
//! `release_date_original` deliberately and Tauri renders it raw
|
||||||
|
//! (`MusicianPageView.svelte:183-185`). The Qt field is named `releaseDate`
|
||||||
|
//! rather than `year` so the NAME stops lying; the VALUE is 1:1.
|
||||||
|
//! * "Appears On" is a plain Qobuz album search on the musician's NAME
|
||||||
|
//! (`core.rs:2745-2768`) — nothing verifies the musician appears on any
|
||||||
|
//! returned album. Tauri is identical. Owner ruling R6: ship as-is.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use cxx_qt_lib::QString;
|
||||||
|
use qbz_integrations::musicbrainz::{MusicianConfidence, ResolvedMusician};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Appearances page size — Tauri's `ITEMS_PER_PAGE`
|
||||||
|
/// (`MusicianPageView.svelte:37`). NOT Slint's 30, and not the scene view's
|
||||||
|
/// 100: three different page sizes in three different places, all deliberate.
|
||||||
|
const PAGE_SIZE: u32 = 20;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Documents (contract §13.2 — FROZEN; a QML lane is written against these)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One "Bands & Projects" chip. The MBID rides along because the chip
|
||||||
|
/// NAVIGATES (§5.3) — Tauri's only `console.log`s a TODO.
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct BandRow {
|
||||||
|
mbid: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One "Appears On" card.
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct AppearanceRow {
|
||||||
|
#[serde(rename = "albumId")]
|
||||||
|
album_id: String,
|
||||||
|
title: String,
|
||||||
|
#[serde(rename = "artistName")]
|
||||||
|
artist_name: String,
|
||||||
|
/// FULL ISO date (see the file header). Empty when the backend had none.
|
||||||
|
#[serde(rename = "releaseDate")]
|
||||||
|
release_date: String,
|
||||||
|
#[serde(rename = "roleOnAlbum")]
|
||||||
|
role_on_album: String,
|
||||||
|
/// The REMOTE cover url, kept because it is the key
|
||||||
|
/// `QbzShell.sidebarArtworkWindow` replies on.
|
||||||
|
#[serde(rename = "artworkUrl")]
|
||||||
|
artwork_url: String,
|
||||||
|
/// Rust-resolved `file://` path for the same cover, "" until it is on
|
||||||
|
/// disk. ADDITIVE to the frozen shape: a QML delegate that binds
|
||||||
|
/// `artworkUrl` straight into an `Image.source` draws nothing (the cache is
|
||||||
|
/// a Rust-side concern — `artist_releases_qt.rs:37-47` documents the same
|
||||||
|
/// trap), and the two-pass fill below is what makes covers appear without
|
||||||
|
/// any QML artwork plumbing at all.
|
||||||
|
#[serde(rename = "artPath")]
|
||||||
|
art_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `musicianJson` — the page.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MusicianDoc<'a> {
|
||||||
|
/// `loading` | `error` | `ready`. Tauri's three page states
|
||||||
|
/// (`MusicianPageView.svelte:143-155`); there is no empty state distinct
|
||||||
|
/// from `ready` with an empty grid, which is what its Disc-icon block is.
|
||||||
|
state: &'a str,
|
||||||
|
name: &'a str,
|
||||||
|
role: &'a str,
|
||||||
|
confidence: &'a str,
|
||||||
|
error: &'a str,
|
||||||
|
total: usize,
|
||||||
|
#[serde(rename = "hasMore")]
|
||||||
|
has_more: bool,
|
||||||
|
#[serde(rename = "loadingMore")]
|
||||||
|
loading_more: bool,
|
||||||
|
/// `[]` means the section is ABSENT — no spinner, no empty frame (§5.3).
|
||||||
|
bands: &'a [BandRow],
|
||||||
|
appearances: &'a [AppearanceRow],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `modalJson` — the global overlay. `""` (not `{}`) is the CLOSED state, so
|
||||||
|
/// the QML host tests the raw string before parsing it.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ModalDoc<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
role: &'a str,
|
||||||
|
confidence: &'a str,
|
||||||
|
/// Tauri's modal never renders this (verified: the identifier appears
|
||||||
|
/// nowhere in `MusicianModal.svelte`); it is carried because the CTA and
|
||||||
|
/// the explanatory copy are chosen from confidence + this count.
|
||||||
|
#[serde(rename = "appearsOnCount")]
|
||||||
|
appears_on_count: usize,
|
||||||
|
bands: &'a [BandRow],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The PAGE's state. ONE musician at a time — opening another resets it
|
||||||
|
/// wholesale.
|
||||||
|
///
|
||||||
|
/// The modal keeps its own identity below rather than sharing this one, and
|
||||||
|
/// that separation is load-bearing: the modal can open FROM the page (a band
|
||||||
|
/// chip whose group resolves weak), and a shared struct would rewrite the
|
||||||
|
/// page's header and wipe its grid underneath the overlay, leaving the wrong
|
||||||
|
/// musician on screen the moment the modal closes.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
/// Bumped by every open. A page of appearances that lands for a musician
|
||||||
|
/// the user has already left is DROPPED rather than appended — the
|
||||||
|
/// `artist_releases_qt.rs:74-78` guard, and it matters for the same reason:
|
||||||
|
/// the nav stack stores no payload (`nav_qt.rs:9-23`), so Back/Forward
|
||||||
|
/// re-mounts this view against whatever document is still on the bridge.
|
||||||
|
generation: u64,
|
||||||
|
name: String,
|
||||||
|
/// The role as the CALLER supplied it. Also the API argument for every
|
||||||
|
/// appearances page, which is why it is kept verbatim and not prettified:
|
||||||
|
/// `role_on_album` echoes it back on every row.
|
||||||
|
role: String,
|
||||||
|
confidence: String,
|
||||||
|
/// MusicBrainz id of the resolved musician, `""` when MB is off or the
|
||||||
|
/// resolve found nothing. Gates the whole bands section.
|
||||||
|
mbid: String,
|
||||||
|
/// `loading` | `error` | `ready`.
|
||||||
|
page_state: String,
|
||||||
|
error: String,
|
||||||
|
bands: Vec<BandRow>,
|
||||||
|
appearances: Vec<AppearanceRow>,
|
||||||
|
total: usize,
|
||||||
|
/// Rows the SERVER has handed over — the offset of the next page.
|
||||||
|
/// Deliberately NOT `appearances.len()`: the dedupe below makes the
|
||||||
|
/// rendered count smaller than the fetched one, and feeding the smaller
|
||||||
|
/// number back re-requests rows already seen and skips the tail.
|
||||||
|
offset: u32,
|
||||||
|
has_more: bool,
|
||||||
|
loading_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
static MUSICIAN: Mutex<Option<State>> = Mutex::new(None);
|
||||||
|
|
||||||
|
fn with_state<R>(f: impl FnOnce(&mut State) -> R) -> R {
|
||||||
|
let mut guard = MUSICIAN.lock().unwrap();
|
||||||
|
f(guard.get_or_insert_with(State::default))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MODAL's identity, independent of the page (see `State`). `None` is the
|
||||||
|
/// closed state and publishes `""`.
|
||||||
|
struct Modal {
|
||||||
|
name: String,
|
||||||
|
role: String,
|
||||||
|
confidence: String,
|
||||||
|
mbid: String,
|
||||||
|
appears_on_count: usize,
|
||||||
|
bands: Vec<BandRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static MODAL: Mutex<Option<Modal>> = Mutex::new(None);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Publish
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// `musicianRev` is bumped inline in both publishers rather than through a
|
||||||
|
// helper: the Pin<&mut QbzMusician> receiver cannot cross a plain fn boundary
|
||||||
|
// without naming the bridge's generated type, and two lines twice is cheaper
|
||||||
|
// than that import. QML re-parses on the QString change already — the counter
|
||||||
|
// exists so a view can force a re-read of a document whose text happens to be
|
||||||
|
// identical (a second resolve of the same musician), the same trick
|
||||||
|
// `QbzSession.trRev` plays.
|
||||||
|
|
||||||
|
fn publish_page() {
|
||||||
|
let json = with_state(|s| {
|
||||||
|
let doc = MusicianDoc {
|
||||||
|
state: &s.page_state,
|
||||||
|
name: &s.name,
|
||||||
|
role: &s.role,
|
||||||
|
confidence: &s.confidence,
|
||||||
|
error: &s.error,
|
||||||
|
total: s.total,
|
||||||
|
has_more: s.has_more,
|
||||||
|
loading_more: s.loading_more,
|
||||||
|
bands: &s.bands,
|
||||||
|
appearances: &s.appearances,
|
||||||
|
};
|
||||||
|
serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
});
|
||||||
|
crate::musician_bridge::ui(move |mut b| {
|
||||||
|
b.as_mut().set_musician_json(QString::from(json.as_str()));
|
||||||
|
let next = b.as_ref().musician_rev() + 1;
|
||||||
|
b.as_mut().set_musician_rev(next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes the modal document, or `""` when it is closed.
|
||||||
|
fn publish_modal() {
|
||||||
|
let json = {
|
||||||
|
let guard = MODAL.lock().unwrap();
|
||||||
|
match guard.as_ref() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(m) => {
|
||||||
|
let doc = ModalDoc {
|
||||||
|
name: &m.name,
|
||||||
|
role: &m.role,
|
||||||
|
confidence: &m.confidence,
|
||||||
|
appears_on_count: m.appears_on_count,
|
||||||
|
bands: &m.bands,
|
||||||
|
};
|
||||||
|
serde_json::to_string(&doc).unwrap_or_else(|_| String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
crate::musician_bridge::ui(move |mut b| {
|
||||||
|
b.as_mut().set_modal_json(QString::from(json.as_str()));
|
||||||
|
let next = b.as_ref().musician_rev() + 1;
|
||||||
|
b.as_mut().set_musician_rev(next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The dispatcher (contract §2.2) — the ONLY entry the six consumers call
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn confidence_label(c: MusicianConfidence) -> &'static str {
|
||||||
|
match c {
|
||||||
|
MusicianConfidence::Confirmed => "confirmed",
|
||||||
|
MusicianConfidence::Contextual => "contextual",
|
||||||
|
MusicianConfidence::Weak => "weak",
|
||||||
|
MusicianConfidence::None => "none",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a musician NAME (they carry no catalog id) and open whichever
|
||||||
|
/// surface the confidence earns. See the file header for the branch table.
|
||||||
|
///
|
||||||
|
/// The "Looking up {}..." toast fires BEFORE the call, exactly as Tauri does
|
||||||
|
/// (`+page.svelte:2179`) — the resolve is two sequential network round trips
|
||||||
|
/// (MB resolve + a Qobuz artist search, plus an album search on the miss path),
|
||||||
|
/// so without it the click looks dead for a second or more.
|
||||||
|
pub fn resolve_and_open(name: String, role: String) {
|
||||||
|
let name = name.trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
// No name means the caller has no performer row on screen, not that
|
||||||
|
// something failed — the `artist_releases_qt::open` empty-id rule.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Every branch of this dispatcher needs the network: the resolve itself,
|
||||||
|
// the appearances search, the artist page. A dead click with no explanation
|
||||||
|
// is worse than the toast.
|
||||||
|
if crate::offline_fwd::engine().status().is_offline() {
|
||||||
|
crate::toast_qt::warning(qbz_i18n::t("You're offline"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
crate::toast_qt::info(qbz_i18n::t_args("Looking up {}...", &[&name]));
|
||||||
|
|
||||||
|
let runtime = crate::app();
|
||||||
|
crate::spawn(async move {
|
||||||
|
match runtime
|
||||||
|
.core()
|
||||||
|
.musicbrainz_resolve_musician(&name, &role)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resolved) => dispatch(resolved),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[qbz-qt] musician resolve failed for {name:?}: {e}");
|
||||||
|
// Toast AND modal, 1:1 with Tauri's catch
|
||||||
|
// (`+page.svelte:2208-2219`): the modal is synthesised so the
|
||||||
|
// user still gets the name, the role and an explanation rather
|
||||||
|
// than a toast that vanishes in five seconds.
|
||||||
|
crate::toast_qt::error(qbz_i18n::t("Failed to look up musician"));
|
||||||
|
// `ResolvedMusician::empty` IS Tauri's synthetic literal
|
||||||
|
// `{confidence:'none', bands:[], appears_on_count:0}`.
|
||||||
|
open_modal(ResolvedMusician::empty(name, role));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch(resolved: ResolvedMusician) {
|
||||||
|
match (resolved.confidence, resolved.qobuz_artist_id) {
|
||||||
|
// `id > 0` is load-bearing — see trap 1 in the file header.
|
||||||
|
(MusicianConfidence::Confirmed, Some(id)) if id > 0 => {
|
||||||
|
log::info!(
|
||||||
|
"[qbz-qt] musician {:?} confirmed -> artist {id}",
|
||||||
|
resolved.name
|
||||||
|
);
|
||||||
|
crate::open_artist(id.to_string());
|
||||||
|
}
|
||||||
|
(MusicianConfidence::Contextual, _) => open_page(resolved),
|
||||||
|
// Confirmed-with-no-id, Weak, None, and any confidence added upstream
|
||||||
|
// later — Tauri's `default:` arm.
|
||||||
|
_ => open_modal(resolved),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route `musician`. Seeds the header from the resolve, flips the page into its
|
||||||
|
/// loading state, records the nav entry, THEN fetches — the order
|
||||||
|
/// `artist_releases_qt::open` uses, so the view mounts already spinning instead
|
||||||
|
/// of flashing an empty grid.
|
||||||
|
fn open_page(resolved: ResolvedMusician) {
|
||||||
|
let mbid = resolved.mbid.clone().unwrap_or_default();
|
||||||
|
let generation = reset(&resolved);
|
||||||
|
// Navigating to the page ends the modal, whichever route got us here (a
|
||||||
|
// band chip inside it is the live case).
|
||||||
|
*MODAL.lock().unwrap() = None;
|
||||||
|
// Two-file route contract (`nav_qt.rs:9-16`): this records the id,
|
||||||
|
// `ContentRouter.qml` mounts it. A missing arm is a BLANK pane logged
|
||||||
|
// nowhere. Not in `VIEW_TO_PREF`, so it never becomes `last_view` — a
|
||||||
|
// restore onto this route would have no name to resolve.
|
||||||
|
crate::nav_qt::record("musician");
|
||||||
|
publish_page();
|
||||||
|
publish_modal();
|
||||||
|
fetch_appearances(generation, resolved.name, resolved.role, 0, true);
|
||||||
|
fetch_bands(mbid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The global overlay (ADR-009, z >= 3000). No route, no nav entry: it is a
|
||||||
|
/// published document, mounted outside the view chain.
|
||||||
|
///
|
||||||
|
/// The page document is deliberately left ALONE — see `State`. A modal opened
|
||||||
|
/// over a loaded musician page must not erase what is behind it.
|
||||||
|
fn open_modal(resolved: ResolvedMusician) {
|
||||||
|
let mbid = resolved.mbid.clone().unwrap_or_default();
|
||||||
|
*MODAL.lock().unwrap() = Some(Modal {
|
||||||
|
name: resolved.name,
|
||||||
|
role: resolved.role,
|
||||||
|
confidence: confidence_label(resolved.confidence).to_string(),
|
||||||
|
// `resolved.bands` is `Vec::new()` on EVERY path of EVERY backend
|
||||||
|
// (`core.rs:2714`,`:2736`) — the "Known for" list has never rendered
|
||||||
|
// anywhere. Seeded from the relationship memo and filled by
|
||||||
|
// `fetch_bands`. Contract DV-2.
|
||||||
|
bands: cached_bands(&mbid).unwrap_or_default(),
|
||||||
|
mbid: mbid.clone(),
|
||||||
|
appears_on_count: resolved.appears_on_count,
|
||||||
|
});
|
||||||
|
publish_modal();
|
||||||
|
fetch_bands(mbid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed the PAGE. Returns the new generation, which every in-flight fetch is
|
||||||
|
/// checked against.
|
||||||
|
fn reset(resolved: &ResolvedMusician) -> u64 {
|
||||||
|
with_state(|s| {
|
||||||
|
s.generation = s.generation.wrapping_add(1);
|
||||||
|
s.name = resolved.name.clone();
|
||||||
|
s.role = resolved.role.clone();
|
||||||
|
s.confidence = confidence_label(resolved.confidence).to_string();
|
||||||
|
let mbid = resolved.mbid.clone().unwrap_or_default();
|
||||||
|
s.page_state = "loading".to_string();
|
||||||
|
s.error.clear();
|
||||||
|
// See the DV-2 note in `open_modal` — same reason, same seed.
|
||||||
|
s.bands = cached_bands(&mbid).unwrap_or_default();
|
||||||
|
s.mbid = mbid;
|
||||||
|
s.appearances.clear();
|
||||||
|
s.total = 0;
|
||||||
|
s.offset = 0;
|
||||||
|
s.has_more = false;
|
||||||
|
s.loading_more = false;
|
||||||
|
s.generation
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bands & Projects (contract §5.3 / DV-2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Completed relationship lookups, keyed by MBID. `qbz-core`'s own 7-day
|
||||||
|
/// SQLite cache only exists when the MB cache is installed, which the Qt build
|
||||||
|
/// does not do yet (contract B4, integration-owned), so without this memo a
|
||||||
|
/// user bouncing between two sidemen re-fetches both every time.
|
||||||
|
static BANDS_CACHE: Mutex<Option<HashMap<String, Vec<BandRow>>>> = Mutex::new(None);
|
||||||
|
/// MBIDs with a request in the air. There is NO in-flight registry in the cache
|
||||||
|
/// layer — only a completed-result cache (`core.rs:2210-2226`) — so this module
|
||||||
|
/// owns the coalescing or it fires duplicate requests for the same MBID.
|
||||||
|
static BANDS_INFLIGHT: Mutex<Option<HashSet<String>>> = Mutex::new(None);
|
||||||
|
|
||||||
|
fn cached_bands(mbid: &str) -> Option<Vec<BandRow>> {
|
||||||
|
if mbid.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let guard = BANDS_CACHE.lock().unwrap();
|
||||||
|
guard.as_ref()?.get(mbid).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_bands(mbid: &str, bands: &[BandRow]) {
|
||||||
|
let mut guard = BANDS_CACHE.lock().unwrap();
|
||||||
|
guard
|
||||||
|
.get_or_insert_with(HashMap::new)
|
||||||
|
.insert(mbid.to_string(), bands.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when THIS caller owns the request; `false` when one is already in the
|
||||||
|
/// air for the same MBID.
|
||||||
|
fn claim_inflight(mbid: &str) -> bool {
|
||||||
|
let mut guard = BANDS_INFLIGHT.lock().unwrap();
|
||||||
|
guard.get_or_insert_with(HashSet::new).insert(mbid.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_inflight(mbid: &str) {
|
||||||
|
let mut guard = BANDS_INFLIGHT.lock().unwrap();
|
||||||
|
if let Some(set) = guard.as_mut() {
|
||||||
|
set.remove(mbid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the groups this musician is a member of.
|
||||||
|
///
|
||||||
|
/// The state table (§5.3), and every arm of it is a SILENT one:
|
||||||
|
/// no MBID -> the section is absent; the fetch failing -> the section is
|
||||||
|
/// absent, logged only. An optional block never gets an error frame, and it
|
||||||
|
/// never gets a spinner either — it appears when it has content or not at all.
|
||||||
|
fn fetch_bands(mbid: String) {
|
||||||
|
if mbid.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(cached) = cached_bands(&mbid) {
|
||||||
|
apply_bands(&mbid, cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !claim_inflight(&mbid) {
|
||||||
|
// Someone else is fetching this exact MBID; their `apply_bands` keys on
|
||||||
|
// the MBID (not on a generation), so it will land on whatever surface
|
||||||
|
// is open by then — including this one.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let runtime = crate::app();
|
||||||
|
crate::spawn(async move {
|
||||||
|
let result = runtime
|
||||||
|
.core()
|
||||||
|
.musicbrainz_get_artist_relationships(&mbid)
|
||||||
|
.await;
|
||||||
|
release_inflight(&mbid);
|
||||||
|
match result {
|
||||||
|
Ok(rels) => {
|
||||||
|
// MB emits one relation per membership PERIOD, so a musician who
|
||||||
|
// left and rejoined a band yields the same group twice. Dedupe on
|
||||||
|
// MBID, keep the first.
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
let bands: Vec<BandRow> = rels
|
||||||
|
.groups
|
||||||
|
.into_iter()
|
||||||
|
.filter(|g| !g.name.trim().is_empty() && seen.insert(g.mbid.clone()))
|
||||||
|
.map(|g| BandRow {
|
||||||
|
mbid: g.mbid,
|
||||||
|
name: g.name,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
store_bands(&mbid, &bands);
|
||||||
|
apply_bands(&mbid, bands);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("[qbz-qt] musician bands unavailable for mbid {mbid}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyed on the MBID rather than on a generation, deliberately: bands belong to
|
||||||
|
/// an identity, not to a visit. A coalesced request started for one surface is
|
||||||
|
/// therefore still correct for whichever surfaces are showing that identity when
|
||||||
|
/// it lands — and both can be, since the modal opens over the page.
|
||||||
|
fn apply_bands(mbid: &str, bands: Vec<BandRow>) {
|
||||||
|
if bands.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let page_hit = with_state(|s| {
|
||||||
|
if s.mbid != mbid {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
s.bands = bands.clone();
|
||||||
|
true
|
||||||
|
});
|
||||||
|
if page_hit {
|
||||||
|
publish_page();
|
||||||
|
}
|
||||||
|
let modal_hit = {
|
||||||
|
let mut guard = MODAL.lock().unwrap();
|
||||||
|
match guard.as_mut() {
|
||||||
|
Some(m) if m.mbid == mbid => {
|
||||||
|
m.bands = bands;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if modal_hit {
|
||||||
|
publish_modal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Appearances
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Fill `art_path` from the disk cache and return the urls still missing.
|
||||||
|
/// `cached_path` is synchronous and memoised, so this is cheap to run twice.
|
||||||
|
fn attach_art(rows: &mut [AppearanceRow]) -> Vec<String> {
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
for row in rows.iter_mut() {
|
||||||
|
if row.artwork_url.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
row.art_path = crate::artwork_qt::cached_path(&row.artwork_url);
|
||||||
|
if row.art_path.is_empty() {
|
||||||
|
missing.push(row.artwork_url.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of "Appears On".
|
||||||
|
///
|
||||||
|
/// `first` separates the two error arms Tauri keeps apart: the FIRST page
|
||||||
|
/// failing raises the page's error state (`MusicianPageView.svelte:62-64`), a
|
||||||
|
/// later page failing is `console.error`-only and leaves the grid alone
|
||||||
|
/// (`:84-86`).
|
||||||
|
fn fetch_appearances(generation: u64, name: String, role: String, offset: u32, first: bool) {
|
||||||
|
let runtime = crate::app();
|
||||||
|
crate::spawn(async move {
|
||||||
|
let page = match runtime
|
||||||
|
.core()
|
||||||
|
.musicbrainz_get_musician_appearances(&name, &role, PAGE_SIZE, offset)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(page) => page,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[qbz-qt] musician appearances failed for {name:?}@{offset}: {e}");
|
||||||
|
fail(generation, first);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let returned = page.albums.len() as u32;
|
||||||
|
let total = page.total;
|
||||||
|
let mut rows: Vec<AppearanceRow> = page
|
||||||
|
.albums
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| AppearanceRow {
|
||||||
|
album_id: a.album_id,
|
||||||
|
title: a.album_title,
|
||||||
|
artist_name: a.artist_name,
|
||||||
|
// The FULL date the backend returned — see the file header.
|
||||||
|
release_date: a.year.unwrap_or_default(),
|
||||||
|
role_on_album: a.role_on_album,
|
||||||
|
artwork_url: a.album_artwork,
|
||||||
|
art_path: String::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let missing = attach_art(&mut rows);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = MUSICIAN.lock().unwrap();
|
||||||
|
let Some(s) = guard.as_mut() else { return };
|
||||||
|
if s.generation != generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Qobuz repeats rows across pages of the same search; the grid must
|
||||||
|
// not show the same album twice.
|
||||||
|
let seen: HashSet<String> = s.appearances.iter().map(|r| r.album_id.clone()).collect();
|
||||||
|
s.appearances
|
||||||
|
.extend(rows.into_iter().filter(|r| !seen.contains(&r.album_id)));
|
||||||
|
s.offset = offset.saturating_add(returned);
|
||||||
|
if first {
|
||||||
|
// Tauri reads the total ONCE and never refreshes it from a
|
||||||
|
// later page (`MusicianPageView.svelte:69-89` sets neither) —
|
||||||
|
// kept 1:1, because the total is the Qobuz album-search total
|
||||||
|
// and a later page's copy is no more accurate.
|
||||||
|
s.total = total;
|
||||||
|
}
|
||||||
|
// Tauri's rule is `appearances.length < totalAppearances` alone
|
||||||
|
// (`:40`), and because the search total routinely exceeds what
|
||||||
|
// pagination will actually return, that leaves a Load More button
|
||||||
|
// that is infinitely clickable and does nothing once the pages run
|
||||||
|
// out. `returned > 0` terminates it — the same guard
|
||||||
|
// `artist_releases_qt.rs:430` and `label_qt.rs:820` carry. Proposed
|
||||||
|
// divergence, flagged to the owner; drop the second term to be
|
||||||
|
// bug-compatible with Tauri.
|
||||||
|
s.has_more = s.appearances.len() < s.total && returned > 0;
|
||||||
|
s.page_state = "ready".to_string();
|
||||||
|
s.error.clear();
|
||||||
|
s.loading_more = false;
|
||||||
|
}
|
||||||
|
publish_page();
|
||||||
|
|
||||||
|
if missing.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Second pass — covers that were not on disk yet. Same shape as
|
||||||
|
// `artist_releases_qt::fetch`: download, re-attach over the WHOLE set,
|
||||||
|
// republish.
|
||||||
|
crate::artwork_qt::download_missing(missing).await;
|
||||||
|
let mut all = {
|
||||||
|
let mut guard = MUSICIAN.lock().unwrap();
|
||||||
|
let Some(s) = guard.as_mut() else { return };
|
||||||
|
if s.generation != generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::mem::take(&mut s.appearances)
|
||||||
|
};
|
||||||
|
let _ = attach_art(&mut all);
|
||||||
|
{
|
||||||
|
let mut guard = MUSICIAN.lock().unwrap();
|
||||||
|
let Some(s) = guard.as_mut() else { return };
|
||||||
|
if s.generation != generation {
|
||||||
|
// The take above already emptied the state for a musician the
|
||||||
|
// user has left; `reset` has since refilled it, so dropping
|
||||||
|
// `all` on the floor is correct.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.appearances = all;
|
||||||
|
}
|
||||||
|
publish_page();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two error arms (see `fetch_appearances`). Generation-guarded like every
|
||||||
|
/// other write.
|
||||||
|
fn fail(generation: u64, first: bool) {
|
||||||
|
let changed = with_state(|s| {
|
||||||
|
if s.generation != generation {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if first {
|
||||||
|
s.page_state = "error".to_string();
|
||||||
|
// Rust-translated and travelling inside a JSON document, so it
|
||||||
|
// cannot re-translate through `trRev` — see `republish`.
|
||||||
|
s.error = qbz_i18n::t("Failed to load appearances");
|
||||||
|
s.has_more = false;
|
||||||
|
} else {
|
||||||
|
s.loading_more = false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
if changed {
|
||||||
|
publish_page();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Remaining invokables
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The page's "Load more". Every guard lives here rather than in QML so a burst
|
||||||
|
/// of clicks cannot queue two pages.
|
||||||
|
pub fn load_more() {
|
||||||
|
let Some((generation, name, role, offset)) = with_state(|s| {
|
||||||
|
if s.page_state != "ready" || s.loading_more || !s.has_more {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
s.loading_more = true;
|
||||||
|
Some((s.generation, s.name.clone(), s.role.clone(), s.offset))
|
||||||
|
}) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
publish_page();
|
||||||
|
fetch_appearances(generation, name, role, offset, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An "Appears On" card. Straight through to the shell's album router, which
|
||||||
|
/// records its own nav entry and is a no-op offline.
|
||||||
|
pub fn open_album(album_id: String) {
|
||||||
|
if album_id.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
crate::open_album(album_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A "Bands & Projects" chip.
|
||||||
|
///
|
||||||
|
/// The group arrives as an MBID and there is no MBID -> Qobuz resolver
|
||||||
|
/// anywhere in the tree, so the chip resolves the group by NAME through the
|
||||||
|
/// same dispatcher every other consumer uses: a group Qobuz knows opens its
|
||||||
|
/// artist page, one it does not opens the modal. That is strictly more than
|
||||||
|
/// §5.3's "navigates when the group resolves, inert otherwise" — there is no
|
||||||
|
/// inert state left — and it costs no new machinery.
|
||||||
|
///
|
||||||
|
/// The role literal is `"Band"`, matching Tauri's Member-Of call site
|
||||||
|
/// (`ArtistDetailView.svelte`), and it is deliberately NOT translated: the role
|
||||||
|
/// string is echoed back as `role_on_album` on every appearance card, and Tauri
|
||||||
|
/// prints English there in every locale.
|
||||||
|
pub fn open_band(mbid: String) {
|
||||||
|
// The chip exists on BOTH surfaces, so both are searched — the modal first,
|
||||||
|
// since when it is open it is the one the user is clicking in.
|
||||||
|
let name = {
|
||||||
|
let guard = MODAL.lock().unwrap();
|
||||||
|
guard
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.bands.iter().find(|b| b.mbid == mbid).map(|b| b.name.clone()))
|
||||||
|
}
|
||||||
|
.or_else(|| {
|
||||||
|
with_state(|s| {
|
||||||
|
s.bands
|
||||||
|
.iter()
|
||||||
|
.find(|b| b.mbid == mbid)
|
||||||
|
.map(|b| b.name.clone())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let Some(name) = name else {
|
||||||
|
log::debug!("[qbz-qt] musician band chip: no band with mbid {mbid} in the open document");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
resolve_and_open(name, "Band".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape / X / backdrop / a parent modal closing — all four dismissal paths
|
||||||
|
/// land here (contract §5.2). Only the document is cleared; the page state is
|
||||||
|
/// left alone so a modal opened over a loaded page does not destroy it.
|
||||||
|
/// Is the musician modal up? Sampled by `hotkeys_bridge::handle_escape` for
|
||||||
|
/// the §1.2 Escape stack.
|
||||||
|
///
|
||||||
|
/// It must be asked of the OWNER of the state, not of the QML overlay: the
|
||||||
|
/// modal is a self-gated global item that is always instantiated, so "is it
|
||||||
|
/// visible" is not a thing any other surface can see. Reading `MODAL` here is
|
||||||
|
/// the same shape `search_qt::cortinilla_open()` and
|
||||||
|
/// `immersive_bridge::is_open()` already use.
|
||||||
|
pub fn modal_open() -> bool {
|
||||||
|
MODAL.lock().unwrap().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_modal() {
|
||||||
|
let was_open = MODAL.lock().unwrap().take().is_some();
|
||||||
|
if was_open {
|
||||||
|
publish_modal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live language switch. The only Rust-built translated string inside either
|
||||||
|
/// document is the page's error line, so this is a no-op unless the page is
|
||||||
|
/// standing in its error state — but it is called unconditionally from
|
||||||
|
/// `apply_language` for the same reason `artist_releases_qt::republish` is:
|
||||||
|
/// `LAST_DETAIL` only ever latches "album" or "artist" and cannot reach here.
|
||||||
|
///
|
||||||
|
/// The modal carries no Rust-translated string at all (its explanatory copy is
|
||||||
|
/// chosen in QML from `confidence`), so it is not republished.
|
||||||
|
pub fn republish() {
|
||||||
|
let has_doc = with_state(|s| {
|
||||||
|
if s.name.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if s.page_state == "error" {
|
||||||
|
s.error = qbz_i18n::t("Failed to load appearances");
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
if has_doc {
|
||||||
|
publish_page();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,15 @@
|
|||||||
//! `artistreleases`, `settings`, `search`, `playlist`, `discoverbrowse`,
|
//! `artistreleases`, `settings`, `search`, `playlist`, `discoverbrowse`,
|
||||||
//! `playlistbrowse`, `recentalbums`, `mostplayedalbums`, `label`,
|
//! `playlistbrowse`, `recentalbums`, `mostplayedalbums`, `label`,
|
||||||
//! `labelreleases`, `mix`, `mixtapes`, `collections`, `mixtapedetail`,
|
//! `labelreleases`, `mix`, `mixtapes`, `collections`, `mixtapedetail`,
|
||||||
//! `discobuilder`, `blacklist`, `playlistmanager`, `nowplaying`.
|
//! `discobuilder`, `blacklist`, `playlistmanager`, `nowplaying`, `scene`,
|
||||||
|
//! `musician`.
|
||||||
|
//!
|
||||||
|
//! `scene` (the "artists from the same place" discovery view) and `musician`
|
||||||
|
//! both carry a required context that only their opener has, so neither is in
|
||||||
|
//! the session-restore-safe set below: reopening the app on one would land on
|
||||||
|
//! a page with nothing to show. Note the musician MODAL is not a route at all —
|
||||||
|
//! only the `contextual` branch navigates; `weak`/`none` publish a document
|
||||||
|
//! that a global overlay renders (`musician_qt.rs`).
|
||||||
//!
|
//!
|
||||||
//! `nowplaying` is KIOSK-ONLY (2026-08-02 kiosk-port contract §3): it is the
|
//! `nowplaying` is KIOSK-ONLY (2026-08-02 kiosk-port contract §3): it is the
|
||||||
//! NavRail's fifth tile and the route the kiosk's full-screen player lives on.
|
//! NavRail's fifth tile and the route the kiosk's full-screen player lives on.
|
||||||
|
|||||||
@@ -79,6 +79,41 @@ pub(crate) fn current_quality() -> Quality {
|
|||||||
|
|
||||||
// Mute bookkeeping (mirrors playback.rs MUTED / PREMUTE_VOLUME: there is no
|
// Mute bookkeeping (mirrors playback.rs MUTED / PREMUTE_VOLUME: there is no
|
||||||
// dedicated mute API on the core — mute is volume 0 with a stash).
|
// dedicated mute API on the core — mute is volume 0 with a stash).
|
||||||
|
/// The track id of a COLD START that has been dispatched but has not yet
|
||||||
|
/// surfaced on the poll loop's track-change edge. 0 = nothing in flight.
|
||||||
|
///
|
||||||
|
/// It exists because the cold-start branch in `toggle_play` opened a window the
|
||||||
|
/// resume branch never had: between dispatch and the first audio frame the
|
||||||
|
/// engine reports `is_playing == false` AND `has_loaded_audio() == false` — the
|
||||||
|
/// exact condition that branch tests — so a second Play press would dispatch a
|
||||||
|
/// SECOND `play_track_resolved` for the same id, racing the first. On this port
|
||||||
|
/// that window is seconds, not milliseconds (the ACTIVE_TASK notes an
|
||||||
|
/// unexplained 5459 ms CMAF init).
|
||||||
|
///
|
||||||
|
/// THE SHAPE MATTERS, and it is the reference's, not Tauri's. Tauri had a
|
||||||
|
/// consumed-once `pendingSessionRestore` token that it cleared BEFORE awaiting
|
||||||
|
/// the load and never re-armed in the catch (`playerStore.ts:522`, `:603-608`),
|
||||||
|
/// so ONE failed load consumed the restore intent and every later Play press
|
||||||
|
/// fell through to a bare resume against an empty engine — the Play button was
|
||||||
|
/// dead for the rest of the session, silently. That is a large part of why this
|
||||||
|
/// feature "nunca quedaba bien" there.
|
||||||
|
///
|
||||||
|
/// This latch cannot do that, because it never gates the ABILITY to start:
|
||||||
|
/// - Its branch CANCELS the in-flight load (stop + clear) rather than
|
||||||
|
/// refusing, so a press always does something and always leaves a state the
|
||||||
|
/// next press can act on.
|
||||||
|
/// - It is cleared on the track edge AND on every failure path, so a failed
|
||||||
|
/// cold start re-opens the cold-start branch instead of latching it shut.
|
||||||
|
/// - Even if it were somehow stranded set, the next press stops and clears it,
|
||||||
|
/// and the press after that cold-starts normally. It self-heals in both
|
||||||
|
/// directions; a consumed-once token heals in neither.
|
||||||
|
///
|
||||||
|
/// This is also the reference's #661 fix (`crates/qbz/src/playback.rs:4811-4824`):
|
||||||
|
/// a pause issued during the load window used to fall through to the resume
|
||||||
|
/// branch and be swallowed — the track started anyway and the sleep inhibitor
|
||||||
|
/// stayed held with no successful pause to release it.
|
||||||
|
static PENDING_PLAY_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
static MUTED: AtomicBool = AtomicBool::new(false);
|
static MUTED: AtomicBool = AtomicBool::new(false);
|
||||||
static PREMUTE_VOLUME: AtomicU64 = AtomicU64::new(0);
|
static PREMUTE_VOLUME: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
@@ -1432,6 +1467,76 @@ pub async fn toggle_play(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
|||||||
}
|
}
|
||||||
let event = runtime.core().player().get_playback_event();
|
let event = runtime.core().player().get_playback_event();
|
||||||
let was_playing = event.is_playing;
|
let was_playing = event.is_playing;
|
||||||
|
|
||||||
|
// COLD CURSOR: not playing, and the engine holds no loaded stream.
|
||||||
|
//
|
||||||
|
// `resume()` only means "un-pause a stream that is already decoded". The
|
||||||
|
// audio thread answers a resume with no data by logging
|
||||||
|
// "cannot resume - no audio data available" and RETURNING
|
||||||
|
// (`qbz-player/src/player/mod.rs` around :3184-3205) — it does not error,
|
||||||
|
// so the `Err` arm below never fires and the user just sees a dead Play
|
||||||
|
// button. Reachable states:
|
||||||
|
//
|
||||||
|
// - **A restored session.** `session_persist::restore` rebuilds the queue
|
||||||
|
// and the cursor, and the NPB paints the track, but nothing is loaded
|
||||||
|
// until something plays. This is the owner-reported bug: queue restored,
|
||||||
|
// bar correct, Play does nothing.
|
||||||
|
// - A QConnect renderer queue whose cursor sits on a never-loaded track.
|
||||||
|
// - A cold cursor after the queue ended.
|
||||||
|
//
|
||||||
|
// The reference solves it the same way and names the same log line
|
||||||
|
// (`crates/qbz/src/playback.rs:4781-4791`, `:4826-4841`): load and play the
|
||||||
|
// CURRENT queue track instead of resuming. A normal pause leaves the stream
|
||||||
|
// loaded, so `has_loaded_audio()` stays true and the ordinary pause/resume
|
||||||
|
// path is untouched.
|
||||||
|
// A cold start is already in flight: this press is a CANCEL, not a second
|
||||||
|
// start. Without this arm the window below is re-entrant — see
|
||||||
|
// PENDING_PLAY_ID. Ordered before the `has_loaded_audio` test because
|
||||||
|
// during that window the engine answers false to both.
|
||||||
|
let pending = PENDING_PLAY_ID.load(Ordering::Relaxed);
|
||||||
|
if !was_playing && pending != 0 {
|
||||||
|
log::info!("[qbz-qt] toggle-play: cancelling the in-flight cold start of track {pending}");
|
||||||
|
PENDING_PLAY_ID.store(0, Ordering::Relaxed);
|
||||||
|
crate::now_playing::clear_loading();
|
||||||
|
if let Err(e) = runtime.core().stop() {
|
||||||
|
log::warn!("[qbz-qt] toggle-play: stop-during-load failed: {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !was_playing && !runtime.core().player().has_loaded_audio() {
|
||||||
|
match runtime.core().current_track().await {
|
||||||
|
Some(track) => {
|
||||||
|
log::info!(
|
||||||
|
"[qbz-qt] toggle-play: no loaded audio -> cold-starting current track {}",
|
||||||
|
track.id
|
||||||
|
);
|
||||||
|
PENDING_PLAY_ID.store(track.id, Ordering::Relaxed);
|
||||||
|
// Goes through the normal play path, so the persisted resume
|
||||||
|
// position is consumed by the same `take_resume_for` handshake
|
||||||
|
// a fresh play uses — a bare `resume()` could never have
|
||||||
|
// applied it, because there was no stream to seek.
|
||||||
|
play_queue_track(runtime, track.id, 0).await;
|
||||||
|
// Cleared unconditionally: `play_queue_track` swallows its own
|
||||||
|
// errors, so this is the only place a FAILED cold start can
|
||||||
|
// release the latch. The poll loop's track-change edge clears
|
||||||
|
// it too — whichever runs first wins, and both are idempotent.
|
||||||
|
// Leaving it set on failure is precisely the Tauri scar.
|
||||||
|
PENDING_PLAY_ID.compare_exchange(
|
||||||
|
track.id,
|
||||||
|
0,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::info!("[qbz-qt] toggle-play ignored: no loaded audio and an empty queue");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let result = if was_playing {
|
let result = if was_playing {
|
||||||
runtime.core().pause()
|
runtime.core().pause()
|
||||||
} else {
|
} else {
|
||||||
@@ -1466,7 +1571,7 @@ pub async fn next(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(track) = runtime.core().next_track().await {
|
if let Some(track) = runtime.core().next_track().await {
|
||||||
play_queue_track(runtime, track.id).await;
|
play_queue_track(runtime, track.id, 0).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1483,15 +1588,28 @@ pub async fn previous(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(track) = runtime.core().previous_track().await {
|
if let Some(track) = runtime.core().previous_track().await {
|
||||||
play_queue_track(runtime, track.id).await;
|
play_queue_track(runtime, track.id, 0).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn play_queue_track_public(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u64) {
|
pub(crate) async fn play_queue_track_public(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u64) {
|
||||||
play_queue_track(runtime, track_id).await;
|
play_queue_track(runtime, track_id, 0).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn play_queue_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u64) {
|
/// Play a queue track from the top of the source ladder (cast → peer → local
|
||||||
|
/// → Qobuz).
|
||||||
|
///
|
||||||
|
/// `start_position_secs` is the position to START at, and 0 means "the normal
|
||||||
|
/// start", NOT "second zero": `play_resolved_offline_aware` treats 0 as the
|
||||||
|
/// signal to consult `session_persist::take_resume_for`, so a restored track
|
||||||
|
/// resumes where it was left. A non-zero value is an EXPLICIT request and wins
|
||||||
|
/// over the restore stash — which is what lets a seek on a cold cursor
|
||||||
|
/// cold-start directly at the dragged position.
|
||||||
|
async fn play_queue_track(
|
||||||
|
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||||
|
track_id: u64,
|
||||||
|
start_position_secs: u64,
|
||||||
|
) {
|
||||||
// CAST owns playback while a renderer is connected: route the new track to
|
// CAST owns playback while a renderer is connected: route the new track to
|
||||||
// it instead of starting the local backend, or both would play.
|
// it instead of starting the local backend, or both would play.
|
||||||
if crate::cast_qt::play_current_if_cast(runtime).await {
|
if crate::cast_qt::play_current_if_cast(runtime).await {
|
||||||
@@ -1514,7 +1632,8 @@ async fn play_queue_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u
|
|||||||
publish_queue(runtime).await;
|
publish_queue(runtime).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(e) = play_resolved_offline_aware(runtime, track_id, current_quality(), 0)
|
if let Err(e) =
|
||||||
|
play_resolved_offline_aware(runtime, track_id, current_quality(), start_position_secs)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
log::error!("[qbz-qt] playback: play_track {track_id} failed: {e}");
|
log::error!("[qbz-qt] playback: play_track {track_id} failed: {e}");
|
||||||
@@ -1609,7 +1728,7 @@ fn auto_skip_unavailable<'a>(
|
|||||||
"[qbz-qt] advance: skipping unavailable track, trying {} ({skips}/{MAX_UNAVAILABLE_SKIPS})",
|
"[qbz-qt] advance: skipping unavailable track, trying {} ({skips}/{MAX_UNAVAILABLE_SKIPS})",
|
||||||
track.id
|
track.id
|
||||||
);
|
);
|
||||||
play_queue_track(runtime, track.id).await;
|
play_queue_track(runtime, track.id, 0).await;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1641,6 +1760,48 @@ pub async fn seek_frac(runtime: &Arc<AppRuntime<LoggingAdapter>>, frac: f32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let event = runtime.core().player().get_playback_event();
|
let event = runtime.core().player().get_playback_event();
|
||||||
|
|
||||||
|
// COLD CURSOR: the bar is draggable but the engine holds no stream.
|
||||||
|
//
|
||||||
|
// Two ways in, and BOTH were silently wrong before this branch:
|
||||||
|
//
|
||||||
|
// (a) A restored session. `event.duration` is 0 because nothing ever
|
||||||
|
// loaded, so the guard below returned and the drag did nothing at
|
||||||
|
// all. The bar still looked seekable, because the NPB's duration
|
||||||
|
// comes from the restored track's META, not from the engine. The
|
||||||
|
// keyboard seek was worse: it derives its fraction from the MODEL
|
||||||
|
// position, which is non-zero, so it built a perfectly valid
|
||||||
|
// fraction and handed it to a function that discarded it.
|
||||||
|
// (b) A cold cursor after a Stop (cast/QConnect handoff, or a queue that
|
||||||
|
// ended). `SharedState::duration` is never reset by the Stop arm, so
|
||||||
|
// it still holds the PREVIOUS track's length — the guard passed,
|
||||||
|
// `seek()` returned Ok because it only posts a command, and we then
|
||||||
|
// told D-Bus we had jumped. The audio thread meanwhile bailed with
|
||||||
|
// "cannot seek - no audio data available". The desktop widget was
|
||||||
|
// being told a position the player was not at and could not reach.
|
||||||
|
//
|
||||||
|
// Cold-starting AT the requested position is the honest answer to a drag,
|
||||||
|
// and it composes for free: a non-zero `start_position_secs` is treated as
|
||||||
|
// an explicit request and wins over the restore stash
|
||||||
|
// (`play_resolved_offline_aware`), so dragging before pressing Play does
|
||||||
|
// what the user asked instead of jumping back to the saved position.
|
||||||
|
if !runtime.core().player().has_loaded_audio() {
|
||||||
|
let Some(track) = runtime.core().current_track().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let duration = track.duration_secs;
|
||||||
|
if duration == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let target = (frac.clamp(0.0, 1.0) * duration as f32) as u64;
|
||||||
|
log::info!(
|
||||||
|
"[qbz-qt] seek on a cold cursor -> cold-starting track {} at {target}s",
|
||||||
|
track.id
|
||||||
|
);
|
||||||
|
play_queue_track(runtime, track.id, target).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if event.duration == 0 {
|
if event.duration == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2224,6 +2385,8 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
|||||||
|
|
||||||
// --- Track-change edge (new current track surfaced) ----------
|
// --- Track-change edge (new current track surfaced) ----------
|
||||||
if track_id != 0 && track_id != last_track_id {
|
if track_id != 0 && track_id != last_track_id {
|
||||||
|
// A cold start that reached audio is no longer pending.
|
||||||
|
PENDING_PLAY_ID.store(0, Ordering::Relaxed);
|
||||||
// Reconcile the queue pointer (a real advance moves it; a
|
// Reconcile the queue pointer (a real advance moves it; a
|
||||||
// manual play already did) and refresh meta + queue.
|
// manual play already did) and refresh meta + queue.
|
||||||
let _ = runtime.core().sync_current_to_id(track_id).await;
|
let _ = runtime.core().sync_current_to_id(track_id).await;
|
||||||
@@ -2526,7 +2689,7 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
|
|||||||
if let Some(track) = runtime.core().next_track().await {
|
if let Some(track) = runtime.core().next_track().await {
|
||||||
let next_id = track.id;
|
let next_id = track.id;
|
||||||
log::info!("[qbz-qt] poll: advancing to {next_id}");
|
log::info!("[qbz-qt] poll: advancing to {next_id}");
|
||||||
play_queue_track(&runtime, next_id).await;
|
play_queue_track(&runtime, next_id, 0).await;
|
||||||
} else if try_infinite_refill(&runtime, ended_track_id).await {
|
} else if try_infinite_refill(&runtime, ended_track_id).await {
|
||||||
// InfiniteRadio started a fresh smart radio; it replaced
|
// InfiniteRadio started a fresh smart radio; it replaced
|
||||||
// the queue and published on its way in.
|
// the queue and published on its way in.
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! QbzScene — ArtistScene ("artists from the same place") domain bridge.
|
||||||
|
//!
|
||||||
|
//! A NEW bridge rather than three more members on `QbzArtist` (TRACK-RULE 2:
|
||||||
|
//! one bridge per domain, never a God-object — `artist_bridge.rs` already
|
||||||
|
//! serves the artist page and its discography sub-view). Contract §13.1 freezes
|
||||||
|
//! everything below; a QML lane is written against it, and
|
||||||
|
//! `qml_singleton_xref.py` is the gate that proves the two halves agree.
|
||||||
|
//!
|
||||||
|
//! The module is `qbz_scene_bridge`, not `qbz_scene`: bridge modules are never
|
||||||
|
//! named after a workspace crate, and the QML type name comes from the QObject
|
||||||
|
//! (`QbzScene`), not from the module.
|
||||||
|
//!
|
||||||
|
//! Props: ONE JSON document (`artist_scene_qt::SceneDoc` — state, hero,
|
||||||
|
//! progress, the grid, and the sidepanel's own sub-document) plus its revision
|
||||||
|
//! counter.
|
||||||
|
//! Invokables: open / loadMore / retry / openArtist / selectArtist / close.
|
||||||
|
//!
|
||||||
|
//! **A "new bridge" is seven artefacts, not one file** (contract §5/B5), and
|
||||||
|
//! this is one of them. The other six are integration-owned and are listed in
|
||||||
|
//! the handoff report: `build.rs` `rust_files` registration, `main.rs` `mod`
|
||||||
|
//! line, the QML singleton registration, `Main.qml`'s `boot()` entry, and
|
||||||
|
//! `ContentRouter.qml`'s `scene` arm. Missing the router arm is a BLANK content
|
||||||
|
//! pane logged nowhere (`nav_qt.rs:9-23`).
|
||||||
|
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use cxx_qt::CxxQtThread;
|
||||||
|
use cxx_qt::Threading as _;
|
||||||
|
use cxx_qt_lib::QString;
|
||||||
|
|
||||||
|
#[cxx_qt::bridge]
|
||||||
|
pub mod qbz_scene_bridge {
|
||||||
|
extern "C++" {
|
||||||
|
include!("cxx-qt-lib/qstring.h");
|
||||||
|
type QString = cxx_qt_lib::QString;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[auto_cxx_name]
|
||||||
|
extern "RustQt" {
|
||||||
|
#[qobject]
|
||||||
|
#[qml_element]
|
||||||
|
#[qml_singleton]
|
||||||
|
// ONE JSON document (contract §13.1). Everything the view paints —
|
||||||
|
// the five body states, the hero, the progress stream, the grid rows
|
||||||
|
// and the sidepanel sub-document — travels inside it, so a frame is
|
||||||
|
// never half-old.
|
||||||
|
#[qproperty(QString, scene_json)]
|
||||||
|
// Bumped on EVERY publish. QML binds it to force a re-read: a QString
|
||||||
|
// identical to the previous one emits no change signal, and the
|
||||||
|
// progress stream produces exactly that case (same phase, same pct,
|
||||||
|
// same detail) more often than any other view in the tree.
|
||||||
|
#[qproperty(i32, scene_rev)]
|
||||||
|
|
||||||
|
type QbzScene = super::QbzSceneRust;
|
||||||
|
|
||||||
|
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
|
||||||
|
/// singleton; only QbzSession.boot also fires crate::on_boot).
|
||||||
|
#[qinvokable]
|
||||||
|
fn boot(self: Pin<&mut QbzScene>);
|
||||||
|
|
||||||
|
/// The two doors (contract §2.1): the Network sidebar's Origin
|
||||||
|
/// location row, and the header ⋯ "Artist Scene" item.
|
||||||
|
///
|
||||||
|
/// Every query parameter travels WITH the call. The Slint port instead
|
||||||
|
/// reads a process-global `static LOCATION_PARAMS` that the next
|
||||||
|
/// artist page overwrites, so its "Load more" can append a different
|
||||||
|
/// artist's scene into the open grid (`crates/qbz/src/artist.rs:1518`).
|
||||||
|
///
|
||||||
|
/// `genres_csv` / `tags_csv` are comma-separated because the QML seam
|
||||||
|
/// has no list type; empty segments are dropped.
|
||||||
|
///
|
||||||
|
/// `artist_name` is the MUSICBRAINZ name, not the Qobuz one — it is
|
||||||
|
/// what the hero subtitle "Based on {artist}" prints
|
||||||
|
/// (`ArtistDetailView.svelte:2915`).
|
||||||
|
///
|
||||||
|
/// `#[auto_cxx_name]` makes the QML name `QbzScene.open(...)`.
|
||||||
|
// NOTE: no `#[allow(clippy::too_many_arguments)]` here — the
|
||||||
|
// `#[cxx_qt::bridge]` macro rewrites this block and only understands
|
||||||
|
// its own attributes plus doc comments. The allow lives on the `impl`
|
||||||
|
// below, which is where the real function body is.
|
||||||
|
#[qinvokable]
|
||||||
|
fn open(
|
||||||
|
self: Pin<&mut QbzScene>,
|
||||||
|
mbid: QString,
|
||||||
|
artist_name: QString,
|
||||||
|
area_id: QString,
|
||||||
|
city: QString,
|
||||||
|
country: QString,
|
||||||
|
country_code: QString,
|
||||||
|
precision: QString,
|
||||||
|
genres_csv: QString,
|
||||||
|
tags_csv: QString,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The footer's explicit "Load More" — Tauri paginates on a button,
|
||||||
|
/// not on scroll (`ArtistsByLocationView.svelte:390-398`). Every guard
|
||||||
|
/// lives in Rust as well, so a double click cannot queue two pages.
|
||||||
|
///
|
||||||
|
/// QML name: `QbzScene.loadMore()`.
|
||||||
|
#[qinvokable]
|
||||||
|
fn load_more(self: Pin<&mut QbzScene>);
|
||||||
|
|
||||||
|
/// The error state's Retry. Re-arms the progress stream AND the
|
||||||
|
/// cancellation token (contract DV-5 / B8) and restarts at offset 0,
|
||||||
|
/// recording no nav entry.
|
||||||
|
#[qinvokable]
|
||||||
|
fn retry(self: Pin<&mut QbzScene>);
|
||||||
|
|
||||||
|
/// Grid card / sidepanel row click -> the artist page.
|
||||||
|
///
|
||||||
|
/// QML name: `QbzScene.openArtist(id)`.
|
||||||
|
#[qinvokable]
|
||||||
|
fn open_artist(self: Pin<&mut QbzScene>, qobuz_id: QString);
|
||||||
|
|
||||||
|
/// Sidepanel mode: load that artist's albums into `panel` (limit 500,
|
||||||
|
/// offset 0 — Tauri's numbers, `ArtistsByLocationView.svelte:345-349`),
|
||||||
|
/// categorised into Discography / EPs & Singles / Live.
|
||||||
|
///
|
||||||
|
/// QML name: `QbzScene.selectArtist(id)`.
|
||||||
|
#[qinvokable]
|
||||||
|
fn select_artist(self: Pin<&mut QbzScene>, qobuz_id: QString);
|
||||||
|
|
||||||
|
/// View deactivation. **Not** cosmetic: one page runs up to 100
|
||||||
|
/// SEQUENTIAL Qobuz artist searches plus rate-limited MusicBrainz
|
||||||
|
/// calls, and a generation guard only drops the final publish — it
|
||||||
|
/// stops none of that traffic (contract §3/B8). The view MUST call
|
||||||
|
/// this when it is torn down or navigated away from.
|
||||||
|
#[qinvokable]
|
||||||
|
fn close(self: Pin<&mut QbzScene>);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl cxx_qt::Threading for QbzScene {}
|
||||||
|
}
|
||||||
|
|
||||||
|
use qbz_scene_bridge::QbzScene;
|
||||||
|
|
||||||
|
/// Rust side of the scene bridge (plain storage, phase-1 pattern).
|
||||||
|
pub struct QbzSceneRust {
|
||||||
|
scene_json: QString,
|
||||||
|
scene_rev: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for QbzSceneRust {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// "{}" and not "": the view `JSON.parse`s this straight, and an
|
||||||
|
// empty string throws into the catch arm on the very first frame.
|
||||||
|
scene_json: QString::from("{}"),
|
||||||
|
scene_rev: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The UI hop (bridges/mod.rs pattern)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static QT_THREAD: OnceLock<CxxQtThread<QbzScene>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Queue a scene-bridge mutation onto the Qt event loop (no-op before boot
|
||||||
|
/// registers the thread).
|
||||||
|
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzScene>) + Send + 'static) {
|
||||||
|
if let Some(thread) = QT_THREAD.get() {
|
||||||
|
let _ = thread.queue(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl qbz_scene_bridge::QbzScene {
|
||||||
|
pub fn boot(self: Pin<&mut Self>) {
|
||||||
|
if QT_THREAD.set(self.qt_thread()).is_err() {
|
||||||
|
log::warn!("[qbz-qt] scene Qt thread already registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-line delegates into `artist_scene_qt`, with no `crate::` forwarder in
|
||||||
|
// main.rs: that indirection exists for the paths that mutate main.rs's own
|
||||||
|
// state (LAST_DETAIL, the nav latch). This view owns its state in its own
|
||||||
|
// module and records its own nav entry, so there is nothing for main.rs to
|
||||||
|
// coordinate. The one exception is `open_artist`, which goes through
|
||||||
|
// `crate::open_artist` precisely because that path DOES set LAST_DETAIL.
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn open(
|
||||||
|
self: Pin<&mut Self>,
|
||||||
|
mbid: QString,
|
||||||
|
artist_name: QString,
|
||||||
|
area_id: QString,
|
||||||
|
city: QString,
|
||||||
|
country: QString,
|
||||||
|
country_code: QString,
|
||||||
|
precision: QString,
|
||||||
|
genres_csv: QString,
|
||||||
|
tags_csv: QString,
|
||||||
|
) {
|
||||||
|
crate::artist_scene_qt::open(
|
||||||
|
mbid.to_string(),
|
||||||
|
artist_name.to_string(),
|
||||||
|
area_id.to_string(),
|
||||||
|
city.to_string(),
|
||||||
|
country.to_string(),
|
||||||
|
country_code.to_string(),
|
||||||
|
precision.to_string(),
|
||||||
|
genres_csv.to_string(),
|
||||||
|
tags_csv.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_more(self: Pin<&mut Self>) {
|
||||||
|
crate::artist_scene_qt::load_more();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retry(self: Pin<&mut Self>) {
|
||||||
|
crate::artist_scene_qt::retry();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_artist(self: Pin<&mut Self>, qobuz_id: QString) {
|
||||||
|
crate::artist_scene_qt::open_artist(qobuz_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_artist(self: Pin<&mut Self>, qobuz_id: QString) {
|
||||||
|
crate::artist_scene_qt::select_artist(qobuz_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(self: Pin<&mut Self>) {
|
||||||
|
crate::artist_scene_qt::close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2227,6 +2227,16 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
|
|||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Live apply (main.rs:9019-9029 seeds core from this pref).
|
// Live apply (main.rs:9019-9029 seeds core from this pref).
|
||||||
runtime.core().musicbrainz_set_enabled(value).await;
|
runtime.core().musicbrainz_set_enabled(value).await;
|
||||||
|
// ...and repaint the open artist page, because every
|
||||||
|
// MB-derived field on that document is baked when the
|
||||||
|
// document is built: `mbAvailable`, the Origin block, the
|
||||||
|
// relationship rows, and `origin.locationClickable` — the
|
||||||
|
// gate on both Artist Scene doors. Without this, turning
|
||||||
|
// MusicBrainz OFF leaves a live "Artist Scene" link on a
|
||||||
|
// page the user navigates back to, and following it would
|
||||||
|
// run discovery against a disabled client that reports a
|
||||||
|
// false "no artists found" rather than an error.
|
||||||
|
crate::republish_open_artist();
|
||||||
Ok(Apply::None)
|
Ok(Apply::None)
|
||||||
}
|
}
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
|
|||||||
@@ -5111,3 +5111,75 @@ msgstr "Cover ansehen"
|
|||||||
|
|
||||||
msgid "Could not load the image"
|
msgid "Could not load the image"
|
||||||
msgstr "Das Bild konnte nicht geladen werden"
|
msgstr "Das Bild konnte nicht geladen werden"
|
||||||
|
|
||||||
|
# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's
|
||||||
|
# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen
|
||||||
|
# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter",
|
||||||
|
# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On",
|
||||||
|
# "Bands & Projects", "Load more", "Loading…", "Artist Scene",
|
||||||
|
# "No artists found for this scene.", "No album appearances found." and
|
||||||
|
# "Select an artist to see their albums." (period-only delta) already exist.
|
||||||
|
# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7,
|
||||||
|
# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them
|
||||||
|
# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every
|
||||||
|
# call site substitutes explicitly, e.g. .replace("{artist}", name).
|
||||||
|
|
||||||
|
msgid "Based on {artist} · {genres}"
|
||||||
|
msgstr "Basierend auf {artist} · {genres}"
|
||||||
|
|
||||||
|
msgid "Searching MusicBrainz for {genre} artists..."
|
||||||
|
msgstr "Suche auf MusicBrainz nach {genre}-Künstlern..."
|
||||||
|
|
||||||
|
msgid "Verifying availability on Qobuz..."
|
||||||
|
msgstr "Verfügbarkeit auf Qobuz wird geprüft..."
|
||||||
|
|
||||||
|
msgid "Preparing results..."
|
||||||
|
msgstr "Ergebnisse werden vorbereitet..."
|
||||||
|
|
||||||
|
msgid "No matching artists from this scene are currently available on Qobuz."
|
||||||
|
msgstr "Derzeit sind keine passenden Künstler aus dieser Szene auf Qobuz verfügbar."
|
||||||
|
|
||||||
|
msgid "Loading albums..."
|
||||||
|
msgstr "Alben werden geladen..."
|
||||||
|
|
||||||
|
msgid "Failed to load albums"
|
||||||
|
msgstr "Alben konnten nicht geladen werden"
|
||||||
|
|
||||||
|
msgid "Failed to load appearances"
|
||||||
|
msgstr "Mitwirkungen konnten nicht geladen werden"
|
||||||
|
|
||||||
|
msgid "Looking up {name}..."
|
||||||
|
msgstr "{name} wird gesucht..."
|
||||||
|
|
||||||
|
msgid "Failed to look up musician"
|
||||||
|
msgstr "Musiker konnte nicht gefunden werden"
|
||||||
|
|
||||||
|
msgid "Browse view"
|
||||||
|
msgstr "Browse-Ansicht"
|
||||||
|
|
||||||
|
msgid "Grid view"
|
||||||
|
msgstr "Rasteransicht"
|
||||||
|
|
||||||
|
msgid "Alphabetical (A-Z)"
|
||||||
|
msgstr "Alphabetisch (A-Z)"
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits but has limited catalog information available in Qobuz."
|
||||||
|
msgstr "Dieser Musiker erscheint in den Album-Credits, es sind bei Qobuz aber nur wenige Katalogdaten verfügbar."
|
||||||
|
|
||||||
|
msgid "Unable to verify this musician's catalog. The information shown is based on album credits."
|
||||||
|
msgstr "Der Katalog dieses Musikers konnte nicht überprüft werden. Die angezeigten Informationen stammen aus den Album-Credits."
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits."
|
||||||
|
msgstr "Dieser Musiker erscheint in den Album-Credits."
|
||||||
|
|
||||||
|
msgid "Discography"
|
||||||
|
msgstr "Diskografie"
|
||||||
|
|
||||||
|
msgid "Live Albums"
|
||||||
|
msgstr "Live-Alben"
|
||||||
|
|
||||||
|
msgid "Select an artist to see their albums"
|
||||||
|
msgstr "Wähle einen Künstler aus, um seine Alben zu sehen"
|
||||||
|
|
||||||
|
msgid "{} / {} artists"
|
||||||
|
msgstr "{} / {} Künstler"
|
||||||
@@ -741,3 +741,63 @@ msgstr "Couldn't create the playlist"
|
|||||||
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
|
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
|
||||||
msgid "Open playlist"
|
msgid "Open playlist"
|
||||||
msgstr "Open playlist"
|
msgstr "Open playlist"
|
||||||
|
|
||||||
|
# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's
|
||||||
|
# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen
|
||||||
|
# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter",
|
||||||
|
# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On",
|
||||||
|
# "Bands & Projects", "Load more", "Loading…", "Artist Scene",
|
||||||
|
# "No artists found for this scene.", "No album appearances found." and
|
||||||
|
# "Select an artist to see their albums." (period-only delta) already exist.
|
||||||
|
# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7,
|
||||||
|
# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them
|
||||||
|
# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every
|
||||||
|
# call site substitutes explicitly, e.g. .replace("{artist}", name).
|
||||||
|
|
||||||
|
msgid "Based on {artist} · {genres}"
|
||||||
|
msgstr "Based on {artist} · {genres}"
|
||||||
|
|
||||||
|
msgid "Searching MusicBrainz for {genre} artists..."
|
||||||
|
msgstr "Searching MusicBrainz for {genre} artists..."
|
||||||
|
|
||||||
|
msgid "Verifying availability on Qobuz..."
|
||||||
|
msgstr "Verifying availability on Qobuz..."
|
||||||
|
|
||||||
|
msgid "Preparing results..."
|
||||||
|
msgstr "Preparing results..."
|
||||||
|
|
||||||
|
msgid "No matching artists from this scene are currently available on Qobuz."
|
||||||
|
msgstr "No matching artists from this scene are currently available on Qobuz."
|
||||||
|
|
||||||
|
msgid "Loading albums..."
|
||||||
|
msgstr "Loading albums..."
|
||||||
|
|
||||||
|
msgid "Failed to load albums"
|
||||||
|
msgstr "Failed to load albums"
|
||||||
|
|
||||||
|
msgid "Failed to load appearances"
|
||||||
|
msgstr "Failed to load appearances"
|
||||||
|
|
||||||
|
msgid "Looking up {name}..."
|
||||||
|
msgstr "Looking up {name}..."
|
||||||
|
|
||||||
|
msgid "Failed to look up musician"
|
||||||
|
msgstr "Failed to look up musician"
|
||||||
|
|
||||||
|
msgid "Browse view"
|
||||||
|
msgstr "Browse view"
|
||||||
|
|
||||||
|
msgid "Grid view"
|
||||||
|
msgstr "Grid view"
|
||||||
|
|
||||||
|
msgid "Alphabetical (A-Z)"
|
||||||
|
msgstr "Alphabetical (A-Z)"
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits but has limited catalog information available in Qobuz."
|
||||||
|
msgstr "This musician appears in album credits but has limited catalog information available in Qobuz."
|
||||||
|
|
||||||
|
msgid "Unable to verify this musician's catalog. The information shown is based on album credits."
|
||||||
|
msgstr "Unable to verify this musician's catalog. The information shown is based on album credits."
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits."
|
||||||
|
msgstr "This musician appears in album credits."
|
||||||
@@ -860,7 +860,7 @@ msgid "Album"
|
|||||||
msgstr "Álbum"
|
msgstr "Álbum"
|
||||||
|
|
||||||
msgid "Bands & Projects"
|
msgid "Bands & Projects"
|
||||||
msgstr "Alzacuello"
|
msgstr "Bandas y proyectos"
|
||||||
|
|
||||||
msgid "No album appearances found."
|
msgid "No album appearances found."
|
||||||
msgstr "No se encontraron apariciones en álbumes."
|
msgstr "No se encontraron apariciones en álbumes."
|
||||||
@@ -5119,3 +5119,75 @@ msgstr "Ver carátula"
|
|||||||
|
|
||||||
msgid "Could not load the image"
|
msgid "Could not load the image"
|
||||||
msgstr "No se pudo cargar la imagen"
|
msgstr "No se pudo cargar la imagen"
|
||||||
|
|
||||||
|
# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's
|
||||||
|
# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen
|
||||||
|
# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter",
|
||||||
|
# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On",
|
||||||
|
# "Bands & Projects", "Load more", "Loading…", "Artist Scene",
|
||||||
|
# "No artists found for this scene.", "No album appearances found." and
|
||||||
|
# "Select an artist to see their albums." (period-only delta) already exist.
|
||||||
|
# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7,
|
||||||
|
# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them
|
||||||
|
# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every
|
||||||
|
# call site substitutes explicitly, e.g. .replace("{artist}", name).
|
||||||
|
|
||||||
|
msgid "Based on {artist} · {genres}"
|
||||||
|
msgstr "Basado en {artist} · {genres}"
|
||||||
|
|
||||||
|
msgid "Searching MusicBrainz for {genre} artists..."
|
||||||
|
msgstr "Buscando artistas de {genre} en MusicBrainz..."
|
||||||
|
|
||||||
|
msgid "Verifying availability on Qobuz..."
|
||||||
|
msgstr "Verificando disponibilidad en Qobuz..."
|
||||||
|
|
||||||
|
msgid "Preparing results..."
|
||||||
|
msgstr "Preparando resultados..."
|
||||||
|
|
||||||
|
msgid "No matching artists from this scene are currently available on Qobuz."
|
||||||
|
msgstr "Ningún artista de esta escena está disponible en Qobuz por el momento."
|
||||||
|
|
||||||
|
msgid "Loading albums..."
|
||||||
|
msgstr "Cargando álbumes..."
|
||||||
|
|
||||||
|
msgid "Failed to load albums"
|
||||||
|
msgstr "No se pudieron cargar los álbumes"
|
||||||
|
|
||||||
|
msgid "Failed to load appearances"
|
||||||
|
msgstr "No se pudieron cargar las participaciones"
|
||||||
|
|
||||||
|
msgid "Looking up {name}..."
|
||||||
|
msgstr "Buscando a {name}..."
|
||||||
|
|
||||||
|
msgid "Failed to look up musician"
|
||||||
|
msgstr "No se pudo buscar al músico"
|
||||||
|
|
||||||
|
msgid "Browse view"
|
||||||
|
msgstr "Vista de exploración"
|
||||||
|
|
||||||
|
msgid "Grid view"
|
||||||
|
msgstr "Vista de cuadrícula"
|
||||||
|
|
||||||
|
msgid "Alphabetical (A-Z)"
|
||||||
|
msgstr "Alfabético (A-Z)"
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits but has limited catalog information available in Qobuz."
|
||||||
|
msgstr "Este músico aparece en los créditos del álbum, pero hay poca información de catálogo disponible en Qobuz."
|
||||||
|
|
||||||
|
msgid "Unable to verify this musician's catalog. The information shown is based on album credits."
|
||||||
|
msgstr "No se pudo verificar el catálogo de este músico. La información mostrada proviene de los créditos del álbum."
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits."
|
||||||
|
msgstr "Este músico aparece en los créditos del álbum."
|
||||||
|
|
||||||
|
msgid "Discography"
|
||||||
|
msgstr "Discografía"
|
||||||
|
|
||||||
|
msgid "Live Albums"
|
||||||
|
msgstr "Álbumes en vivo"
|
||||||
|
|
||||||
|
msgid "Select an artist to see their albums"
|
||||||
|
msgstr "Selecciona un artista para ver sus álbumes"
|
||||||
|
|
||||||
|
msgid "{} / {} artists"
|
||||||
|
msgstr "{} / {} artistas"
|
||||||
@@ -5111,3 +5111,75 @@ msgstr "Voir la pochette"
|
|||||||
|
|
||||||
msgid "Could not load the image"
|
msgid "Could not load the image"
|
||||||
msgstr "Impossible de charger l'image"
|
msgstr "Impossible de charger l'image"
|
||||||
|
|
||||||
|
# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's
|
||||||
|
# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen
|
||||||
|
# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter",
|
||||||
|
# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On",
|
||||||
|
# "Bands & Projects", "Load more", "Loading…", "Artist Scene",
|
||||||
|
# "No artists found for this scene.", "No album appearances found." and
|
||||||
|
# "Select an artist to see their albums." (period-only delta) already exist.
|
||||||
|
# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7,
|
||||||
|
# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them
|
||||||
|
# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every
|
||||||
|
# call site substitutes explicitly, e.g. .replace("{artist}", name).
|
||||||
|
|
||||||
|
msgid "Based on {artist} · {genres}"
|
||||||
|
msgstr "D'après {artist} · {genres}"
|
||||||
|
|
||||||
|
msgid "Searching MusicBrainz for {genre} artists..."
|
||||||
|
msgstr "Recherche d'artistes {genre} sur MusicBrainz..."
|
||||||
|
|
||||||
|
msgid "Verifying availability on Qobuz..."
|
||||||
|
msgstr "Vérification de la disponibilité sur Qobuz..."
|
||||||
|
|
||||||
|
msgid "Preparing results..."
|
||||||
|
msgstr "Préparation des résultats..."
|
||||||
|
|
||||||
|
msgid "No matching artists from this scene are currently available on Qobuz."
|
||||||
|
msgstr "Aucun artiste correspondant à cette scène n'est actuellement disponible sur Qobuz."
|
||||||
|
|
||||||
|
msgid "Loading albums..."
|
||||||
|
msgstr "Chargement des albums..."
|
||||||
|
|
||||||
|
msgid "Failed to load albums"
|
||||||
|
msgstr "Échec du chargement des albums"
|
||||||
|
|
||||||
|
msgid "Failed to load appearances"
|
||||||
|
msgstr "Échec du chargement des participations"
|
||||||
|
|
||||||
|
msgid "Looking up {name}..."
|
||||||
|
msgstr "Recherche de {name}..."
|
||||||
|
|
||||||
|
msgid "Failed to look up musician"
|
||||||
|
msgstr "Impossible de rechercher ce musicien"
|
||||||
|
|
||||||
|
msgid "Browse view"
|
||||||
|
msgstr "Vue exploration"
|
||||||
|
|
||||||
|
msgid "Grid view"
|
||||||
|
msgstr "Vue en grille"
|
||||||
|
|
||||||
|
msgid "Alphabetical (A-Z)"
|
||||||
|
msgstr "Alphabétique (A-Z)"
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits but has limited catalog information available in Qobuz."
|
||||||
|
msgstr "Ce musicien apparaît dans les crédits de l'album, mais peu d'informations de catalogue sont disponibles sur Qobuz."
|
||||||
|
|
||||||
|
msgid "Unable to verify this musician's catalog. The information shown is based on album credits."
|
||||||
|
msgstr "Impossible de vérifier le catalogue de ce musicien. Les informations affichées proviennent des crédits de l'album."
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits."
|
||||||
|
msgstr "Ce musicien apparaît dans les crédits de l'album."
|
||||||
|
|
||||||
|
msgid "Discography"
|
||||||
|
msgstr "Discographie"
|
||||||
|
|
||||||
|
msgid "Live Albums"
|
||||||
|
msgstr "Albums live"
|
||||||
|
|
||||||
|
msgid "Select an artist to see their albums"
|
||||||
|
msgstr "Sélectionnez un artiste pour voir ses albums"
|
||||||
|
|
||||||
|
msgid "{} / {} artists"
|
||||||
|
msgstr "{} / {} artistes"
|
||||||
@@ -5096,3 +5096,75 @@ msgstr "カバーを表示"
|
|||||||
|
|
||||||
msgid "Could not load the image"
|
msgid "Could not load the image"
|
||||||
msgstr "画像を読み込めませんでした"
|
msgstr "画像を読み込めませんでした"
|
||||||
|
|
||||||
|
# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's
|
||||||
|
# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen
|
||||||
|
# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter",
|
||||||
|
# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On",
|
||||||
|
# "Bands & Projects", "Load more", "Loading…", "Artist Scene",
|
||||||
|
# "No artists found for this scene.", "No album appearances found." and
|
||||||
|
# "Select an artist to see their albums." (period-only delta) already exist.
|
||||||
|
# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7,
|
||||||
|
# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them
|
||||||
|
# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every
|
||||||
|
# call site substitutes explicitly, e.g. .replace("{artist}", name).
|
||||||
|
|
||||||
|
msgid "Based on {artist} · {genres}"
|
||||||
|
msgstr "{artist} · {genres} に基づく"
|
||||||
|
|
||||||
|
msgid "Searching MusicBrainz for {genre} artists..."
|
||||||
|
msgstr "MusicBrainz で {genre} のアーティストを検索中..."
|
||||||
|
|
||||||
|
msgid "Verifying availability on Qobuz..."
|
||||||
|
msgstr "Qobuz での配信状況を確認中..."
|
||||||
|
|
||||||
|
msgid "Preparing results..."
|
||||||
|
msgstr "結果を準備中..."
|
||||||
|
|
||||||
|
msgid "No matching artists from this scene are currently available on Qobuz."
|
||||||
|
msgstr "このシーンに該当するアーティストは現在 Qobuz では利用できません。"
|
||||||
|
|
||||||
|
msgid "Loading albums..."
|
||||||
|
msgstr "アルバムを読み込み中..."
|
||||||
|
|
||||||
|
msgid "Failed to load albums"
|
||||||
|
msgstr "アルバムを読み込めませんでした"
|
||||||
|
|
||||||
|
msgid "Failed to load appearances"
|
||||||
|
msgstr "参加作品を読み込めませんでした"
|
||||||
|
|
||||||
|
msgid "Looking up {name}..."
|
||||||
|
msgstr "{name} を検索中..."
|
||||||
|
|
||||||
|
msgid "Failed to look up musician"
|
||||||
|
msgstr "ミュージシャンを検索できませんでした"
|
||||||
|
|
||||||
|
msgid "Browse view"
|
||||||
|
msgstr "ブラウズ表示"
|
||||||
|
|
||||||
|
msgid "Grid view"
|
||||||
|
msgstr "グリッド表示"
|
||||||
|
|
||||||
|
msgid "Alphabetical (A-Z)"
|
||||||
|
msgstr "アルファベット順 (A-Z)"
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits but has limited catalog information available in Qobuz."
|
||||||
|
msgstr "このミュージシャンはアルバムのクレジットに含まれていますが、Qobuz で利用できるカタログ情報は限られています。"
|
||||||
|
|
||||||
|
msgid "Unable to verify this musician's catalog. The information shown is based on album credits."
|
||||||
|
msgstr "このミュージシャンのカタログを確認できませんでした。表示されている情報はアルバムのクレジットに基づいています。"
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits."
|
||||||
|
msgstr "このミュージシャンはアルバムのクレジットに含まれています。"
|
||||||
|
|
||||||
|
msgid "Discography"
|
||||||
|
msgstr "ディスコグラフィー"
|
||||||
|
|
||||||
|
msgid "Live Albums"
|
||||||
|
msgstr "ライブアルバム"
|
||||||
|
|
||||||
|
msgid "Select an artist to see their albums"
|
||||||
|
msgstr "アーティストを選択してアルバムを表示します"
|
||||||
|
|
||||||
|
msgid "{} / {} artists"
|
||||||
|
msgstr "{} / {} 組のアーティスト"
|
||||||
@@ -5101,3 +5101,75 @@ msgstr "Hoes bekijken"
|
|||||||
|
|
||||||
msgid "Could not load the image"
|
msgid "Could not load the image"
|
||||||
msgstr "De afbeelding kon niet worden geladen"
|
msgstr "De afbeelding kon niet worden geladen"
|
||||||
|
|
||||||
|
# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's
|
||||||
|
# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen
|
||||||
|
# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter",
|
||||||
|
# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On",
|
||||||
|
# "Bands & Projects", "Load more", "Loading…", "Artist Scene",
|
||||||
|
# "No artists found for this scene.", "No album appearances found." and
|
||||||
|
# "Select an artist to see their albums." (period-only delta) already exist.
|
||||||
|
# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7,
|
||||||
|
# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them
|
||||||
|
# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every
|
||||||
|
# call site substitutes explicitly, e.g. .replace("{artist}", name).
|
||||||
|
|
||||||
|
msgid "Based on {artist} · {genres}"
|
||||||
|
msgstr "Gebaseerd op {artist} · {genres}"
|
||||||
|
|
||||||
|
msgid "Searching MusicBrainz for {genre} artists..."
|
||||||
|
msgstr "Zoeken naar {genre}-artiesten op MusicBrainz..."
|
||||||
|
|
||||||
|
msgid "Verifying availability on Qobuz..."
|
||||||
|
msgstr "Beschikbaarheid op Qobuz controleren..."
|
||||||
|
|
||||||
|
msgid "Preparing results..."
|
||||||
|
msgstr "Resultaten voorbereiden..."
|
||||||
|
|
||||||
|
msgid "No matching artists from this scene are currently available on Qobuz."
|
||||||
|
msgstr "Er zijn momenteel geen bijpassende artiesten uit deze scene beschikbaar op Qobuz."
|
||||||
|
|
||||||
|
msgid "Loading albums..."
|
||||||
|
msgstr "Albums laden..."
|
||||||
|
|
||||||
|
msgid "Failed to load albums"
|
||||||
|
msgstr "Albums laden mislukt"
|
||||||
|
|
||||||
|
msgid "Failed to load appearances"
|
||||||
|
msgstr "Bijdragen laden mislukt"
|
||||||
|
|
||||||
|
msgid "Looking up {name}..."
|
||||||
|
msgstr "{name} opzoeken..."
|
||||||
|
|
||||||
|
msgid "Failed to look up musician"
|
||||||
|
msgstr "Muzikant opzoeken mislukt"
|
||||||
|
|
||||||
|
msgid "Browse view"
|
||||||
|
msgstr "Bladerweergave"
|
||||||
|
|
||||||
|
msgid "Grid view"
|
||||||
|
msgstr "Rasterweergave"
|
||||||
|
|
||||||
|
msgid "Alphabetical (A-Z)"
|
||||||
|
msgstr "Alfabetisch (A-Z)"
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits but has limited catalog information available in Qobuz."
|
||||||
|
msgstr "Deze muzikant staat in de albumcredits, maar er is weinig catalogusinformatie beschikbaar op Qobuz."
|
||||||
|
|
||||||
|
msgid "Unable to verify this musician's catalog. The information shown is based on album credits."
|
||||||
|
msgstr "De catalogus van deze muzikant kon niet worden geverifieerd. De getoonde informatie komt uit de albumcredits."
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits."
|
||||||
|
msgstr "Deze muzikant staat in de albumcredits."
|
||||||
|
|
||||||
|
msgid "Discography"
|
||||||
|
msgstr "Discografie"
|
||||||
|
|
||||||
|
msgid "Live Albums"
|
||||||
|
msgstr "Livealbums"
|
||||||
|
|
||||||
|
msgid "Select an artist to see their albums"
|
||||||
|
msgstr "Selecteer een artiest om diens albums te zien"
|
||||||
|
|
||||||
|
msgid "{} / {} artists"
|
||||||
|
msgstr "{} / {} artiesten"
|
||||||
@@ -5111,3 +5111,75 @@ msgstr "Ver capa"
|
|||||||
|
|
||||||
msgid "Could not load the image"
|
msgid "Could not load the image"
|
||||||
msgstr "Não foi possível carregar a imagem"
|
msgstr "Não foi possível carregar a imagem"
|
||||||
|
|
||||||
|
# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's
|
||||||
|
# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen
|
||||||
|
# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter",
|
||||||
|
# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On",
|
||||||
|
# "Bands & Projects", "Load more", "Loading…", "Artist Scene",
|
||||||
|
# "No artists found for this scene.", "No album appearances found." and
|
||||||
|
# "Select an artist to see their albums." (period-only delta) already exist.
|
||||||
|
# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7,
|
||||||
|
# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them
|
||||||
|
# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every
|
||||||
|
# call site substitutes explicitly, e.g. .replace("{artist}", name).
|
||||||
|
|
||||||
|
msgid "Based on {artist} · {genres}"
|
||||||
|
msgstr "Baseado em {artist} · {genres}"
|
||||||
|
|
||||||
|
msgid "Searching MusicBrainz for {genre} artists..."
|
||||||
|
msgstr "Procurando artistas de {genre} no MusicBrainz..."
|
||||||
|
|
||||||
|
msgid "Verifying availability on Qobuz..."
|
||||||
|
msgstr "Verificando disponibilidade no Qobuz..."
|
||||||
|
|
||||||
|
msgid "Preparing results..."
|
||||||
|
msgstr "Preparando resultados..."
|
||||||
|
|
||||||
|
msgid "No matching artists from this scene are currently available on Qobuz."
|
||||||
|
msgstr "Nenhum artista correspondente desta cena está disponível no Qobuz no momento."
|
||||||
|
|
||||||
|
msgid "Loading albums..."
|
||||||
|
msgstr "Carregando álbuns..."
|
||||||
|
|
||||||
|
msgid "Failed to load albums"
|
||||||
|
msgstr "Falha ao carregar os álbuns"
|
||||||
|
|
||||||
|
msgid "Failed to load appearances"
|
||||||
|
msgstr "Falha ao carregar as participações"
|
||||||
|
|
||||||
|
msgid "Looking up {name}..."
|
||||||
|
msgstr "Procurando {name}..."
|
||||||
|
|
||||||
|
msgid "Failed to look up musician"
|
||||||
|
msgstr "Falha ao procurar o músico"
|
||||||
|
|
||||||
|
msgid "Browse view"
|
||||||
|
msgstr "Visualização de exploração"
|
||||||
|
|
||||||
|
msgid "Grid view"
|
||||||
|
msgstr "Visualização em grade"
|
||||||
|
|
||||||
|
msgid "Alphabetical (A-Z)"
|
||||||
|
msgstr "Alfabético (A-Z)"
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits but has limited catalog information available in Qobuz."
|
||||||
|
msgstr "Este músico aparece nos créditos do álbum, mas há poucas informações de catálogo disponíveis no Qobuz."
|
||||||
|
|
||||||
|
msgid "Unable to verify this musician's catalog. The information shown is based on album credits."
|
||||||
|
msgstr "Não foi possível verificar o catálogo deste músico. As informações exibidas vêm dos créditos do álbum."
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits."
|
||||||
|
msgstr "Este músico aparece nos créditos do álbum."
|
||||||
|
|
||||||
|
msgid "Discography"
|
||||||
|
msgstr "Discografia"
|
||||||
|
|
||||||
|
msgid "Live Albums"
|
||||||
|
msgstr "Álbuns ao vivo"
|
||||||
|
|
||||||
|
msgid "Select an artist to see their albums"
|
||||||
|
msgstr "Selecione um artista para ver os seus álbuns"
|
||||||
|
|
||||||
|
msgid "{} / {} artists"
|
||||||
|
msgstr "{} / {} artistas"
|
||||||
@@ -5128,3 +5128,75 @@ msgstr "Показать обложку"
|
|||||||
|
|
||||||
msgid "Could not load the image"
|
msgid "Could not load the image"
|
||||||
msgstr "Не удалось загрузить изображение"
|
msgstr "Не удалось загрузить изображение"
|
||||||
|
|
||||||
|
# ArtistScene + MusicianPage (Qt port, 2026-08-14 contract §7). Copy is Tauri's
|
||||||
|
# verbatim: ASCII "..." (not U+2026), U+00B7 in the hero subtitle, ASCII hyphen
|
||||||
|
# in "(A-Z)". Reused rather than twinned — "Filter by genre", "Clear filter",
|
||||||
|
# "No albums found", "Off", "Group: A-Z", "Group: Off", "Scene", "Appears On",
|
||||||
|
# "Bands & Projects", "Load more", "Loading…", "Artist Scene",
|
||||||
|
# "No artists found for this scene.", "No album appearances found." and
|
||||||
|
# "Select an artist to see their albums." (period-only delta) already exist.
|
||||||
|
# The {artist}/{genres}/{genre}/{name} placeholders are NAMED, per contract §7,
|
||||||
|
# and are byte-identical in all eight locales. qbz_i18n::t_args CANNOT fill them
|
||||||
|
# (substitute() at qbz-i18n/src/lib.rs:153-170 only matches a bare "{}") — every
|
||||||
|
# call site substitutes explicitly, e.g. .replace("{artist}", name).
|
||||||
|
|
||||||
|
msgid "Based on {artist} · {genres}"
|
||||||
|
msgstr "На основе {artist} · {genres}"
|
||||||
|
|
||||||
|
msgid "Searching MusicBrainz for {genre} artists..."
|
||||||
|
msgstr "Поиск исполнителей жанра {genre} в MusicBrainz..."
|
||||||
|
|
||||||
|
msgid "Verifying availability on Qobuz..."
|
||||||
|
msgstr "Проверка доступности в Qobuz..."
|
||||||
|
|
||||||
|
msgid "Preparing results..."
|
||||||
|
msgstr "Подготовка результатов..."
|
||||||
|
|
||||||
|
msgid "No matching artists from this scene are currently available on Qobuz."
|
||||||
|
msgstr "Подходящих исполнителей из этой сцены сейчас нет в Qobuz."
|
||||||
|
|
||||||
|
msgid "Loading albums..."
|
||||||
|
msgstr "Загрузка альбомов..."
|
||||||
|
|
||||||
|
msgid "Failed to load albums"
|
||||||
|
msgstr "Не удалось загрузить альбомы"
|
||||||
|
|
||||||
|
msgid "Failed to load appearances"
|
||||||
|
msgstr "Не удалось загрузить участия"
|
||||||
|
|
||||||
|
msgid "Looking up {name}..."
|
||||||
|
msgstr "Поиск {name}..."
|
||||||
|
|
||||||
|
msgid "Failed to look up musician"
|
||||||
|
msgstr "Не удалось найти музыканта"
|
||||||
|
|
||||||
|
msgid "Browse view"
|
||||||
|
msgstr "Режим обзора"
|
||||||
|
|
||||||
|
msgid "Grid view"
|
||||||
|
msgstr "Режим сетки"
|
||||||
|
|
||||||
|
msgid "Alphabetical (A-Z)"
|
||||||
|
msgstr "По алфавиту (А–Я)"
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits but has limited catalog information available in Qobuz."
|
||||||
|
msgstr "Этот музыкант указан в авторах альбома, но в Qobuz доступно мало информации о его каталоге."
|
||||||
|
|
||||||
|
msgid "Unable to verify this musician's catalog. The information shown is based on album credits."
|
||||||
|
msgstr "Не удалось проверить каталог этого музыканта. Показанная информация основана на данных об авторах альбома."
|
||||||
|
|
||||||
|
msgid "This musician appears in album credits."
|
||||||
|
msgstr "Этот музыкант указан в авторах альбома."
|
||||||
|
|
||||||
|
msgid "Discography"
|
||||||
|
msgstr "Дискография"
|
||||||
|
|
||||||
|
msgid "Live Albums"
|
||||||
|
msgstr "Концертные альбомы"
|
||||||
|
|
||||||
|
msgid "Select an artist to see their albums"
|
||||||
|
msgstr "Выберите исполнителя, чтобы увидеть его альбомы"
|
||||||
|
|
||||||
|
msgid "{} / {} artists"
|
||||||
|
msgstr "{} / {} исполнителей"
|
||||||
Reference in new issue
Block a user