feat(slint): LabelReleasesView (Port 3, second of three click targets)

Wires the artist network sidebar's Labels section to a real
destination. Mirrors Tauri's LabelReleasesView.svelte core: a label
header (image + name + release count) over a wrapping album grid
with load-more pagination. (Sort / filter Hi-Res / group-by-artist
from the Tauri view are deferred — the core browse experience is in.)

Slint:
  - ContentView gains `label`; NavEntry mirrors with
    `Label { id, name }`.
  - LabelState / LabelActions globals carry the header (id, name,
    image-url + decoded image) and the AlbumCardItem grid + total.
  - New `ui/label/LabelReleasesView.slint`: label header card,
    then the same absolute-position album grid + load-more pattern
    the artist/musician views use. Reuses the Discover AlbumCard
    (artist-as-subtitle is correct here — a label's catalog is
    various artists).
  - AppShell routes ContentView.label and forwards open-album +
    media-action.

Rust:
  - `crate::label` controller — load_label (parallel-ish
    get_label_page + get_label_albums), load_more_albums,
    apply_label, append_albums, apply_image (label header), and
    artwork_jobs. extract_label_image handles the flexible
    /label/page image value (string or mega/extralarge/.../small
    object), same precedence as the Svelte.
  - artwork::ArtworkTarget gains LabelAlbum { index }.
  - main.rs: navigate_label + apply_entry route + on_label_clicked
    (sidebar) + LabelActions::on_load_more.
  - No new qbz-core methods — get_label_page / get_label_albums
    already existed.

cc
This commit is contained in:
vicrodh committed 2026-05-20 20:09:09 -06:00
1 parent cfe083898d
commit 302f708694
8 files changed
+550 -7

No files matched your search

