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:
8 files changed
+550
-7
No files matched your search
@@ -54,6 +54,8 @@ pub enum ArtworkTarget {
|
|||||||
ArtistRelease { section_idx: usize, album_idx: usize },
|
ArtistRelease { section_idx: usize, album_idx: usize },
|
||||||
/// A card in MusicianState.appearances[index].
|
/// A card in MusicianState.appearances[index].
|
||||||
MusicianAppearance { index: usize },
|
MusicianAppearance { index: usize },
|
||||||
|
/// A card in LabelState.albums[index].
|
||||||
|
LabelAlbum { index: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An artwork download job: which card, and the image URL.
|
/// An artwork download job: which card, and the image URL.
|
||||||
@@ -349,5 +351,12 @@ fn apply_artwork(
|
|||||||
model.set_row_data(index, item);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ mod commands;
|
|||||||
mod custom_artwork;
|
mod custom_artwork;
|
||||||
mod discovery_dismiss;
|
mod discovery_dismiss;
|
||||||
mod home;
|
mod home;
|
||||||
|
mod label;
|
||||||
mod musician;
|
mod musician;
|
||||||
mod nav;
|
mod nav;
|
||||||
mod play_history;
|
mod play_history;
|
||||||
@@ -424,9 +425,55 @@ fn apply_entry(
|
|||||||
role,
|
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
|
/// Open a MusicianPageView for `name + role`. Routes to the artist
|
||||||
/// page instead when the resolved musician has a Confirmed Qobuz
|
/// page instead when the resolved musician has a Confirmed Qobuz
|
||||||
/// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches
|
/// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches
|
||||||
@@ -1278,11 +1325,37 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.on_location_clicked(|mbid| {
|
.on_location_clicked(|mbid| {
|
||||||
log::info!("[qbz-slint] network sidebar: location clicked for mbid={mbid}");
|
log::info!("[qbz-slint] network sidebar: location clicked for mbid={mbid}");
|
||||||
});
|
});
|
||||||
window
|
// Label click — open LabelReleasesView.
|
||||||
.global::<NetworkSidebarActions>()
|
{
|
||||||
.on_label_clicked(|id, name| {
|
let runtime = app_runtime.clone();
|
||||||
log::info!("[qbz-slint] network sidebar: label clicked id={id} name={name}");
|
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)
|
// artist-clicked actually navigates — the target view (artist page)
|
||||||
// already exists in Slint, unlike LabelReleases / ArtistsByLocation /
|
// already exists in Slint, unlike LabelReleases / ArtistsByLocation /
|
||||||
// MusicianPage. Same flow as the top-level on_open_artist handler.
|
// 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 /
|
// Artwork right-click menu wiring — Open in browser / Save as /
|
||||||
// Add custom / Remove custom. Mirrors the v2_library_* + native
|
// Add custom / Remove custom. Mirrors the v2_library_* + native
|
||||||
// dialog flow Tauri uses on artist portraits + album covers.
|
// dialog flow Tauri uses on artist portraits + album covers.
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ pub enum NavEntry {
|
|||||||
name: String,
|
name: String,
|
||||||
role: 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 {
|
struct History {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { LoginScreen } from "login/LoginScreen.slint";
|
|||||||
import { AppShell } from "shell/AppShell.slint";
|
import { AppShell } from "shell/AppShell.slint";
|
||||||
|
|
||||||
// Re-export the state globals so the Rust layer can populate them.
|
// 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
|
// Which top-level screen is shown. The app starts on `splash` while it
|
||||||
// restores a saved session, then resolves to `shell` or `login`.
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { HomeView } from "../discover/HomeView.slint";
|
|||||||
import { AlbumPageView } from "../album/AlbumPageView.slint";
|
import { AlbumPageView } from "../album/AlbumPageView.slint";
|
||||||
import { ArtistPageView } from "../artist/ArtistPageView.slint";
|
import { ArtistPageView } from "../artist/ArtistPageView.slint";
|
||||||
import { MusicianPageView } from "../musician/MusicianPageView.slint";
|
import { MusicianPageView } from "../musician/MusicianPageView.slint";
|
||||||
|
import { LabelReleasesView } from "../label/LabelReleasesView.slint";
|
||||||
import { SettingsView } from "../settings/SettingsView.slint";
|
import { SettingsView } from "../settings/SettingsView.slint";
|
||||||
import { SearchResultsView } from "../search/SearchResultsView.slint";
|
import { SearchResultsView } from "../search/SearchResultsView.slint";
|
||||||
|
|
||||||
@@ -100,6 +101,14 @@ export component AppShell inherits Rectangle {
|
|||||||
root.open-album(id);
|
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 {
|
if NavState.view == ContentView.settings: SettingsView {
|
||||||
settings-bool(k, v) => {
|
settings-bool(k, v) => {
|
||||||
root.settings-bool(k, v);
|
root.settings-bool(k, v);
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export global AlbumActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Which content view the shell shows.
|
// 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
|
// Settings screen state. Audio and Playback preferences are loaded from
|
||||||
// the persistence stores by the Rust settings controller and pushed here;
|
// the persistence stores by the Rust settings controller and pushed here;
|
||||||
@@ -433,6 +433,24 @@ export global MusicianActions {
|
|||||||
callback load-more();
|
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.
|
// Shell chrome state — collapsible left sidebar and right-side panels.
|
||||||
// Globals are imported directly by the shell components, so toggles
|
// Globals are imported directly by the shell components, so toggles
|
||||||
// need no callback plumbing.
|
// need no callback plumbing.
|
||||||
|
|||||||
Reference in new issue
Block a user