feat(slint): origin section + MB metadata wiring on artist navigate
Wires the artist controller to call QbzCore's MusicBrainz orchestration
and feed the Origin section of the network sidebar, plus typed click
callbacks for every section (logged-only until the target views land).
Controller (`crates/qbz-slint/src/artist.rs`):
- reset_network_sidebar() — clears sidebar MB state on artist change.
- load_mb_metadata(runtime, name) — checks the client's enabled flag,
resolves name -> mbid via musicbrainz_resolve_artist, fetches
metadata via musicbrainz_get_artist_metadata, maps into MbOrigin
(is_person derived from artist_type, dates formatted as "1976",
"May 1976" or "May 14, 1976" matching Tauri's en-US output).
- apply_mb_metadata() / apply_mb_unavailable() — push into
NetworkSidebarState on the event loop.
main.rs:
- navigate_artist now resets the sidebar before loading and spawns
the MB enrichment in parallel with artwork. Origin loading flag
flips to true before the resolve, back to false on success or
on MB-unavailable.
- NetworkSidebarActions click callbacks (location, label, artist,
musician, discovery dismiss) wired to log() with full payload —
placeholder until ArtistsByLocation/LabelReleases/MusicianPage land
in Slint.
ArtistPageView.slint:
- Sidebar body becomes a Flickable + VerticalLayout (so the upcoming
Labels / Similar / Relationships / Discovery sections can overflow
cleanly).
- Origin section renders three optional rows — Born/Founded,
Born in/Founded in, Died/Disbanded — with the same uppercased
label + right-aligned value pattern Tauri uses. The location row
is clickable when the location precision is finer than country
or a city is present; click emits NetworkSidebarActions.location-
clicked with the artist's mbid.
- Section is gated by NetworkSidebarState.mb-available, so a
disabled MB integration or an unmatched artist hides Origin
entirely (next phases fill Labels/Similar with Qobuz data that
does not depend on MB).
Adds qbz-integrations as a direct dep of qbz-slint so the controller
can match on ArtistType / LocationPrecision without going through a
re-export shim.
cc
This commit is contained in:
5 files changed
+382
-3
No files matched your search
Generated
+1
@@ -5160,6 +5160,7 @@ dependencies = [
|
||||
"qbz-cache",
|
||||
"qbz-core",
|
||||
"qbz-credentials",
|
||||
"qbz-integrations",
|
||||
"qbz-models",
|
||||
"reqwest",
|
||||
"serde",
|
||||
|
||||
@@ -37,6 +37,7 @@ qbz-app = { path = "../qbz-app" }
|
||||
qbz-audio = { path = "../qbz-audio" }
|
||||
qbz-core = { path = "../qbz-core" }
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
qbz-integrations = { path = "../qbz-integrations" }
|
||||
qbz-cache = { path = "../qbz-cache" }
|
||||
qbz-credentials = { path = "../qbz-credentials" }
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ use slint::{ComponentHandle, ModelRc, VecModel};
|
||||
use crate::album::TrackData;
|
||||
use crate::artwork::{ArtworkJob, ArtworkTarget};
|
||||
use crate::home::CardData;
|
||||
use crate::{AlbumCardItem, AlbumTrackItem, AppWindow, ArtistState, DiscoverSection};
|
||||
use crate::{
|
||||
AlbumCardItem, AlbumTrackItem, AppWindow, ArtistState, DiscoverSection, MbOriginData,
|
||||
NetworkSidebarState,
|
||||
};
|
||||
|
||||
/// Plain, `Send` artist data produced on the worker thread.
|
||||
pub struct ArtistData {
|
||||
@@ -455,3 +458,177 @@ pub fn apply_artwork(window: &AppWindow, pixels: &[u8], width: u32, height: u32)
|
||||
state.set_artwork(slint::Image::from_rgba8(buffer));
|
||||
state.set_header_color(slint::Color::from_rgb_u8(r, g, b));
|
||||
}
|
||||
|
||||
// ----- MusicBrainz network sidebar ---------------------------------------
|
||||
|
||||
/// Plain, `Send` payload mapping `qbz_integrations::ArtistMetadata` into
|
||||
/// the shape the Origin section of the sidebar renders.
|
||||
pub struct MbMetadata {
|
||||
pub mbid: String,
|
||||
pub origin: MbOrigin,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MbOrigin {
|
||||
pub is_person: bool,
|
||||
pub begin_date: String,
|
||||
pub end_date: String,
|
||||
pub location_display: String,
|
||||
pub location_clickable: bool,
|
||||
}
|
||||
|
||||
/// Reset the network sidebar's MB-driven state. Called when the artist
|
||||
/// changes so a stale Origin/Relationships/Discovery never bleeds
|
||||
/// across artists.
|
||||
pub fn reset_network_sidebar(window: &AppWindow) {
|
||||
let state = window.global::<NetworkSidebarState>();
|
||||
state.set_mb_available(true);
|
||||
state.set_mb_mbid("".into());
|
||||
state.set_origin_loading(false);
|
||||
state.set_origin(MbOriginData::default());
|
||||
state.set_relationships_loading(false);
|
||||
state.set_discovery_loading(false);
|
||||
}
|
||||
|
||||
/// Resolve the artist name to an MBID, then fetch artist metadata. Returns
|
||||
/// `Ok(None)` when MB is disabled or no confident match is found — the
|
||||
/// sidebar treats both the same (Origin section hides).
|
||||
pub async fn load_mb_metadata<A>(
|
||||
runtime: &Arc<AppRuntime<A>>,
|
||||
artist_name: &str,
|
||||
) -> Result<Option<MbMetadata>, String>
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
if !runtime.core().musicbrainz_is_enabled().await {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let resolved = runtime
|
||||
.core()
|
||||
.musicbrainz_resolve_artist(artist_name)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some(resolved) = resolved else {
|
||||
return Ok(None);
|
||||
};
|
||||
// resolve_artist may return a low-confidence match; surface only the
|
||||
// mbid when the client gives us one (the qbz-integrations layer
|
||||
// already filters out the "no match at all" case before returning).
|
||||
let mbid = resolved.mbid;
|
||||
if mbid.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let meta = runtime
|
||||
.core()
|
||||
.musicbrainz_get_artist_metadata(&mbid)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Some(MbMetadata {
|
||||
mbid: mbid.clone(),
|
||||
origin: map_origin(&meta),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_origin(meta: &qbz_integrations::musicbrainz::ArtistMetadata) -> MbOrigin {
|
||||
use qbz_integrations::musicbrainz::{ArtistType, LocationPrecision};
|
||||
|
||||
let is_person = matches!(meta.artist_type, ArtistType::Person);
|
||||
|
||||
let begin_date = meta
|
||||
.life_span
|
||||
.as_ref()
|
||||
.and_then(|ls| ls.begin.as_deref().map(format_mb_date_short))
|
||||
.unwrap_or_default();
|
||||
let end_date = meta
|
||||
.life_span
|
||||
.as_ref()
|
||||
.and_then(|ls| ls.end.as_deref().map(format_mb_date_short))
|
||||
.unwrap_or_default();
|
||||
|
||||
let (location_display, location_clickable) = match &meta.location {
|
||||
Some(loc) => {
|
||||
// Tauri's gate: clickable when precision isn't "country" OR
|
||||
// a city is present somehow. Country-only locations stay as
|
||||
// plain text — there's nothing to drill into.
|
||||
let clickable = !matches!(loc.precision, LocationPrecision::Country)
|
||||
|| loc.city.is_some();
|
||||
(loc.display_name.clone(), clickable)
|
||||
}
|
||||
None => (String::new(), false),
|
||||
};
|
||||
|
||||
MbOrigin {
|
||||
is_person,
|
||||
begin_date,
|
||||
end_date,
|
||||
location_display,
|
||||
location_clickable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the MB metadata to NetworkSidebarState. Runs on the Slint
|
||||
/// event loop.
|
||||
pub fn apply_mb_metadata(window: &AppWindow, meta: MbMetadata) {
|
||||
let state = window.global::<NetworkSidebarState>();
|
||||
state.set_mb_mbid(meta.mbid.into());
|
||||
state.set_origin(MbOriginData {
|
||||
is_person: meta.origin.is_person,
|
||||
begin_date: meta.origin.begin_date.into(),
|
||||
end_date: meta.origin.end_date.into(),
|
||||
location_display: meta.origin.location_display.into(),
|
||||
location_clickable: meta.origin.location_clickable,
|
||||
});
|
||||
state.set_origin_loading(false);
|
||||
}
|
||||
|
||||
/// Mark the sidebar as MB-unavailable (disabled in settings, or no
|
||||
/// confident match for this artist). The MB-driven sections hide.
|
||||
pub fn apply_mb_unavailable(window: &AppWindow) {
|
||||
let state = window.global::<NetworkSidebarState>();
|
||||
state.set_mb_available(false);
|
||||
state.set_origin_loading(false);
|
||||
state.set_relationships_loading(false);
|
||||
state.set_discovery_loading(false);
|
||||
}
|
||||
|
||||
/// Format a MusicBrainz partial date into a short human string —
|
||||
/// "1990", "May 1990", or "May 14, 1990" — matching Tauri's
|
||||
/// formatMbDate_v2 output when the locale is en-US.
|
||||
fn format_mb_date_short(date: &str) -> String {
|
||||
let parts: Vec<&str> = date.split('-').collect();
|
||||
let month = |m: &str| -> Option<&'static str> {
|
||||
Some(match m {
|
||||
"01" => "January",
|
||||
"02" => "February",
|
||||
"03" => "March",
|
||||
"04" => "April",
|
||||
"05" => "May",
|
||||
"06" => "June",
|
||||
"07" => "July",
|
||||
"08" => "August",
|
||||
"09" => "September",
|
||||
"10" => "October",
|
||||
"11" => "November",
|
||||
"12" => "December",
|
||||
_ => return None,
|
||||
})
|
||||
};
|
||||
match parts.as_slice() {
|
||||
[y] => (*y).to_string(),
|
||||
[y, m] => match month(m) {
|
||||
Some(name) => format!("{} {}", name, y),
|
||||
None => date.to_string(),
|
||||
},
|
||||
[y, m, d] => match month(m) {
|
||||
Some(name) => {
|
||||
let day = d.trim_start_matches('0');
|
||||
format!("{} {}, {}", name, day, y)
|
||||
}
|
||||
None => date.to_string(),
|
||||
},
|
||||
_ => date.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -223,17 +223,49 @@ fn navigate_artist(
|
||||
handle.spawn(async move {
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
artist::reset_artist(&w);
|
||||
artist::reset_network_sidebar(&w);
|
||||
w.global::<NavState>().set_view(ContentView::Artist);
|
||||
});
|
||||
match artist::load_artist(&runtime, &artist_id).await {
|
||||
Ok(data) => {
|
||||
let artwork_url = data.artwork_url.clone();
|
||||
let jobs = artist::artwork_jobs(&data);
|
||||
let artist_name = data.name.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
artist::apply_artist(&w, data);
|
||||
w.global::<ArtistState>().set_loading(false);
|
||||
});
|
||||
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
||||
|
||||
// Network sidebar — kick the MB enrichment off in
|
||||
// parallel with artwork. Origin section shows a loading
|
||||
// state until the MB resolve + metadata calls return.
|
||||
let runtime_mb = runtime.clone();
|
||||
let weak_mb = weak.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = weak_mb.upgrade_in_event_loop(|w| {
|
||||
w.global::<NetworkSidebarState>().set_origin_loading(true);
|
||||
});
|
||||
match artist::load_mb_metadata(&runtime_mb, &artist_name).await {
|
||||
Ok(Some(meta)) => {
|
||||
let _ = weak_mb.upgrade_in_event_loop(move |w| {
|
||||
artist::apply_mb_metadata(&w, meta);
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = weak_mb.upgrade_in_event_loop(|w| {
|
||||
artist::apply_mb_unavailable(&w);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] MB metadata load failed: {e}");
|
||||
let _ = weak_mb.upgrade_in_event_loop(|w| {
|
||||
artist::apply_mb_unavailable(&w);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if !artwork_url.is_empty() {
|
||||
if let Some((pixels, width, height)) =
|
||||
artwork::fetch_and_decode(&artwork_url, &image_cache, 440).await
|
||||
@@ -1106,6 +1138,38 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Network sidebar — typed click callbacks. Each delivers the
|
||||
// minimum payload the future target views (ArtistsByLocation,
|
||||
// LabelReleases, MusicianPage) will need. Logged-only until those
|
||||
// views land in Slint.
|
||||
window
|
||||
.global::<NetworkSidebarActions>()
|
||||
.on_location_clicked(|mbid| {
|
||||
log::info!("[qbz-slint] network sidebar: location clicked for mbid={mbid}");
|
||||
});
|
||||
window
|
||||
.global::<NetworkSidebarActions>()
|
||||
.on_label_clicked(|id, name| {
|
||||
log::info!("[qbz-slint] network sidebar: label clicked id={id} name={name}");
|
||||
});
|
||||
window
|
||||
.global::<NetworkSidebarActions>()
|
||||
.on_artist_clicked(|id| {
|
||||
log::info!("[qbz-slint] network sidebar: artist row clicked qobuz_id={id}");
|
||||
});
|
||||
window
|
||||
.global::<NetworkSidebarActions>()
|
||||
.on_musician_clicked(|name, role| {
|
||||
log::info!(
|
||||
"[qbz-slint] network sidebar: musician clicked name={name} role={role}"
|
||||
);
|
||||
});
|
||||
window
|
||||
.global::<NetworkSidebarActions>()
|
||||
.on_discovery_dismissed(|mbid| {
|
||||
log::info!("[qbz-slint] network sidebar: discovery dismissed mbid={mbid}");
|
||||
});
|
||||
|
||||
window.on_close_app(|| {
|
||||
log::info!("[qbz-slint] closing");
|
||||
let _ = slint::quit_event_loop();
|
||||
|
||||
@@ -630,9 +630,145 @@ export component ArtistPageView inherits Rectangle {
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
|
||||
// Body — placeholder. Sections land in phases 5-8.
|
||||
Rectangle {
|
||||
// Body — sections: Origin (MB), Labels, Similar, MB
|
||||
// Relationships, Discovery. Wrapped in a Flickable so a
|
||||
// long sidebar (Labels + Relationships + Discovery) can
|
||||
// scroll. Phase 5 adds Origin only; phases 6-8 fill the
|
||||
// rest.
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-width: self.width;
|
||||
viewport-height: sidebar-body.preferred-height;
|
||||
|
||||
sidebar-body := VerticalLayout {
|
||||
spacing: 0px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 12px;
|
||||
alignment: start;
|
||||
|
||||
// --- Origin (MB metadata) ----------------------
|
||||
// Mirrors Tauri's .sidebar-section.sidebar-origin
|
||||
// and the gate `mbAvailable && (loading || data)`.
|
||||
if NetworkSidebarState.mb-available
|
||||
&& (NetworkSidebarState.origin-loading
|
||||
|| NetworkSidebarState.origin.begin-date != ""
|
||||
|| NetworkSidebarState.origin.end-date != ""
|
||||
|| NetworkSidebarState.origin.location-display != ""):
|
||||
VerticalLayout {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
spacing: 6px;
|
||||
|
||||
if NetworkSidebarState.origin-loading: Text {
|
||||
text: @tr("Loading origin…");
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// Born / Founded
|
||||
if !NetworkSidebarState.origin-loading
|
||||
&& NetworkSidebarState.origin.begin-date != "":
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: stretch;
|
||||
Text {
|
||||
text: NetworkSidebarState.origin.is-person
|
||||
? @tr("BORN")
|
||||
: @tr("FOUNDED");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 0;
|
||||
}
|
||||
Text {
|
||||
text: NetworkSidebarState.origin.begin-date;
|
||||
color: Theme.text-primary;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Born in / Founded in — optionally clickable
|
||||
if !NetworkSidebarState.origin-loading
|
||||
&& NetworkSidebarState.origin.location-display != "":
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: stretch;
|
||||
Text {
|
||||
text: NetworkSidebarState.origin.is-person
|
||||
? @tr("BORN IN")
|
||||
: @tr("FOUNDED IN");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 0;
|
||||
}
|
||||
if NetworkSidebarState.origin.location-clickable: Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
location-text := Text {
|
||||
text: NetworkSidebarState.origin.location-display;
|
||||
color: location-ta.has-hover
|
||||
? Theme.accent
|
||||
: Theme.text-primary;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
width: 100%;
|
||||
}
|
||||
location-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
NetworkSidebarActions.location-clicked(
|
||||
NetworkSidebarState.mb-mbid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !NetworkSidebarState.origin.location-clickable: Text {
|
||||
text: NetworkSidebarState.origin.location-display;
|
||||
color: Theme.text-primary;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Died / Disbanded
|
||||
if !NetworkSidebarState.origin-loading
|
||||
&& NetworkSidebarState.origin.end-date != "":
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: stretch;
|
||||
Text {
|
||||
text: NetworkSidebarState.origin.is-person
|
||||
? @tr("DIED")
|
||||
: @tr("DISBANDED");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 0;
|
||||
}
|
||||
Text {
|
||||
text: NetworkSidebarState.origin.end-date;
|
||||
color: Theme.text-primary;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user