feat(slint): ArtistsByLocationView (Port 3 complete — third click target)

Wires the Origin section's location link to the scene-discovery
view. Completes the three artist-network click targets (Musician,
Label, Location).

Slint:
  - ContentView gains `location`; NavEntry mirrors with the full
    `Location { mbid, area_id, area_name, country, genres, tags }`
    payload so back/forward replays the scene.
  - LocationViewState / LocationViewActions globals carry the scene
    header (label + genre summary) and a paginated artist grid
    (LocationArtistItem: qobuz id, name, genres line, image).
  - New `ui/location/ArtistsByLocationView.slint`: a scene header
    (map-pin + location + genre summary) over a wrapping round-
    portrait artist grid with load-more. open-artist routes the
    card click to the artist page.
  - AppShell routes ContentView.location; new map-pin.svg (Lucide).

Rust:
  - `crate::location_view` controller — load_scene, apply_scene,
    append_scene, reset_scene, artwork_jobs. Maps LocationCandidate
    rows to the grid (qobuz_name fallback to mb_name).
  - artwork::ArtworkTarget gains LocationArtist { index }.
  - The Origin metadata load now caches LocationParams (area +
    affinity seeds) in a cross-thread Mutex; reset_network_sidebar
    clears it so a stale click can't fire for the wrong artist.
    artist::location_params() exposes it to the click handler.
  - main.rs: navigate_location + apply_entry route +
    on_location_clicked (reads the cached params) +
    LocationViewActions open-artist / load-more.

Backend (prior commit) supplied discover_artists_by_location;
this commit is the Slint + wiring layer.

cc
This commit is contained in:
vicrodh committed 2026-05-20 20:31:08 -06:00
1 parent 6d68e47daf
commit cdb93341d0
11 files changed
+595 -7

No files matched your search

