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:
vicrodh committed 2026-08-14 19:25:11 -06:00
1 parent b045f7023b
commit 016eb3cd23
37 files changed
+9203 -83

No files matched your search

+518 -21
View File
@@ -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)
} }
} }
+6
View File
@@ -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()?;
+7 -1
View File
@@ -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",
+2
View File
@@ -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()
+11 -1
View File
@@ -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
+14
View File
@@ -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
+12
View File
@@ -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
+577
View File
@@ -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
}
}
}
}
+14 -3
View File
@@ -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
+99 -7
View File
@@ -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
}
}
}
}
+96 -3
View File
@@ -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
+19
View File
@@ -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();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+5
View File
@@ -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 => {
+60 -20
View File
@@ -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
View File
@@ -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
+184
View File
@@ -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();
}
}
+800
View File
@@ -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();
}
}
+9 -1
View File
@@ -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.
+170 -7
View File
@@ -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.
+228
View File
@@ -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();
}
}
+10
View File
@@ -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 "{} / {} исполнителей"