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 },
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user