+9
View File
@@ -54,6 +54,8 @@ pub enum ArtworkTarget {
ArtistRelease { section_idx: usize, album_idx: usize },
/// A card in MusicianState.appearances[index].
MusicianAppearance { index: usize },
/// A card in LabelState.albums[index].
LabelAlbum { index: usize },
}
/// An artwork download job: which card, and the image URL.
@@ -349,5 +351,12 @@ fn apply_artwork(
model.set_row_data(index, item);
}
}
ArtworkTarget::LabelAlbum { index } => {
let model = window.global::<crate::LabelState>().get_albums();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
}
}
+222
View File
@@ -0,0 +1,222 @@
//! LabelReleasesView controller — loads the label header (name +
//! image from /label/page) and the paginated album catalog (from
//! /label/getAlbums), pushing them into `LabelState`.
//!
//! Mirrors Tauri's LabelReleasesView.svelte data flow. The rich
//! sort / filter / group-by-artist controls there are deferred; this
//! port covers the header, the album grid, and load-more pagination.
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use qbz_models::Album;
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::{AlbumCardItem, AppWindow, LabelState};
/// Page size for the album catalog. Tauri pulls 500 at a time; keep
/// the same so a typical label loads in one shot.
pub const PAGE_SIZE: u32 = 500;
pub struct LabelData {
pub id: String,
pub name: String,
pub image_url: String,
pub albums: Vec<AlbumCard>,
pub total: usize,
}
#[derive(Clone)]
pub struct AlbumCard {
pub id: String,
pub title: String,
pub artist: String,
pub year: String,
pub quality_tier: String,
pub quality_label: String,
pub artwork_url: String,
}
/// Fetch the label page (name + image) and the first album page.
pub async fn load_label<A>(
runtime: &Arc<AppRuntime<A>>,
label_id: u64,
fallback_name: &str,
) -> Result<LabelData, String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let page = runtime
.core()
.get_label_page(label_id)
.await
.map_err(|e| e.to_string())?;
let albums_page = runtime
.core()
.get_label_albums(label_id, PAGE_SIZE, 0, None, None, None, None, None)
.await
.map_err(|e| e.to_string())?;
let name = if page.name.is_empty() {
fallback_name.to_string()
} else {
page.name
};
let image_url = extract_label_image(page.image.as_ref());
let total = albums_page.total.unwrap_or(albums_page.items.len() as u32) as usize;
let albums = albums_page.items.into_iter().map(map_album).collect();
Ok(LabelData {
id: label_id.to_string(),
name,
image_url,
albums,
total,
})
}
/// Fetch one more album page for the load-more affordance.
pub async fn load_more_albums<A>(
runtime: &Arc<AppRuntime<A>>,
label_id: u64,
offset: u32,
) -> Result<(Vec<AlbumCard>, usize), String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let page = runtime
.core()
.get_label_albums(label_id, PAGE_SIZE, offset, None, None, None, None, None)
.await
.map_err(|e| e.to_string())?;
let total = page.total.unwrap_or(0) as usize;
let albums = page.items.into_iter().map(map_album).collect();
Ok((albums, total))
}
fn map_album(album: Album) -> AlbumCard {
let year = album
.release_date_original
.as_deref()
.and_then(|s| s.get(..4).map(|y| y.to_string()))
.unwrap_or_default();
let quality_tier = tier(album.maximum_bit_depth).to_string();
let quality_label = match (album.maximum_bit_depth, album.maximum_sampling_rate) {
(Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, sr),
_ => String::new(),
};
AlbumCard {
id: album.id,
title: album.title,
artist: album.artist.name,
year,
quality_tier,
quality_label,
artwork_url: album.image.best().cloned().unwrap_or_default(),
}
}
fn tier(bit_depth: Option<u32>) -> &'static str {
match bit_depth {
Some(b) if b > 16 => "hires",
Some(_) => "cd",
None => "",
}
}
/// Extract the best URL from /label/page's flexible image value. It
/// can be a bare string or an object with mega/extralarge/large/...
/// keys (mirrors the Svelte extraction order).
fn extract_label_image(image: Option<&serde_json::Value>) -> String {
let Some(image) = image else {
return String::new();
};
if let Some(s) = image.as_str() {
return s.to_string();
}
for key in ["mega", "extralarge", "large", "thumbnail", "small"] {
if let Some(s) = image.get(key).and_then(|v| v.as_str()) {
return s.to_string();
}
}
String::new()
}
fn to_item(card: AlbumCard) -> AlbumCardItem {
AlbumCardItem {
id: card.id.into(),
title: card.title.into(),
artist: card.artist.into(),
genre: "".into(),
year: card.year.into(),
quality_tier: card.quality_tier.into(),
quality_label: card.quality_label.into(),
ribbon: "".into(),
ribbon_kind: "".into(),
artwork_url: card.artwork_url.into(),
artwork: slint::Image::default(),
}
}
pub fn apply_label(window: &AppWindow, data: LabelData) {
let items: Vec<AlbumCardItem> = data.albums.into_iter().map(to_item).collect();
let state = window.global::<LabelState>();
state.set_id(data.id.into());
state.set_name(data.name.into());
state.set_image_url(data.image_url.into());
state.set_albums(ModelRc::new(VecModel::from(items)));
state.set_total(data.total as i32);
state.set_loading(false);
}
pub fn append_albums(window: &AppWindow, albums: Vec<AlbumCard>, total: usize) {
let state = window.global::<LabelState>();
let model = state.get_albums();
let mut combined: Vec<AlbumCardItem> = (0..model.row_count())
.filter_map(|i| model.row_data(i))
.collect();
combined.extend(albums.into_iter().map(to_item));
state.set_albums(ModelRc::new(VecModel::from(combined)));
state.set_total(total as i32);
state.set_load_more_loading(false);
}
/// Apply the decoded label header image. Runs on the Slint event loop.
pub fn apply_image(window: &AppWindow, pixels: &[u8], width: u32, height: u32) {
let mut buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
let dst = buffer.make_mut_bytes();
if dst.len() != pixels.len() {
return;
}
dst.copy_from_slice(pixels);
window
.global::<LabelState>()
.set_image(slint::Image::from_rgba8(buffer));
}
pub fn reset_label(window: &AppWindow) {
let state = window.global::<LabelState>();
state.set_name("".into());
state.set_image_url("".into());
state.set_albums(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
state.set_total(0);
state.set_loading(true);
state.set_load_more_loading(false);
}
/// Artwork jobs for the label album grid — same pipeline the
/// Discover cards use.
pub fn artwork_jobs(data: &LabelData) -> Vec<ArtworkJob> {
data.albums
.iter()
.enumerate()
.filter(|(_, a)| !a.artwork_url.is_empty())
.map(|(i, a)| ArtworkJob {
url: a.artwork_url.clone(),
target: ArtworkTarget::LabelAlbum { index: i },
})
.collect()
}
+130 -5
View File
@@ -23,6 +23,7 @@ mod commands;
mod custom_artwork;
mod discovery_dismiss;
mod home;
mod label;
mod musician;
mod nav;
mod play_history;
@@ -424,9 +425,55 @@ fn apply_entry(
role,
);
}
nav::NavEntry::Label { id, name } => {
navigate_label(runtime.clone(), weak.clone(), handle, image_cache.clone(), id, name);
}
}
}
/// Open a LabelReleasesView for `label_id`. Fetches the label header
/// + first album page, then the header image.
fn navigate_label(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: artwork::ImageCache,
label_id: u64,
name: String,
) {
handle.spawn(async move {
let _ = weak.upgrade_in_event_loop(|w| {
label::reset_label(&w);
w.global::<NavState>().set_view(ContentView::Label);
});
match label::load_label(&runtime, label_id, &name).await {
Ok(data) => {
let jobs = label::artwork_jobs(&data);
let image_url = data.image_url.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
label::apply_label(&w, data);
});
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
if !image_url.is_empty() {
if let Some((pixels, width, height)) =
artwork::fetch_and_decode(&image_url, &image_cache, 240).await
{
let _ = weak.upgrade_in_event_loop(move |w| {
label::apply_image(&w, &pixels, width, height);
});
}
}
}
Err(e) => {
log::error!("[qbz-slint] label load failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LabelState>().set_loading(false);
});
}
}
});
}
/// Open a MusicianPageView for `name + role`. Routes to the artist
/// page instead when the resolved musician has a Confirmed Qobuz
/// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches
@@ -1278,11 +1325,37 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.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}");
});
// Label click — open LabelReleasesView.
{
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_label_clicked(move |id, name| {
let Ok(label_id) = id.parse::<u64>() else {
log::warn!("[qbz-slint] label clicked: invalid id {id}");
return;
};
let name = name.to_string();
nav::record(nav::NavEntry::Label {
id: label_id,
name: name.clone(),
});
navigate_label(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
label_id,
name,
);
if let Some(w) = weak.upgrade() {
update_nav_flags(&w);
}
});
}
// artist-clicked actually navigates — the target view (artist page)
// already exists in Slint, unlike LabelReleases / ArtistsByLocation /
// MusicianPage. Same flow as the top-level on_open_artist handler.
@@ -1448,6 +1521,58 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Label album pagination — Load more in LabelReleasesView
// appends the next page onto the grid.
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<LabelActions>()
.on_load_more(move || {
let Some(w) = weak.upgrade() else {
return;
};
let state = w.global::<LabelState>();
let Ok(label_id) = state.get_id().to_string().parse::<u64>() else {
return;
};
let offset = state.get_albums().row_count() as u32;
state.set_load_more_loading(true);
let runtime = runtime.clone();
let weak = weak.clone();
let image_cache = image_cache.clone();
handle.spawn(async move {
match label::load_more_albums(&runtime, label_id, offset).await {
Ok((data, total)) => {
let jobs: Vec<artwork::ArtworkJob> = data
.iter()
.enumerate()
.filter(|(_, a)| !a.artwork_url.is_empty())
.map(|(i, a)| artwork::ArtworkJob {
url: a.artwork_url.clone(),
target: artwork::ArtworkTarget::LabelAlbum {
index: offset as usize + i,
},
})
.collect();
let _ = weak.upgrade_in_event_loop(move |w| {
label::append_albums(&w, data, total);
});
artwork::spawn_loads(jobs, weak, image_cache);
}
Err(e) => {
log::error!("[qbz-slint] label load-more failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<LabelState>().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.
+6
View File
@@ -26,6 +26,12 @@ pub enum NavEntry {
name: String,
role: String,
},
/// LabelReleasesView — opened by the artist network sidebar's
/// Labels section. Carries the label id + name fallback.
Label {
id: u64,
name: 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 } from "state.slint";
export { HomeState, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions } 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,154 @@
// LabelReleasesView — opened from the artist network sidebar's
// Labels section. Mirrors Tauri's LabelReleasesView.svelte core:
// a label header (image + name + album count) over a wrapping
// album grid with load-more pagination. The sort / filter /
// group-by-artist controls from the Tauri view are deferred.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { LabelState, LabelActions, AlbumCardItem } from "../state.slint";
import { AlbumCard } from "../discover/AlbumCard.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { NavButtons } from "../shell/NavButtons.slint";
export component LabelReleasesView inherits Rectangle {
callback open-album(string);
callback media-action(string, string, string);
property <length> card-width: 220px;
property <length> card-height: 266px;
property <length> card-gap: 24px;
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; }
// --- Label header ------------------------------------------
HorizontalLayout {
spacing: 24px;
alignment: start;
Rectangle {
width: 120px;
height: 120px;
border-radius: Radius.md;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
source: LabelState.image;
image-fit: cover;
}
if LabelState.image-url == "": QbzIcon {
source: @image-url("../assets/icons/disc-3.svg");
width: 44px;
height: 44px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
}
VerticalLayout {
alignment: center;
spacing: 6px;
Text {
text: @tr("LABEL");
color: Theme.text-muted;
font-size: 10px;
font-weight: Typography.semibold;
letter-spacing: 1.5px;
}
Text {
text: LabelState.name;
color: Theme.text-primary;
font-size: Typography.title;
font-weight: Typography.bold;
}
if LabelState.total > 0: Text {
text: LabelState.total + " " + @tr("releases");
color: Theme.text-muted;
font-size: Typography.body;
}
}
}
Rectangle { height: 32px; }
if LabelState.loading && LabelState.albums.length == 0: HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
// --- Album grid --------------------------------------------
if LabelState.albums.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(LabelState.albums.length / self.columns);
height: self.rows > 0
? self.rows * root.card-height + Math.max(0, self.rows - 1) * root.card-gap
: 0px;
for album[i] in LabelState.albums: AlbumCard {
x: Math.mod(i, grid.columns) * (root.card-width + root.card-gap);
y: Math.floor(i / grid.columns) * (root.card-height + root.card-gap);
width: root.card-width;
height: root.card-height;
album: album;
clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
}
if LabelState.albums.length > 0
&& LabelState.albums.length < LabelState.total: HorizontalLayout {
alignment: center;
Rectangle {
width: load-more-text.preferred-width + 24px;
height: 32px;
load-more-text := Text {
text: LabelState.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: !LabelState.load-more-loading;
clicked => {
LabelActions.load-more();
}
}
}
}
}
}
}
+9
View File
@@ -17,6 +17,7 @@ import { HomeView } from "../discover/HomeView.slint";
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 { SettingsView } from "../settings/SettingsView.slint";
import { SearchResultsView } from "../search/SearchResultsView.slint";
@@ -100,6 +101,14 @@ export component AppShell inherits Rectangle {
root.open-album(id);
}
}
if NavState.view == ContentView.label: LabelReleasesView {
open-album(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.settings: SettingsView {
settings-bool(k, v) => {
root.settings-bool(k, v);
+19 -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 }
export enum ContentView { home, album, artist, settings, search, musician, label }
// Settings screen state. Audio and Playback preferences are loaded from
// the persistence stores by the Rust settings controller and pushed here;
@@ -433,6 +433,24 @@ export global MusicianActions {
callback load-more();
}
// LabelReleasesView state — opened by the artist network sidebar's
// Labels section. Header (name + image) from /label/page, the
// album grid from /label/getAlbums. Populated by `crate::label`.
export global LabelState {
in property <string> id;
in property <string> name;
in property <string> image-url;
in property <image> image;
in property <[AlbumCardItem]> albums: [];
in property <int> total: 0;
in property <bool> loading: false;
in property <bool> load-more-loading: false;
}
export global LabelActions {
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.