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:
11 files changed
+595
-7
No files matched your search
@@ -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};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
¶ms.mbid,
|
||||
area_id,
|
||||
¶ms.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()
|
||||
}
|
||||
@@ -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, ¶ms, 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, ¶ms, 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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user