+51
View File
@@ -707,6 +707,11 @@ pub struct MbOrigin {
/// sidebar always starts open on every artist visit (per user policy
/// — close is per-session, never persisted).
pub fn reset_network_sidebar(window: &AppWindow) {
// Drop the previous artist's cached location params so a stale
// scene-view click can't fire for the wrong artist.
if let Ok(mut guard) = LOCATION_PARAMS.lock() {
*guard = None;
}
let state = window.global::<NetworkSidebarState>();
state.set_open(true);
state.set_mb_available(true);
@@ -753,12 +758,58 @@ where
.await
.map_err(|e| e.to_string())?;
// Cache the location params so the Origin location click can
// open ArtistsByLocationView without re-resolving. Stored in a
// cross-thread Mutex because the click handler runs on the UI
// thread while this loads on a worker.
store_location_params(&mbid, &meta);
Ok(Some(MbMetadata {
mbid: mbid.clone(),
origin: map_origin(&meta),
}))
}
/// Location parameters for the "artists from the same place" scene
/// view, captured from the Origin metadata. None until an artist's
/// metadata with a location resolves.
#[derive(Clone, Default)]
pub struct LocationParams {
pub mbid: String,
pub area_id: String,
pub area_name: String,
pub country: String,
pub genres: Vec<String>,
pub tags: Vec<String>,
}
static LOCATION_PARAMS: std::sync::Mutex<Option<LocationParams>> =
std::sync::Mutex::new(None);
fn store_location_params(mbid: &str, meta: &qbz_integrations::musicbrainz::ArtistMetadata) {
let params = meta.location.as_ref().map(|loc| LocationParams {
mbid: mbid.to_string(),
area_id: loc.area_id.clone().unwrap_or_default(),
area_name: loc
.city
.clone()
.filter(|c| !c.is_empty())
.unwrap_or_else(|| loc.display_name.clone()),
country: loc.country.clone().unwrap_or_default(),
genres: meta.affinity_seeds.genres.clone(),
tags: meta.affinity_seeds.tags.clone(),
});
if let Ok(mut guard) = LOCATION_PARAMS.lock() {
*guard = params;
}
}
/// The location params for the currently loaded artist, if it has a
/// resolved MB location. Read by the Origin location-click handler.
pub fn location_params() -> Option<LocationParams> {
LOCATION_PARAMS.lock().ok().and_then(|g| g.clone())
}
fn map_origin(meta: &qbz_integrations::musicbrainz::ArtistMetadata) -> MbOrigin {
use qbz_integrations::musicbrainz::{ArtistType, LocationPrecision};
+9
View File
@@ -56,6 +56,8 @@ pub enum ArtworkTarget {
MusicianAppearance { index: usize },
/// A card in LabelState.albums[index].
LabelAlbum { index: usize },
/// A card in LocationViewState.artists[index].
LocationArtist { index: usize },
}
/// An artwork download job: which card, and the image URL.
@@ -358,5 +360,12 @@ fn apply_artwork(
model.set_row_data(index, item);
}
}
ArtworkTarget::LocationArtist { index } => {
let model = window.global::<crate::LocationViewState>().get_artists();
if let Some(mut item) = model.row_data(index) {
item.image = image;
model.set_row_data(index, item);
}
}
}
}
+134
View File
@@ -0,0 +1,134 @@
//! ArtistsByLocationView controller — runs the scene discovery for a
//! source artist's location and pushes the validated artist grid into
//! `LocationViewState`. Mirrors Tauri's ArtistsByLocationView.svelte
//! (minus the in-progress event stream, which the Slint port replaces
//! with a simple loading flag).
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::artist::LocationParams;
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::{AppWindow, LocationArtistItem, LocationViewState};
/// Validation page size — how many MB candidates to validate against
/// Qobuz per call. Matches the Tauri view's LIMIT.
pub const PAGE_SIZE: usize = 30;
pub struct LocationData {
pub scene_label: String,
pub genre_summary: String,
pub artists: Vec<ArtistCard>,
pub total: usize,
}
#[derive(Clone)]
pub struct ArtistCard {
pub qobuz_id: String,
pub name: String,
pub genres_line: String,
pub image_url: String,
}
fn map_candidate(c: qbz_integrations::musicbrainz::LocationCandidate) -> ArtistCard {
ArtistCard {
qobuz_id: c.qobuz_id.map(|id| id.to_string()).unwrap_or_default(),
name: c.qobuz_name.unwrap_or(c.mb_name),
genres_line: c.genres.join(" · "),
image_url: c.qobuz_image.unwrap_or_default(),
}
}
/// Run the first page of scene discovery for `params`.
pub async fn load_scene<A>(
runtime: &Arc<AppRuntime<A>>,
params: &LocationParams,
offset: usize,
) -> Result<LocationData, String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let area_id = (!params.area_id.is_empty()).then_some(params.area_id.as_str());
let country = (!params.country.is_empty()).then_some(params.country.as_str());
let response = runtime
.core()
.discover_artists_by_location(
&params.mbid,
area_id,
&params.area_name,
country,
params.genres.clone(),
params.tags.clone(),
PAGE_SIZE,
offset,
)
.await
.map_err(|e| e.to_string())?;
let _ = response.next_offset; // offset is recomputed from row_count
Ok(LocationData {
scene_label: response.scene_label,
genre_summary: response.genre_summary,
artists: response.artists.into_iter().map(map_candidate).collect(),
total: response.total_candidates,
})
}
fn to_item(card: ArtistCard) -> LocationArtistItem {
LocationArtistItem {
qobuz_id: card.qobuz_id.into(),
name: card.name.into(),
genres_line: card.genres_line.into(),
image_url: card.image_url.into(),
image: slint::Image::default(),
}
}
pub fn apply_scene(window: &AppWindow, data: LocationData) {
let items: Vec<LocationArtistItem> = data.artists.into_iter().map(to_item).collect();
let state = window.global::<LocationViewState>();
state.set_scene_label(data.scene_label.into());
state.set_genre_summary(data.genre_summary.into());
state.set_artists(ModelRc::new(VecModel::from(items)));
state.set_total(data.total as i32);
state.set_loading(false);
}
pub fn append_scene(window: &AppWindow, artists: Vec<ArtistCard>, total: usize) {
let state = window.global::<LocationViewState>();
let model = state.get_artists();
let mut combined: Vec<LocationArtistItem> = (0..model.row_count())
.filter_map(|i| model.row_data(i))
.collect();
combined.extend(artists.into_iter().map(to_item));
state.set_artists(ModelRc::new(VecModel::from(combined)));
state.set_total(total as i32);
state.set_load_more_loading(false);
}
pub fn reset_scene(window: &AppWindow) {
let state = window.global::<LocationViewState>();
state.set_scene_label("".into());
state.set_genre_summary("".into());
state.set_artists(ModelRc::new(VecModel::from(Vec::<LocationArtistItem>::new())));
state.set_total(0);
state.set_loading(true);
state.set_load_more_loading(false);
}
/// Artwork jobs for the scene artist grid (the candidates' Qobuz
/// thumbnails).
pub fn artwork_jobs(data: &LocationData) -> Vec<ArtworkJob> {
data.artists
.iter()
.enumerate()
.filter(|(_, a)| !a.image_url.is_empty())
.map(|(i, a)| ArtworkJob {
url: a.image_url.clone(),
target: ArtworkTarget::LocationArtist { index: i },
})
.collect()
}
+152 -2
View File
@@ -24,6 +24,7 @@ mod custom_artwork;
mod discovery_dismiss;
mod home;
mod label;
mod location_view;
mod musician;
mod nav;
mod play_history;
@@ -428,7 +429,58 @@ fn apply_entry(
nav::NavEntry::Label { id, name } => {
navigate_label(runtime.clone(), weak.clone(), handle, image_cache.clone(), id, name);
}
nav::NavEntry::Location {
mbid,
area_id,
area_name,
country,
genres,
tags,
} => {
let params = artist::LocationParams {
mbid,
area_id,
area_name,
country,
genres,
tags,
};
navigate_location(runtime.clone(), weak.clone(), handle, image_cache.clone(), params);
}
}
}
/// Open an ArtistsByLocationView for the given scene params. Runs the
/// discovery on a worker, applies the validated artist grid, then
/// fans out artwork jobs for the candidates' Qobuz thumbnails.
fn navigate_location(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: artwork::ImageCache,
params: artist::LocationParams,
) {
handle.spawn(async move {
let _ = weak.upgrade_in_event_loop(|w| {
location_view::reset_scene(&w);
w.global::<NavState>().set_view(ContentView::Location);
});
match location_view::load_scene(&runtime, &params, 0).await {
Ok(data) => {
let jobs = location_view::artwork_jobs(&data);
let _ = weak.upgrade_in_event_loop(move |w| {
location_view::apply_scene(&w, data);
});
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
}
Err(e) => {
log::error!("[qbz-slint] scene discovery failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LocationViewState>().set_loading(false);
});
}
}
});
}
/// Open a LabelReleasesView for `label_id`. Fetches the label header
@@ -1320,11 +1372,42 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// minimum payload the future target views (ArtistsByLocation,
// LabelReleases, MusicianPage) will need. Logged-only until those
// views land in Slint.
// Location click — open ArtistsByLocationView using the cached
// location params from the Origin metadata (area, genres, tags).
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<NetworkSidebarActions>()
.on_location_clicked(|mbid| {
log::info!("[qbz-slint] network sidebar: location clicked for mbid={mbid}");
.on_location_clicked(move |mbid| {
let Some(params) = artist::location_params() else {
log::warn!(
"[qbz-slint] location clicked but no cached params (mbid={mbid})"
);
return;
};
nav::record(nav::NavEntry::Location {
mbid: params.mbid.clone(),
area_id: params.area_id.clone(),
area_name: params.area_name.clone(),
country: params.country.clone(),
genres: params.genres.clone(),
tags: params.tags.clone(),
});
navigate_location(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
params,
);
if let Some(w) = weak.upgrade() {
update_nav_flags(&w);
}
});
}
// Label click — open LabelReleasesView.
{
let runtime = app_runtime.clone();
@@ -1573,6 +1656,73 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Scene (location) view actions — open-artist routes to the
// artist page, load-more validates the next page of candidates.
{
let weak = window.as_weak();
window
.global::<LocationViewActions>()
.on_open_artist(move |id| {
if id.is_empty() {
return;
}
if let Some(w) = weak.upgrade() {
w.invoke_open_artist(id);
}
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<LocationViewActions>()
.on_load_more(move || {
let Some(w) = weak.upgrade() else {
return;
};
let Some(params) = artist::location_params() else {
return;
};
let offset = w.global::<LocationViewState>().get_artists().row_count();
w.global::<LocationViewState>().set_load_more_loading(true);
let runtime = runtime.clone();
let weak = weak.clone();
let image_cache = image_cache.clone();
handle.spawn(async move {
match location_view::load_scene(&runtime, &params, offset).await {
Ok(data) => {
let jobs: Vec<artwork::ArtworkJob> = data
.artists
.iter()
.enumerate()
.filter(|(_, a)| !a.image_url.is_empty())
.map(|(i, a)| artwork::ArtworkJob {
url: a.image_url.clone(),
target: artwork::ArtworkTarget::LocationArtist {
index: offset + i,
},
})
.collect();
let total = data.total;
let artists = data.artists.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
location_view::append_scene(&w, artists, total);
});
artwork::spawn_loads(jobs, weak, image_cache);
}
Err(e) => {
log::error!("[qbz-slint] scene load-more failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LocationViewState>().set_load_more_loading(false);
});
}
}
});
});
}
// Artwork right-click menu wiring — Open in browser / Save as /
// Add custom / Remove custom. Mirrors the v2_library_* + native
// dialog flow Tauri uses on artist portraits + album covers.
-3
View File
@@ -29,7 +29,6 @@ pub struct MusicianData {
pub confidence: MusicianConfidence,
pub appearances: Vec<AppearanceData>,
pub total: usize,
pub offset: u32,
}
#[derive(Clone)]
@@ -75,7 +74,6 @@ where
artwork_url: a.album_artwork,
})
.collect();
let count = appearances.len() as u32;
Ok(MusicianData {
name: resolved.name,
@@ -83,7 +81,6 @@ where
confidence: resolved.confidence,
appearances,
total: page.total,
offset: count,
})
}
+10
View File
@@ -32,6 +32,16 @@ pub enum NavEntry {
id: u64,
name: String,
},
/// ArtistsByLocationView — opened by the Origin section's
/// location link. Carries the full scene-discovery payload.
Location {
mbid: String,
area_id: String,
area_name: String,
country: String,
genres: Vec<String>,
tags: Vec<String>,
},
}
struct History {
+1 -1
View File
@@ -17,7 +17,7 @@ import { LoginScreen } from "login/LoginScreen.slint";
import { AppShell } from "shell/AppShell.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions } from "state.slint";
export { HomeState, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1,201 @@
// ArtistsByLocationView — "artists from the same place" scene
// discovery, opened from the Origin section's location link. Mirrors
// Tauri's ArtistsByLocationView.svelte core: a scene header
// (location + genre summary) over a wrapping artist grid, with
// load-more pagination. The progress bar / live event stream from
// the Tauri view is replaced by a single loading flag.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { LocationViewState, LocationViewActions, LocationArtistItem } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { NavButtons } from "../shell/NavButtons.slint";
component SceneArtistCard inherits Rectangle {
in property <LocationArtistItem> item;
callback clicked(string /* qobuz id */);
width: 160px;
height: 210px;
VerticalLayout {
spacing: 10px;
alignment: start;
// Round portrait.
Rectangle {
width: 140px;
height: 140px;
x: (parent.width - self.width) / 2;
border-radius: 70px;
background: Theme.surface-elevated;
drop-shadow-blur: 16px;
drop-shadow-color: #00000050;
drop-shadow-offset-y: 4px;
clip: true;
Image {
width: 100%;
height: 100%;
source: root.item.image;
image-fit: cover;
}
if root.item.image-url == "": QbzIcon {
source: @image-url("../assets/icons/user.svg");
width: 48px;
height: 48px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.item.qobuz-id);
}
}
}
Text {
text: root.item.name;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
horizontal-alignment: center;
overflow: elide;
width: 160px;
}
if root.item.genres-line != "": Text {
text: root.item.genres-line;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
overflow: elide;
width: 160px;
}
}
}
export component ArtistsByLocationView inherits Rectangle {
callback open-artist(string);
property <length> card-width: 160px;
property <length> card-height: 210px;
property <length> card-gap: 20px;
background: Theme.surface-main;
Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
page := VerticalLayout {
width: parent.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 11px;
padding-bottom: 100px;
spacing: 0px;
alignment: start;
HorizontalLayout {
alignment: start;
NavButtons { }
}
Rectangle { height: 22px; }
// --- Scene header ------------------------------------------
VerticalLayout {
spacing: 4px;
HorizontalLayout {
spacing: 10px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/map-pin.svg");
width: 22px;
height: 22px;
tint: Theme.accent;
}
}
Text {
text: LocationViewState.scene-label != ""
? LocationViewState.scene-label
: @tr("Scene");
color: Theme.text-primary;
font-size: Typography.title;
font-weight: Typography.bold;
vertical-alignment: center;
}
}
if LocationViewState.genre-summary != "": Text {
text: LocationViewState.genre-summary;
color: Theme.text-muted;
font-size: Typography.body;
}
}
Rectangle { height: 28px; }
if LocationViewState.loading
&& LocationViewState.artists.length == 0: HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
if !LocationViewState.loading
&& LocationViewState.artists.length == 0: Text {
text: @tr("No artists found for this scene.");
color: Theme.text-muted;
font-size: Typography.body;
}
// --- Artist grid -------------------------------------------
if LocationViewState.artists.length > 0: grid := Rectangle {
property <int> columns: Math.max(
1,
Math.floor((self.width + root.card-gap) / (root.card-width + root.card-gap)));
property <int> rows: Math.ceil(LocationViewState.artists.length / self.columns);
height: self.rows > 0
? self.rows * root.card-height + Math.max(0, self.rows - 1) * root.card-gap
: 0px;
for item[i] in LocationViewState.artists: SceneArtistCard {
x: Math.mod(i, grid.columns) * (root.card-width + root.card-gap);
y: Math.floor(i / grid.columns) * (root.card-height + root.card-gap);
item: item;
clicked(id) => {
if (id != "") {
root.open-artist(id);
}
}
}
}
if LocationViewState.artists.length > 0
&& LocationViewState.artists.length < LocationViewState.total: HorizontalLayout {
alignment: center;
Rectangle {
width: load-more-text.preferred-width + 24px;
height: 32px;
load-more-text := Text {
text: LocationViewState.load-more-loading
? @tr("Loading…")
: @tr("Load more");
color: load-more-ta.has-hover
? Theme.text-primary
: Theme.text-secondary;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
load-more-ta := TouchArea {
mouse-cursor: pointer;
enabled: !LocationViewState.load-more-loading;
clicked => {
LocationViewActions.load-more();
}
}
}
}
}
}
}
+6
View File
@@ -18,6 +18,7 @@ import { AlbumPageView } from "../album/AlbumPageView.slint";
import { ArtistPageView } from "../artist/ArtistPageView.slint";
import { MusicianPageView } from "../musician/MusicianPageView.slint";
import { LabelReleasesView } from "../label/LabelReleasesView.slint";
import { ArtistsByLocationView } from "../location/ArtistsByLocationView.slint";
import { SettingsView } from "../settings/SettingsView.slint";
import { SearchResultsView } from "../search/SearchResultsView.slint";
@@ -109,6 +110,11 @@ export component AppShell inherits Rectangle {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.location: ArtistsByLocationView {
open-artist(id) => {
root.open-artist(id);
}
}
if NavState.view == ContentView.settings: SettingsView {
settings-bool(k, v) => {
root.settings-bool(k, v);
+27 -1
View File
@@ -94,7 +94,7 @@ export global AlbumActions {
}
// Which content view the shell shows.
export enum ContentView { home, album, artist, settings, search, musician, label }
export enum ContentView { home, album, artist, settings, search, musician, label, location }
// Settings screen state. Audio and Playback preferences are loaded from
// the persistence stores by the Rust settings controller and pushed here;
@@ -451,6 +451,32 @@ export global LabelActions {
callback load-more();
}
// One artist in the scene-discovery grid (artists from the same
// place as the source artist).
export struct LocationArtistItem {
qobuz-id: string,
name: string,
genres-line: string,
image-url: string,
image: image,
}
// ArtistsByLocationView state — opened from the Origin section's
// location link. Populated by `crate::location_view`.
export global LocationViewState {
in property <string> scene-label;
in property <string> genre-summary;
in property <[LocationArtistItem]> artists: [];
in property <int> total: 0;
in property <bool> loading: false;
in property <bool> load-more-loading: false;
}
export global LocationViewActions {
callback open-artist(string /* qobuz id */);
callback load-more();
}
// Shell chrome state — collapsible left sidebar and right-side panels.
// Globals are imported directly by the shell components, so toggles
// need no callback plumbing.