feat(slint): Label landing page + All Releases sub-view
Port the Tauri LabelView module to native Slint, reusing existing components and extending where needed (no duplication). Landing (new LabelPageView): circular header + description read-more + Follow toggle, sticky JUMP TO bar, Popular Tracks (multi-select, 5->20->50 reveal, Play all), and Releases (+ See all) / Critics' Picks / Playlists / Artists / More Labels carousels. All Releases sub-view (LabelReleasesView): circular header + album count, toolbar (local search, group-by-artist, Hi-Res filter, sort) + grid/list toggle, load-more, states. TYPE column now populated. Split the label route: ContentView::label -> LabelPageView (landing), ContentView::label-releases -> LabelReleasesView; add NavEntry::LabelReleases. label.rs load_label_page parses /label/page + getAlbums + explore + the favorite-labels set; derive_releases re-derives the sub-view list. Reuse Carousel/PlaylistCarousel/ArtistCarousel/TrackRow/AlbumCard/ AlbumListRow/JumpNavBar/QbzSelect/ExpandableSearch; extend ArtistCard/ ArtistCarousel (follow-mode/follow-kind); promote CircleAction to a shared primitive; JumpNavBar gains show-search. Wire label media-actions: follow, play-top, open (more-labels), see-all. cc
This commit is contained in:
13 files changed
+1986
-152
No files matched your search
@@ -56,8 +56,20 @@ 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].
|
||||
/// A card in LabelState.albums[index] (releases sub-view grid).
|
||||
LabelAlbum { index: usize },
|
||||
/// A row in LabelState.top-tracks[index] (landing).
|
||||
LabelTopTrack { index: usize },
|
||||
/// A card in LabelState.releases-section.albums[index] (landing).
|
||||
LabelReleaseAlbum { index: usize },
|
||||
/// A card in LabelState.critics-section.albums[index] (landing).
|
||||
LabelCriticsAlbum { index: usize },
|
||||
/// The cover of LabelState.playlists[index] (landing).
|
||||
LabelPlaylistCover { index: usize },
|
||||
/// A card in LabelState.artists[index] (landing).
|
||||
LabelArtist { index: usize },
|
||||
/// A card in LabelState.more-labels[index] (landing).
|
||||
LabelMoreLabel { index: usize },
|
||||
/// A card in LocationViewState.artists[index].
|
||||
LocationArtist { index: usize },
|
||||
/// A row in FavoritesState.tracks[index].
|
||||
@@ -438,6 +450,48 @@ fn apply_artwork(
|
||||
model.set_row_data(index, item);
|
||||
}
|
||||
}
|
||||
ArtworkTarget::LabelTopTrack { index } => {
|
||||
let model = window.global::<crate::LabelState>().get_top_tracks();
|
||||
if let Some(mut item) = model.row_data(index) {
|
||||
item.artwork = image;
|
||||
model.set_row_data(index, item);
|
||||
}
|
||||
}
|
||||
ArtworkTarget::LabelReleaseAlbum { index } => {
|
||||
let model = window.global::<crate::LabelState>().get_releases_section().albums;
|
||||
if let Some(mut item) = model.row_data(index) {
|
||||
item.artwork = image;
|
||||
model.set_row_data(index, item);
|
||||
}
|
||||
}
|
||||
ArtworkTarget::LabelCriticsAlbum { index } => {
|
||||
let model = window.global::<crate::LabelState>().get_critics_section().albums;
|
||||
if let Some(mut item) = model.row_data(index) {
|
||||
item.artwork = image;
|
||||
model.set_row_data(index, item);
|
||||
}
|
||||
}
|
||||
ArtworkTarget::LabelPlaylistCover { index } => {
|
||||
let model = window.global::<crate::LabelState>().get_playlists();
|
||||
if let Some(mut item) = model.row_data(index) {
|
||||
item.cover1 = image;
|
||||
model.set_row_data(index, item);
|
||||
}
|
||||
}
|
||||
ArtworkTarget::LabelArtist { index } => {
|
||||
let model = window.global::<crate::LabelState>().get_artists();
|
||||
if let Some(mut item) = model.row_data(index) {
|
||||
item.artwork = image;
|
||||
model.set_row_data(index, item);
|
||||
}
|
||||
}
|
||||
ArtworkTarget::LabelMoreLabel { index } => {
|
||||
let model = window.global::<crate::LabelState>().get_more_labels();
|
||||
if let Some(mut item) = model.row_data(index) {
|
||||
item.artwork = image;
|
||||
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) {
|
||||
|
||||
@@ -6,15 +6,21 @@
|
||||
//! sort / filter / group-by-artist controls there are deferred; this
|
||||
//! port covers the header, the album grid, and load-more pagination.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::FrontendAdapter;
|
||||
use qbz_models::Album;
|
||||
use qbz_models::{Album, LabelExploreResponse, Track};
|
||||
use serde_json::Value;
|
||||
use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
||||
|
||||
use crate::artwork::{ArtworkJob, ArtworkTarget};
|
||||
use crate::{AlbumCardItem, AppWindow, LabelState};
|
||||
use crate::{
|
||||
AlbumCardItem, AppWindow, DiscoverSection, JumpNavTab, LabelState, SearchPlaylistItem,
|
||||
SlimItem, TrackItem,
|
||||
};
|
||||
|
||||
/// Page size for the album catalog. Tauri pulls 500 at a time; keep
|
||||
/// the same so a typical label loads in one shot.
|
||||
@@ -40,6 +46,7 @@ pub struct AlbumCard {
|
||||
pub quality_label: String,
|
||||
pub artwork_url: String,
|
||||
// List-row extras (AlbumListRow columns; ignored by the grid card).
|
||||
pub release_type: String, // "Album" | "EP" | "Single" (TYPE column)
|
||||
pub quality_detail: String, // "24-bit / 96 kHz"
|
||||
pub track_count: String, // "12"
|
||||
pub plain_year: String, // "1973"
|
||||
@@ -124,6 +131,7 @@ fn map_album(album: Album) -> AlbumCard {
|
||||
.filter(|n| *n > 0)
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_default();
|
||||
let release_type = classify_release_type(album.tracks_count).to_string();
|
||||
AlbumCard {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
@@ -134,6 +142,7 @@ fn map_album(album: Album) -> AlbumCard {
|
||||
quality_tier,
|
||||
quality_label,
|
||||
artwork_url: album.image.best().cloned().unwrap_or_default(),
|
||||
release_type,
|
||||
quality_detail,
|
||||
track_count,
|
||||
plain_year: year,
|
||||
@@ -148,6 +157,17 @@ fn tier(bit_depth: Option<u32>) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify the list-row TYPE column from the album's track count. The
|
||||
/// /label/getAlbums payload carries no explicit release_type, so this
|
||||
/// mirrors home.rs's Discover heuristic (<=3 = Single, <=6 = EP, else Album).
|
||||
fn classify_release_type(track_count: Option<u32>) -> &'static str {
|
||||
match track_count {
|
||||
Some(n) if n <= 3 => "Single",
|
||||
Some(n) if n <= 6 => "EP",
|
||||
_ => "Album",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -180,9 +200,10 @@ fn to_item(card: AlbumCard) -> AlbumCardItem {
|
||||
ribbon_kind: "".into(),
|
||||
artwork_url: card.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
// List-row extras — feed the AlbumListRow columns (QUALITY /
|
||||
// List-row extras — feed the AlbumListRow columns (TYPE / QUALITY /
|
||||
// TRACKS / YEAR) for the list view toggle. SOURCE is hidden in
|
||||
// this single-source (Qobuz) context.
|
||||
release_type: card.release_type.into(),
|
||||
quality_detail: card.quality_detail.into(),
|
||||
track_count: card.track_count.into(),
|
||||
plain_year: card.plain_year.into(),
|
||||
@@ -199,6 +220,100 @@ pub fn apply_label(window: &AppWindow, data: LabelData) {
|
||||
state.set_albums(ModelRc::new(VecModel::from(items)));
|
||||
state.set_total(data.total as i32);
|
||||
state.set_loading(false);
|
||||
derive_releases(window);
|
||||
}
|
||||
|
||||
/// Re-derive the releases sub-view's rendered list (`visible` / `grouped`)
|
||||
/// from the full loaded catalog + the toolbar state (sort / Hi-Res filter /
|
||||
/// search / group-by-artist). Mirrors Tauri's client-side `$derived`
|
||||
/// processing. Search is a local filter over the loaded catalog. Artwork
|
||||
/// stays live in the common case because the no-filter, default-sort path
|
||||
/// shares the `albums` model (the DiscoverBrowse pattern).
|
||||
pub fn derive_releases(window: &AppWindow) {
|
||||
let state = window.global::<LabelState>();
|
||||
let albums = state.get_albums();
|
||||
let count = albums.row_count();
|
||||
let full: Vec<AlbumCardItem> = (0..count).filter_map(|i| albums.row_data(i)).collect();
|
||||
|
||||
let sort = state.get_sort_by().to_string();
|
||||
let hires = state.get_filter_hires();
|
||||
let group = state.get_group_by_artist();
|
||||
let query_owned = state.get_search_query().to_lowercase();
|
||||
let query = query_owned.trim();
|
||||
|
||||
let hires_count = full
|
||||
.iter()
|
||||
.filter(|a| a.quality_tier.as_str() == "hires")
|
||||
.count();
|
||||
|
||||
// Fast path: default order, no filter/search, flat → render the live
|
||||
// `albums` model directly so artwork keeps updating in place.
|
||||
if !hires && query.is_empty() && sort == "newest" && !group {
|
||||
state.set_visible(albums.clone());
|
||||
state.set_grouped(ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new())));
|
||||
state.set_shown(full.len() as i32);
|
||||
state.set_hires_count(hires_count as i32);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut filtered: Vec<AlbumCardItem> = full
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
(!hires || a.quality_tier.as_str() == "hires")
|
||||
&& (query.is_empty()
|
||||
|| a.title.to_lowercase().contains(query)
|
||||
|| a.artist.to_lowercase().contains(query))
|
||||
})
|
||||
.collect();
|
||||
sort_album_items(&mut filtered, &sort);
|
||||
let shown = filtered.len();
|
||||
|
||||
if group {
|
||||
// One section per artist, in first-appearance (sorted) order.
|
||||
let mut sections: Vec<DiscoverSection> = Vec::new();
|
||||
let mut buckets: Vec<Vec<AlbumCardItem>> = Vec::new();
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
for item in filtered {
|
||||
let key = item.artist.to_string();
|
||||
let idx = *index.entry(key.clone()).or_insert_with(|| {
|
||||
buckets.push(Vec::new());
|
||||
sections.push(DiscoverSection {
|
||||
title: if key.is_empty() {
|
||||
"Unknown".into()
|
||||
} else {
|
||||
key.clone().into()
|
||||
},
|
||||
endpoint: "".into(),
|
||||
albums: ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())),
|
||||
});
|
||||
buckets.len() - 1
|
||||
});
|
||||
buckets[idx].push(item);
|
||||
}
|
||||
for (i, bucket) in buckets.into_iter().enumerate() {
|
||||
sections[i].albums = ModelRc::new(VecModel::from(bucket));
|
||||
}
|
||||
state.set_grouped(ModelRc::new(VecModel::from(sections)));
|
||||
state.set_visible(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
|
||||
} else {
|
||||
state.set_visible(ModelRc::new(VecModel::from(filtered)));
|
||||
state.set_grouped(ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new())));
|
||||
}
|
||||
state.set_shown(shown as i32);
|
||||
state.set_hires_count(hires_count as i32);
|
||||
}
|
||||
|
||||
fn sort_album_items(items: &mut [AlbumCardItem], sort: &str) {
|
||||
match sort {
|
||||
"oldest" => items.sort_by(|a, b| a.plain_year.as_str().cmp(b.plain_year.as_str())),
|
||||
"newest" => items.sort_by(|a, b| b.plain_year.as_str().cmp(a.plain_year.as_str())),
|
||||
"title-asc" => items.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
|
||||
"title-desc" => items.sort_by(|a, b| b.title.to_lowercase().cmp(&a.title.to_lowercase())),
|
||||
"artist-asc" => {
|
||||
items.sort_by(|a, b| a.artist.to_lowercase().cmp(&b.artist.to_lowercase()))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append_albums(window: &AppWindow, albums: Vec<AlbumCard>, total: usize) {
|
||||
@@ -211,6 +326,7 @@ pub fn append_albums(window: &AppWindow, albums: Vec<AlbumCard>, total: usize) {
|
||||
state.set_albums(ModelRc::new(VecModel::from(combined)));
|
||||
state.set_total(total as i32);
|
||||
state.set_load_more_loading(false);
|
||||
derive_releases(window);
|
||||
}
|
||||
|
||||
/// Apply the decoded label header image. Runs on the Slint event loop.
|
||||
@@ -231,9 +347,18 @@ pub fn reset_label(window: &AppWindow) {
|
||||
state.set_name("".into());
|
||||
state.set_image_url("".into());
|
||||
state.set_albums(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
|
||||
state.set_visible(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
|
||||
state.set_grouped(ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new())));
|
||||
state.set_total(0);
|
||||
state.set_loading(true);
|
||||
state.set_load_more_loading(false);
|
||||
// Reset the toolbar to defaults for the fresh label.
|
||||
state.set_sort_by("newest".into());
|
||||
state.set_filter_hires(false);
|
||||
state.set_group_by_artist(false);
|
||||
state.set_search_query("".into());
|
||||
state.set_shown(0);
|
||||
state.set_hires_count(0);
|
||||
}
|
||||
|
||||
/// Artwork jobs for the label album grid — same pipeline the
|
||||
@@ -249,3 +374,717 @@ pub fn artwork_jobs(data: &LabelData) -> Vec<ArtworkJob> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Landing page (LabelPageView) — the rich label page: header +
|
||||
// popular tracks + releases / critics / playlists / artists /
|
||||
// more-labels carousels. Mirrors Tauri's LabelView.svelte. Data comes
|
||||
// from /label/page (top_tracks/releases/playlists/top_artists), the
|
||||
// first /label/getAlbums page (releases carousel), /label/explore
|
||||
// (more labels), and the user's favorite-labels set (follow state).
|
||||
// ======================================================================
|
||||
|
||||
/// Plain, `Send` payload for the rich label landing page.
|
||||
pub struct LabelPagePayload {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub image_url: String,
|
||||
pub description: String,
|
||||
pub description_short: String,
|
||||
pub description_truncated: bool,
|
||||
pub is_following: bool,
|
||||
pub top_tracks: Vec<TopTrack>,
|
||||
pub releases: Vec<AlbumCard>,
|
||||
pub critics: Vec<AlbumCard>,
|
||||
pub playlists: Vec<PlaylistSlim>,
|
||||
pub artists: Vec<ArtistSlim>,
|
||||
pub more_labels: Vec<LabelSlim>,
|
||||
/// Catalog tracks kept for "Play all" — deserialized from the page
|
||||
/// top_tracks and queued verbatim (mirrors Tauri's buildTopTracksQueue).
|
||||
pub play_tracks: Vec<Track>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TopTrack {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub artist_id: String,
|
||||
pub album_id: String,
|
||||
pub artwork_url: String,
|
||||
pub duration: String,
|
||||
pub quality_tier: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PlaylistSlim {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub image_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ArtistSlim {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub image_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LabelSlim {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub image_url: String,
|
||||
pub following: bool,
|
||||
}
|
||||
|
||||
// Catalog tracks for the landing's "Play all", cached on the UI thread
|
||||
// (set in `apply_label_page`, read by the play-top media action).
|
||||
thread_local! {
|
||||
static PLAY_TOP_TRACKS: RefCell<Vec<Track>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// The label's popular tracks as a play-ready queue source.
|
||||
pub fn top_tracks_for_play() -> Vec<Track> {
|
||||
PLAY_TOP_TRACKS.with(|c| c.borrow().clone())
|
||||
}
|
||||
|
||||
/// Fetch + map the full label landing page.
|
||||
pub async fn load_label_page<A>(
|
||||
runtime: &Arc<AppRuntime<A>>,
|
||||
label_id: u64,
|
||||
fallback_name: &str,
|
||||
) -> Result<LabelPagePayload, String>
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
let page = runtime
|
||||
.core()
|
||||
.get_label_page(label_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Favorite-label ids — seed the header + per-card follow state.
|
||||
let follow_ids = favorite_label_ids(runtime).await;
|
||||
|
||||
let name = if page.name.is_empty() {
|
||||
fallback_name.to_string()
|
||||
} else {
|
||||
page.name.clone()
|
||||
};
|
||||
let image_url = extract_label_image(page.image.as_ref());
|
||||
|
||||
// Description (HTML-stripped) + truncation for the header read-more.
|
||||
let description = page
|
||||
.description
|
||||
.as_deref()
|
||||
.map(crate::strip_html::strip_html)
|
||||
.unwrap_or_default();
|
||||
let description_short = truncate_words(&description, 360);
|
||||
let description_truncated = description_short != description;
|
||||
|
||||
// Popular tracks → display rows + play-all queue source.
|
||||
let raw_top = page.top_tracks.clone().unwrap_or_default();
|
||||
let top_tracks: Vec<TopTrack> = raw_top.iter().map(parse_top_track).collect();
|
||||
let play_tracks: Vec<Track> = raw_top
|
||||
.into_iter()
|
||||
.filter_map(|v| serde_json::from_value::<Track>(v).ok())
|
||||
.collect();
|
||||
|
||||
// Critics' Picks — the releases container whose id mentions
|
||||
// award/critic/press (mirrors LabelView.svelte:402-413).
|
||||
let critics: Vec<AlbumCard> = page
|
||||
.releases
|
||||
.as_ref()
|
||||
.and_then(|containers| {
|
||||
containers
|
||||
.iter()
|
||||
.find(|c| {
|
||||
c.id
|
||||
.as_deref()
|
||||
.map(|id| {
|
||||
let id = id.to_lowercase();
|
||||
id.contains("award") || id.contains("critic") || id.contains("press")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.and_then(|c| c.data.as_ref())
|
||||
.and_then(|d| d.items.as_ref())
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|v| serde_json::from_value::<Album>(v.clone()).ok())
|
||||
.map(map_album)
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let playlists: Vec<PlaylistSlim> = page
|
||||
.playlists
|
||||
.as_ref()
|
||||
.and_then(|p| p.items.as_ref())
|
||||
.map(|items| items.iter().map(parse_playlist).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let artists: Vec<ArtistSlim> = page
|
||||
.top_artists
|
||||
.as_ref()
|
||||
.and_then(|a| a.items.as_ref())
|
||||
.map(|items| items.iter().map(parse_artist).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Releases carousel — first 20 from /label/getAlbums.
|
||||
let releases: Vec<AlbumCard> = match runtime
|
||||
.core()
|
||||
.get_label_albums(label_id, 20, 0, None, None, None, None, None)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p.items.into_iter().map(map_album).collect(),
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] label releases carousel failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
// More labels — /label/explore minus the current label; seed follow.
|
||||
let more_labels: Vec<LabelSlim> = match runtime.core().get_label_explore(20, 0).await {
|
||||
Ok(resp) => parse_more_labels(&resp, label_id, &follow_ids),
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] label explore failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(LabelPagePayload {
|
||||
id: label_id.to_string(),
|
||||
name,
|
||||
image_url,
|
||||
description,
|
||||
description_short,
|
||||
description_truncated,
|
||||
is_following: follow_ids.contains(&label_id),
|
||||
top_tracks,
|
||||
releases,
|
||||
critics,
|
||||
playlists,
|
||||
artists,
|
||||
more_labels,
|
||||
play_tracks,
|
||||
})
|
||||
}
|
||||
|
||||
/// The user's favorite-label ids (for the header + more-labels follow
|
||||
/// state). Best-effort: an error yields an empty set.
|
||||
async fn favorite_label_ids<A>(runtime: &Arc<AppRuntime<A>>) -> HashSet<u64>
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
match runtime.core().get_favorites("label", 500, 0).await {
|
||||
Ok(v) => v
|
||||
.get("labels")
|
||||
.and_then(|l| l.get("items"))
|
||||
.and_then(|i| i.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|it| it.get("id").and_then(|x| x.as_u64()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_top_track(raw: &Value) -> TopTrack {
|
||||
let id = raw
|
||||
.get("id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_default();
|
||||
let title = raw
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let duration = raw.get("duration").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let album = raw.get("album");
|
||||
let album_id = album
|
||||
.and_then(|a| a.get("id"))
|
||||
.map(value_to_string)
|
||||
.unwrap_or_default();
|
||||
let artwork_url = album
|
||||
.and_then(|a| a.get("image"))
|
||||
.map(parse_image_value)
|
||||
.unwrap_or_default();
|
||||
// performer OR artist (the page uses either).
|
||||
let perf = raw.get("performer").or_else(|| raw.get("artist"));
|
||||
let artist = perf
|
||||
.and_then(|p| p.get("name"))
|
||||
.map(name_display)
|
||||
.unwrap_or_default();
|
||||
let artist_id = perf
|
||||
.and_then(|p| p.get("id"))
|
||||
.map(value_to_string)
|
||||
.unwrap_or_default();
|
||||
let bit_depth = raw
|
||||
.get("audio_info")
|
||||
.and_then(|a| a.get("maximum_bit_depth"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| raw.get("maximum_bit_depth").and_then(|v| v.as_u64()));
|
||||
TopTrack {
|
||||
id,
|
||||
title,
|
||||
artist,
|
||||
artist_id,
|
||||
album_id,
|
||||
artwork_url,
|
||||
duration: mmss(duration as u32),
|
||||
quality_tier: tier(bit_depth.map(|b| b as u32)).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_playlist(raw: &Value) -> PlaylistSlim {
|
||||
let id = raw.get("id").map(value_to_string).unwrap_or_default();
|
||||
let title = raw
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let owner = raw
|
||||
.get("owner")
|
||||
.and_then(|o| o.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("Qobuz")
|
||||
.to_string();
|
||||
let track_count = raw.get("tracks_count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let subtitle = format!("{owner} · {track_count}");
|
||||
PlaylistSlim {
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
image_url: parse_playlist_image(raw),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_artist(raw: &Value) -> ArtistSlim {
|
||||
ArtistSlim {
|
||||
id: raw.get("id").map(value_to_string).unwrap_or_default(),
|
||||
name: raw.get("name").map(name_display).unwrap_or_default(),
|
||||
image_url: parse_artist_image(raw),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_more_labels(
|
||||
resp: &LabelExploreResponse,
|
||||
current: u64,
|
||||
follow: &HashSet<u64>,
|
||||
) -> Vec<LabelSlim> {
|
||||
let Some(items) = resp.items.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let id = item.get("id").and_then(|x| x.as_u64())?;
|
||||
if id == current {
|
||||
return None;
|
||||
}
|
||||
let name = item
|
||||
.get("name")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let image_url = item
|
||||
.get("image")
|
||||
.map(parse_explore_image)
|
||||
.unwrap_or_default();
|
||||
Some(LabelSlim {
|
||||
id: id.to_string(),
|
||||
name,
|
||||
image_url,
|
||||
following: follow.contains(&id),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// --- Value helpers (mirror the Svelte getX helpers) ----------------------
|
||||
|
||||
/// String for an id-ish Value (string verbatim, number stringified).
|
||||
fn value_to_string(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Display name: `{display}` object form or a bare string.
|
||||
fn name_display(v: &Value) -> String {
|
||||
if let Some(s) = v.as_str() {
|
||||
return s.to_string();
|
||||
}
|
||||
v.get("display")
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Best URL out of an album `image` Value (string or {large|...}).
|
||||
fn parse_image_value(v: &Value) -> String {
|
||||
if let Some(s) = v.as_str() {
|
||||
return s.to_string();
|
||||
}
|
||||
for key in ["large", "extralarge", "medium", "thumbnail", "small"] {
|
||||
if let Some(s) = v.get(key).and_then(|x| x.as_str()) {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// getArtistImageUrl: image{large|extralarge|medium|thumbnail|small} ->
|
||||
/// picture(string) -> images.portrait hash (medium covers).
|
||||
fn parse_artist_image(raw: &Value) -> String {
|
||||
if let Some(image) = raw.get("image") {
|
||||
let url = parse_image_value(image);
|
||||
if !url.is_empty() {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
if let Some(pic) = raw.get("picture").and_then(|v| v.as_str()) {
|
||||
if !pic.is_empty() {
|
||||
return pic.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(portrait) = raw.get("images").and_then(|i| i.get("portrait")) {
|
||||
if let (Some(hash), Some(format)) = (
|
||||
portrait.get("hash").and_then(|v| v.as_str()),
|
||||
portrait.get("format").and_then(|v| v.as_str()),
|
||||
) {
|
||||
return format!(
|
||||
"https://static.qobuz.com/images/artists/covers/medium/{hash}.{format}"
|
||||
);
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// getPlaylistImage: image.rectangle -> image.covers[0] ->
|
||||
/// image{large|thumbnail|small} -> images300[0] -> images150[0] ->
|
||||
/// images[0].
|
||||
fn parse_playlist_image(raw: &Value) -> String {
|
||||
if let Some(image) = raw.get("image") {
|
||||
if let Some(rect) = image.get("rectangle").and_then(|v| v.as_str()) {
|
||||
if !rect.is_empty() {
|
||||
return rect.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(cover) = image
|
||||
.get("covers")
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return cover.to_string();
|
||||
}
|
||||
for key in ["large", "thumbnail", "small"] {
|
||||
if let Some(s) = image.get(key).and_then(|v| v.as_str()) {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in ["images300", "images150", "images"] {
|
||||
if let Some(s) = raw
|
||||
.get(key)
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// parseLabelExploreImage: string or {large|thumbnail|small}.
|
||||
fn parse_explore_image(v: &Value) -> String {
|
||||
if let Some(s) = v.as_str() {
|
||||
return s.to_string();
|
||||
}
|
||||
for key in ["large", "thumbnail", "small"] {
|
||||
if let Some(s) = v.get(key).and_then(|x| x.as_str()) {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn mmss(secs: u32) -> String {
|
||||
format!("{}:{:02}", secs / 60, secs % 60)
|
||||
}
|
||||
|
||||
/// Word-boundary truncation with an ellipsis (mirrors artist::truncate_words).
|
||||
fn truncate_words(text: &str, max: usize) -> String {
|
||||
if text.chars().count() <= max {
|
||||
return text.to_string();
|
||||
}
|
||||
let truncated: String = text.chars().take(max).collect();
|
||||
let cut = truncated.rfind(' ').unwrap_or(truncated.len());
|
||||
format!("{}…", truncated[..cut].trim_end())
|
||||
}
|
||||
|
||||
// --- Slint mapping -------------------------------------------------------
|
||||
|
||||
fn top_track_to_item(t: &TopTrack) -> TrackItem {
|
||||
TrackItem {
|
||||
id: t.id.clone().into(),
|
||||
number: "".into(),
|
||||
title: t.title.clone().into(),
|
||||
artist: t.artist.clone().into(),
|
||||
album: "".into(),
|
||||
duration: t.duration.clone().into(),
|
||||
quality_tier: t.quality_tier.clone().into(),
|
||||
explicit: false,
|
||||
selected: false,
|
||||
artwork_url: t.artwork_url.clone().into(),
|
||||
artwork: slint::Image::default(),
|
||||
is_favorite: crate::fav_cache::is_favorite(&t.id),
|
||||
artist_id: t.artist_id.clone().into(),
|
||||
album_id: t.album_id.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn playlist_to_item(p: &PlaylistSlim) -> SearchPlaylistItem {
|
||||
SearchPlaylistItem {
|
||||
id: p.id.clone().into(),
|
||||
title: p.title.clone().into(),
|
||||
subtitle: p.subtitle.clone().into(),
|
||||
cover_count: if p.image_url.is_empty() { 0 } else { 1 },
|
||||
url1: p.image_url.clone().into(),
|
||||
url2: "".into(),
|
||||
url3: "".into(),
|
||||
url4: "".into(),
|
||||
cover1: slint::Image::default(),
|
||||
cover2: slint::Image::default(),
|
||||
cover3: slint::Image::default(),
|
||||
cover4: slint::Image::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn artist_to_item(a: &ArtistSlim) -> SlimItem {
|
||||
SlimItem {
|
||||
id: a.id.clone().into(),
|
||||
title: a.name.clone().into(),
|
||||
subtitle: "".into(),
|
||||
rank: "".into(),
|
||||
artwork_url: a.image_url.clone().into(),
|
||||
artwork: slint::Image::default(),
|
||||
following: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn label_to_item(l: &LabelSlim) -> SlimItem {
|
||||
SlimItem {
|
||||
id: l.id.clone().into(),
|
||||
title: l.name.clone().into(),
|
||||
subtitle: "".into(),
|
||||
rank: "".into(),
|
||||
artwork_url: l.image_url.clone().into(),
|
||||
artwork: slint::Image::default(),
|
||||
following: l.following,
|
||||
}
|
||||
}
|
||||
|
||||
fn section(title: &str, cards: &[AlbumCard]) -> DiscoverSection {
|
||||
DiscoverSection {
|
||||
title: title.into(),
|
||||
endpoint: "".into(),
|
||||
albums: ModelRc::new(VecModel::from(
|
||||
cards.iter().cloned().map(to_item).collect::<Vec<_>>(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the landing payload to `LabelState`. Runs on the Slint event loop.
|
||||
pub fn apply_label_page(window: &AppWindow, payload: LabelPagePayload) {
|
||||
PLAY_TOP_TRACKS.with(|c| *c.borrow_mut() = payload.play_tracks.clone());
|
||||
|
||||
let top_tracks: Vec<TrackItem> = payload.top_tracks.iter().map(top_track_to_item).collect();
|
||||
let playlists: Vec<SearchPlaylistItem> =
|
||||
payload.playlists.iter().map(playlist_to_item).collect();
|
||||
let artists: Vec<SlimItem> = payload.artists.iter().map(artist_to_item).collect();
|
||||
let more_labels: Vec<SlimItem> = payload.more_labels.iter().map(label_to_item).collect();
|
||||
let releases = section("Releases", &payload.releases);
|
||||
let critics = section("Critics' Picks", &payload.critics);
|
||||
let jump_tabs = build_label_jump_tabs(&payload);
|
||||
|
||||
let state = window.global::<LabelState>();
|
||||
state.set_id(payload.id.into());
|
||||
state.set_name(payload.name.into());
|
||||
state.set_image_url(payload.image_url.into());
|
||||
state.set_description(payload.description.into());
|
||||
state.set_description_short(payload.description_short.into());
|
||||
state.set_description_truncated(payload.description_truncated);
|
||||
state.set_is_following(payload.is_following);
|
||||
state.set_top_tracks(ModelRc::new(VecModel::from(top_tracks)));
|
||||
state.set_releases_section(releases);
|
||||
state.set_critics_section(critics);
|
||||
state.set_playlists(ModelRc::new(VecModel::from(playlists)));
|
||||
state.set_artists(ModelRc::new(VecModel::from(artists)));
|
||||
state.set_more_labels(ModelRc::new(VecModel::from(more_labels)));
|
||||
state.set_jump_tabs(ModelRc::new(VecModel::from(jump_tabs)));
|
||||
state.set_page_loaded(true);
|
||||
state.set_loading(false);
|
||||
}
|
||||
|
||||
/// Build the JUMP TO tabs for the landing — only sections with content.
|
||||
/// anchor-y values are layout-derived estimates (variable header/grid
|
||||
/// heights make exact numbers impractical; the estimate lands the user
|
||||
/// inside the right section). Mirrors artist::build_jump_tabs.
|
||||
fn build_label_jump_tabs(payload: &LabelPagePayload) -> Vec<JumpNavTab> {
|
||||
const HEADER_GUESS: f32 = 360.0;
|
||||
const SECTION_SPACER: f32 = 40.0;
|
||||
const CAROUSEL: f32 = 320.0;
|
||||
const POPULAR_HEADER: f32 = 46.0;
|
||||
const POPULAR_ROW: f32 = 50.0;
|
||||
const POPULAR_TAIL: f32 = 40.0;
|
||||
|
||||
let mut tabs: Vec<JumpNavTab> = Vec::new();
|
||||
tabs.push(JumpNavTab {
|
||||
id: "about".into(),
|
||||
label: "About".into(),
|
||||
anchor_y: 0.0,
|
||||
});
|
||||
let mut cursor = HEADER_GUESS;
|
||||
|
||||
if !payload.top_tracks.is_empty() {
|
||||
tabs.push(JumpNavTab {
|
||||
id: "popular".into(),
|
||||
label: "Popular Tracks".into(),
|
||||
anchor_y: cursor,
|
||||
});
|
||||
let rows = payload.top_tracks.len().min(5) as f32;
|
||||
cursor += POPULAR_HEADER + rows * POPULAR_ROW + POPULAR_TAIL;
|
||||
}
|
||||
let push_carousel = |tabs: &mut Vec<JumpNavTab>, id: &str, label: &str, present: bool, cursor: &mut f32| {
|
||||
if present {
|
||||
tabs.push(JumpNavTab {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
anchor_y: *cursor,
|
||||
});
|
||||
*cursor += SECTION_SPACER + CAROUSEL;
|
||||
}
|
||||
};
|
||||
push_carousel(&mut tabs, "releases", "Releases", !payload.releases.is_empty(), &mut cursor);
|
||||
push_carousel(&mut tabs, "critics", "Critics' Picks", !payload.critics.is_empty(), &mut cursor);
|
||||
push_carousel(&mut tabs, "playlists", "Playlists", !payload.playlists.is_empty(), &mut cursor);
|
||||
push_carousel(&mut tabs, "artists", "Artists", !payload.artists.is_empty(), &mut cursor);
|
||||
push_carousel(&mut tabs, "labels", "More Labels", !payload.more_labels.is_empty(), &mut cursor);
|
||||
tabs
|
||||
}
|
||||
|
||||
/// Artwork jobs for the landing sections (top-track thumbs + carousels).
|
||||
pub fn page_artwork_jobs(payload: &LabelPagePayload) -> Vec<ArtworkJob> {
|
||||
let mut jobs = Vec::new();
|
||||
let push = |jobs: &mut Vec<ArtworkJob>, url: &str, target: ArtworkTarget| {
|
||||
if !url.is_empty() {
|
||||
jobs.push(ArtworkJob {
|
||||
url: url.to_string(),
|
||||
target,
|
||||
});
|
||||
}
|
||||
};
|
||||
for (i, t) in payload.top_tracks.iter().enumerate() {
|
||||
push(&mut jobs, &t.artwork_url, ArtworkTarget::LabelTopTrack { index: i });
|
||||
}
|
||||
for (i, a) in payload.releases.iter().enumerate() {
|
||||
push(&mut jobs, &a.artwork_url, ArtworkTarget::LabelReleaseAlbum { index: i });
|
||||
}
|
||||
for (i, a) in payload.critics.iter().enumerate() {
|
||||
push(&mut jobs, &a.artwork_url, ArtworkTarget::LabelCriticsAlbum { index: i });
|
||||
}
|
||||
for (i, p) in payload.playlists.iter().enumerate() {
|
||||
push(&mut jobs, &p.image_url, ArtworkTarget::LabelPlaylistCover { index: i });
|
||||
}
|
||||
for (i, a) in payload.artists.iter().enumerate() {
|
||||
push(&mut jobs, &a.image_url, ArtworkTarget::LabelArtist { index: i });
|
||||
}
|
||||
for (i, l) in payload.more_labels.iter().enumerate() {
|
||||
push(&mut jobs, &l.image_url, ArtworkTarget::LabelMoreLabel { index: i });
|
||||
}
|
||||
jobs
|
||||
}
|
||||
|
||||
/// Current follow state for `label_id` — the header when it's the open
|
||||
/// label, else the matching More-Labels card.
|
||||
pub fn label_following_state(window: &AppWindow, label_id: &str) -> bool {
|
||||
let state = window.global::<LabelState>();
|
||||
if state.get_id().as_str() == label_id {
|
||||
return state.get_is_following();
|
||||
}
|
||||
let model = state.get_more_labels();
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(item) = model.row_data(i) {
|
||||
if item.id.as_str() == label_id {
|
||||
return item.following;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Name of a More-Labels card by id (nav-history fallback for a card click).
|
||||
pub fn more_label_name(window: &AppWindow, label_id: &str) -> String {
|
||||
let model = window.global::<LabelState>().get_more_labels();
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(item) = model.row_data(i) {
|
||||
if item.id.as_str() == label_id {
|
||||
return item.title.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Optimistically reflect a follow toggle — flips the header state when
|
||||
/// it's the current label, and the matching more-labels card.
|
||||
pub fn mark_label_followed(window: &AppWindow, label_id: &str, following: bool) {
|
||||
let state = window.global::<LabelState>();
|
||||
if state.get_id().as_str() == label_id {
|
||||
state.set_is_following(following);
|
||||
}
|
||||
let model = state.get_more_labels();
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(mut item) = model.row_data(i) {
|
||||
if item.id.as_str() == label_id {
|
||||
item.following = following;
|
||||
model.set_row_data(i, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the landing state before loading a new label.
|
||||
pub fn reset_label_page(window: &AppWindow) {
|
||||
let state = window.global::<LabelState>();
|
||||
state.set_name("".into());
|
||||
state.set_image_url("".into());
|
||||
state.set_image(slint::Image::default());
|
||||
state.set_description("".into());
|
||||
state.set_description_short("".into());
|
||||
state.set_description_truncated(false);
|
||||
state.set_is_following(false);
|
||||
state.set_top_tracks(ModelRc::new(VecModel::from(Vec::<TrackItem>::new())));
|
||||
state.set_releases_section(DiscoverSection::default());
|
||||
state.set_critics_section(DiscoverSection::default());
|
||||
state.set_playlists(ModelRc::new(VecModel::from(Vec::<SearchPlaylistItem>::new())));
|
||||
state.set_artists(ModelRc::new(VecModel::from(Vec::<SlimItem>::new())));
|
||||
state.set_more_labels(ModelRc::new(VecModel::from(Vec::<SlimItem>::new())));
|
||||
state.set_jump_tabs(ModelRc::new(VecModel::from(Vec::<JumpNavTab>::new())));
|
||||
state.set_page_loaded(false);
|
||||
state.set_loading(true);
|
||||
}
|
||||
+187
-10
@@ -582,6 +582,16 @@ fn apply_entry(
|
||||
nav::NavEntry::Label { id, name } => {
|
||||
navigate_label(runtime.clone(), weak.clone(), handle, image_cache.clone(), id, name);
|
||||
}
|
||||
nav::NavEntry::LabelReleases { id, name } => {
|
||||
navigate_label_releases(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle,
|
||||
image_cache.clone(),
|
||||
id,
|
||||
name,
|
||||
);
|
||||
}
|
||||
nav::NavEntry::DiscoverBrowse { endpoint, title } => {
|
||||
discover_browse::navigate(
|
||||
runtime.clone(),
|
||||
@@ -661,8 +671,9 @@ fn navigate_location(
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a LabelReleasesView for `label_id`. Fetches the label header
|
||||
/// + first album page, then the header image.
|
||||
/// Open the LabelView landing — the rich label page (header + popular
|
||||
/// tracks + releases/critics/playlists/artists/more-labels carousels).
|
||||
/// Reached by clicking a label anywhere.
|
||||
fn navigate_label(
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
@@ -673,9 +684,54 @@ fn navigate_label(
|
||||
) {
|
||||
handle.spawn(async move {
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
label::reset_label(&w);
|
||||
label::reset_label_page(&w);
|
||||
w.global::<NavState>().set_view(ContentView::Label);
|
||||
});
|
||||
match label::load_label_page(&runtime, label_id, &name).await {
|
||||
Ok(payload) => {
|
||||
let jobs = label::page_artwork_jobs(&payload);
|
||||
let image_url = payload.image_url.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
label::apply_label_page(&w, payload);
|
||||
});
|
||||
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 page load failed: {e}");
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
let s = w.global::<LabelState>();
|
||||
s.set_loading(false);
|
||||
s.set_page_loaded(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the full "See all releases" sub-view for `label_id`. Fetches the
|
||||
/// label header + first album page, then the header image.
|
||||
fn navigate_label_releases(
|
||||
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::LabelReleases);
|
||||
});
|
||||
match label::load_label(&runtime, label_id, &name).await {
|
||||
Ok(data) => {
|
||||
let jobs = label::artwork_jobs(&data);
|
||||
@@ -695,7 +751,7 @@ fn navigate_label(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] label load failed: {e}");
|
||||
log::error!("[qbz-slint] label releases load failed: {e}");
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
w.global::<LabelState>().set_loading(false);
|
||||
});
|
||||
@@ -2193,15 +2249,99 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
// === Label landing actions ===============================
|
||||
("label", "follow") => {
|
||||
// Toggle the label favorite, optimistically flipping the
|
||||
// header + any matching More-Labels card.
|
||||
if let Some(w) = weak.upgrade() {
|
||||
let make = !label::label_following_state(&w, &id);
|
||||
label::mark_label_followed(&w, &id, make);
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
let label_id = id.clone();
|
||||
handle.spawn(async move {
|
||||
let res = if make {
|
||||
runtime.core().add_favorite("label", &label_id).await
|
||||
} else {
|
||||
runtime.core().remove_favorite("label", &label_id).await
|
||||
};
|
||||
if let Err(e) = res {
|
||||
log::error!("[qbz-slint] toggle label favorite failed: {e}");
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
label::mark_label_followed(&w, &label_id, !make);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
("label", "play-top") => {
|
||||
// Popular tracks are cached on the UI thread by
|
||||
// apply_label_page; read them here (UI thread) + queue.
|
||||
let tracks = label::top_tracks_for_play();
|
||||
if tracks.is_empty() {
|
||||
crate::toast::error_weak(&weak, "No popular tracks for this label");
|
||||
} else {
|
||||
playback::play_tracks(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
tracks,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
// More-Labels card click -> open that label's landing.
|
||||
("label", "open") => {
|
||||
if let Ok(label_id) = id.parse::<u64>() {
|
||||
let name = weak
|
||||
.upgrade()
|
||||
.map(|w| label::more_label_name(&w, &id))
|
||||
.unwrap_or_default();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// "See all" -> the full releases sub-view for the open label.
|
||||
("label", "see-all-releases") => {
|
||||
if let (Some(w), Ok(label_id)) = (weak.upgrade(), id.parse::<u64>()) {
|
||||
let name = w.global::<LabelState>().get_name().to_string();
|
||||
nav::record(nav::NavEntry::LabelReleases {
|
||||
id: label_id,
|
||||
name: name.clone(),
|
||||
});
|
||||
navigate_label_releases(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
&handle,
|
||||
image_cache.clone(),
|
||||
label_id,
|
||||
name,
|
||||
);
|
||||
update_nav_flags(&w);
|
||||
}
|
||||
}
|
||||
("track", "toggle-select") => {
|
||||
// Flip `selected` on the matching row, in whichever
|
||||
// multi-select surface is showing: the playlist
|
||||
// detail or the artist Popular Tracks.
|
||||
// multi-select surface is showing: the playlist detail,
|
||||
// the artist Popular Tracks, or the label Popular Tracks.
|
||||
if let Some(w) = weak.upgrade() {
|
||||
let model = if w.global::<NavState>().get_view() == ContentView::Playlist {
|
||||
w.global::<PlaylistState>().get_tracks()
|
||||
} else {
|
||||
w.global::<ArtistState>().get_top_tracks()
|
||||
let model = match w.global::<NavState>().get_view() {
|
||||
ContentView::Playlist => w.global::<PlaylistState>().get_tracks(),
|
||||
ContentView::Label => w.global::<LabelState>().get_top_tracks(),
|
||||
_ => w.global::<ArtistState>().get_top_tracks(),
|
||||
};
|
||||
if let Some(vm) = model
|
||||
.as_any()
|
||||
@@ -2936,6 +3076,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Label releases sub-view toolbar — sort / Hi-Res filter /
|
||||
// group-by-artist / search. The markup updates the bound LabelState
|
||||
// property first; each callback just re-derives the rendered list
|
||||
// (local filter over the loaded catalog).
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<LabelActions>().on_set_sort(move |_| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
label::derive_releases(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<LabelActions>().on_set_hires(move |_| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
label::derive_releases(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<LabelActions>().on_set_group(move |_| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
label::derive_releases(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.global::<LabelActions>().on_search(move |_| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
label::derive_releases(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Scene (location) view actions — open-artist routes to the
|
||||
// artist page, load-more validates the next page of candidates.
|
||||
{
|
||||
|
||||
@@ -57,12 +57,19 @@ pub enum NavEntry {
|
||||
name: String,
|
||||
role: String,
|
||||
},
|
||||
/// LabelReleasesView — opened by the artist network sidebar's
|
||||
/// Labels section. Carries the label id + name fallback.
|
||||
/// LabelView landing — the rich label page (header + popular tracks +
|
||||
/// releases / critics / playlists / artists / more-labels carousels).
|
||||
/// Reached by clicking a label anywhere. Carries the id + name fallback.
|
||||
Label {
|
||||
id: u64,
|
||||
name: String,
|
||||
},
|
||||
/// LabelReleasesView — the "See all releases" sub-view reached from the
|
||||
/// landing's Releases carousel. Carries the label id + name fallback.
|
||||
LabelReleases {
|
||||
id: u64,
|
||||
name: String,
|
||||
},
|
||||
/// ArtistsByLocationView — opened by the Origin section's
|
||||
/// location link. Carries the full scene-discovery payload.
|
||||
Location {
|
||||
|
||||
@@ -30,43 +30,7 @@ import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { ReleaseGrid } from "ReleaseGrid.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { JumpNavBar, JumpNavTab } from "../primitives/JumpNavBar.slint";
|
||||
|
||||
component CircleAction inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <bool> primary: false;
|
||||
// Stays "pressed" — same surface as a hover, icon tinted accent.
|
||||
// Used by the Network button while the sidebar is open.
|
||||
in property <bool> active: false;
|
||||
in property <length> diameter: 40px;
|
||||
callback clicked();
|
||||
|
||||
width: root.diameter;
|
||||
height: root.diameter;
|
||||
border-radius: root.diameter / 2;
|
||||
// Ghost circle — translucent fill with a faint ring, never a solid
|
||||
// accent fill (mirrors the Tauri transport controls).
|
||||
background: (ta.has-hover || root.active) ? Theme.surface-hover : transparent;
|
||||
border-width: 1px;
|
||||
border-color: (ta.has-hover || root.active) ? Theme.text-disabled : Theme.border-subtle;
|
||||
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
width: root.primary ? 19px : 17px;
|
||||
height: root.primary ? 19px : 17px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: root.active
|
||||
? Theme.accent
|
||||
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
import { CircleAction } from "../primitives/CircleAction.slint";
|
||||
|
||||
component SectionTitle inherits Text {
|
||||
color: Theme.text-primary;
|
||||
|
||||
@@ -12,8 +12,15 @@ import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
|
||||
export component ArtistCard inherits Rectangle {
|
||||
in property <SlimItem> artist;
|
||||
// Follow-chip behavior:
|
||||
// "auto" show only when NOT following (artist search default);
|
||||
// "toggle" always show, reflecting Follow/Following (label more-labels);
|
||||
// "none" never show (label artists carousel — no follow there).
|
||||
in property <string> follow-mode: "auto";
|
||||
// kind passed in the follow media-action ("artist" | "label").
|
||||
in property <string> follow-kind: "artist";
|
||||
callback clicked(string);
|
||||
// kind ("artist"), artist id, action ("follow").
|
||||
// kind ("artist" | "label"), id, action ("follow").
|
||||
callback media-action(string, string, string);
|
||||
|
||||
width: 160px;
|
||||
@@ -73,9 +80,11 @@ export component ArtistCard inherits Rectangle {
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// Rectangular Follow chip (ADR-008 — no pills). Shown only when
|
||||
// the artist is not yet followed.
|
||||
if !root.artist.following: HorizontalLayout {
|
||||
// Rectangular Follow / Following chip (ADR-008 — no pills). See
|
||||
// `follow-mode`: "auto" shows it only when not yet following,
|
||||
// "toggle" always shows it (and reflects the state), "none" hides it.
|
||||
if (root.follow-mode == "auto" && !root.artist.following)
|
||||
|| root.follow-mode == "toggle": HorizontalLayout {
|
||||
alignment: center;
|
||||
spacing: 0px;
|
||||
Rectangle {
|
||||
@@ -93,14 +102,16 @@ export component ArtistCard inherits Rectangle {
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/user-plus.svg");
|
||||
source: root.artist.following
|
||||
? @image-url("../assets/icons/check.svg")
|
||||
: @image-url("../assets/icons/user-plus.svg");
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
tint: Theme.text-primary;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: @tr("Follow");
|
||||
text: root.artist.following ? @tr("Following") : @tr("Follow");
|
||||
color: Theme.text-primary;
|
||||
font-size: 12px;
|
||||
font-weight: Typography.medium;
|
||||
@@ -110,7 +121,7 @@ export component ArtistCard inherits Rectangle {
|
||||
follow-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.media-action("artist", root.artist.id, "follow");
|
||||
root.media-action(root.follow-kind, root.artist.id, "follow");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ export component ArtistCarousel inherits VerticalLayout {
|
||||
in property <string> title;
|
||||
in property <[SlimItem]> artists;
|
||||
in property <bool> show-view-all: false;
|
||||
// Follow-chip behavior + kind for the cards (see ArtistCard).
|
||||
// Defaults keep the artist-search usage unchanged.
|
||||
in property <string> card-follow-mode: "auto";
|
||||
in property <string> card-follow-kind: "artist";
|
||||
callback artist-clicked(string);
|
||||
callback media-action(string, string, string);
|
||||
callback view-all-clicked();
|
||||
@@ -141,6 +145,8 @@ export component ArtistCarousel inherits VerticalLayout {
|
||||
spacing: root.gap;
|
||||
for artist in root.artists: ArtistCard {
|
||||
artist: artist;
|
||||
follow-mode: root.card-follow-mode;
|
||||
follow-kind: root.card-follow-kind;
|
||||
clicked(id) => {
|
||||
root.artist-clicked(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
// Label landing page — the rich label view (Tauri's LabelView.svelte):
|
||||
// a circular-portrait header with description + Follow, a sticky JUMP TO
|
||||
// bar, a Popular Tracks list, and Releases / Critics' Picks / Playlists /
|
||||
// Artists / More Labels carousels.
|
||||
//
|
||||
// Faithful 1:1 port assembled from existing primitives — the header,
|
||||
// bio modal and Popular Tracks block mirror ArtistPageView; the carousels
|
||||
// reuse Carousel / PlaylistCarousel / ArtistCarousel verbatim (More Labels
|
||||
// reuses ArtistCarousel with the follow-toggle + label kind). No new
|
||||
// widgets; the only new piece is this composition.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { LabelState, LabelActions, TrackItem } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { TrackRow } from "../primitives/TrackRow.slint";
|
||||
import { CircleAction } from "../primitives/CircleAction.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { JumpNavBar } from "../primitives/JumpNavBar.slint";
|
||||
import { Carousel } from "../discover/Carousel.slint";
|
||||
import { ArtistCarousel } from "../discover/ArtistCarousel.slint";
|
||||
import { PlaylistCarousel } from "../discover/PlaylistCarousel.slint";
|
||||
|
||||
component SectionTitle inherits Text {
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: Typography.semibold;
|
||||
}
|
||||
|
||||
export component LabelPageView inherits Rectangle {
|
||||
callback open-album(string);
|
||||
callback open-artist(string);
|
||||
callback media-action(string, string, string);
|
||||
|
||||
// Full-description modal (same pattern as ArtistPageView's bio modal).
|
||||
property <bool> show-desc: false;
|
||||
// Active JUMP TO tab (set on click; passive scroll-tracking is a
|
||||
// follow-up, matching ArtistPageView).
|
||||
property <string> active-jump-tab: "about";
|
||||
// Popular Tracks progressive reveal: 5 -> 20 -> 50 (Tauri's steps).
|
||||
property <int> preview-count: 5;
|
||||
property <bool> multi-select-mode: false;
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
page-flickable := 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;
|
||||
|
||||
// --- History navigation --------------------------------------
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
NavButtons { }
|
||||
}
|
||||
|
||||
Rectangle { height: 22px; }
|
||||
|
||||
// --- Label header --------------------------------------------
|
||||
HorizontalLayout {
|
||||
spacing: 32px;
|
||||
alignment: start;
|
||||
|
||||
// Circular 180px portrait + Disc-3 placeholder.
|
||||
Rectangle {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 90px;
|
||||
background: Theme.surface-elevated;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-color: #00000080;
|
||||
drop-shadow-offset-y: 8px;
|
||||
clip: true;
|
||||
if LabelState.image-url == "": QbzIcon {
|
||||
source: @image-url("../assets/icons/disc-3.svg");
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: LabelState.image;
|
||||
image-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: start;
|
||||
padding-top: 8px;
|
||||
spacing: 0px;
|
||||
horizontal-stretch: 1;
|
||||
|
||||
Text {
|
||||
text: @tr("LABEL");
|
||||
color: Theme.text-muted;
|
||||
font-size: 10px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
Rectangle { height: 6px; }
|
||||
Text {
|
||||
text: LabelState.name;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.section;
|
||||
font-weight: Typography.bold;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// Description (HTML-stripped) + READ MORE -> modal.
|
||||
if LabelState.description != "": Rectangle { height: 12px; }
|
||||
if LabelState.description != "": Text {
|
||||
width: root.width - 296px;
|
||||
text: LabelState.description-short;
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.legal;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if LabelState.description-truncated: Rectangle { height: 4px; }
|
||||
if LabelState.description-truncated: read-more := TouchArea {
|
||||
width: read-more-row.preferred-width;
|
||||
height: 20px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.show-desc = true;
|
||||
}
|
||||
read-more-row := HorizontalLayout {
|
||||
Text {
|
||||
text: @tr("Read more");
|
||||
color: read-more.has-hover ? Theme.accent-hover : Theme.accent;
|
||||
font-size: Typography.legal;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 18px; }
|
||||
|
||||
// Follow / Following — ghost circle, accent-tinted when
|
||||
// followed. Toggles via the global media-action channel.
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
CircleAction {
|
||||
diameter: 44px;
|
||||
active: LabelState.is-following;
|
||||
icon: LabelState.is-following
|
||||
? @image-url("../assets/icons/check.svg")
|
||||
: @image-url("../assets/icons/user-plus.svg");
|
||||
clicked => {
|
||||
root.media-action("label", LabelState.id, "follow");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reservation for the sticky JUMP TO overlay (its 44px + gap).
|
||||
// The overlay anchors its natural y to this element.
|
||||
jump-anchor := Rectangle { height: 52px; }
|
||||
|
||||
// --- Loading (first paint) -----------------------------------
|
||||
if LabelState.loading && LabelState.top-tracks.length == 0
|
||||
&& !LabelState.page-loaded: Rectangle {
|
||||
height: 280px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 18px;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
Text {
|
||||
text: @tr("Loading…");
|
||||
color: Theme.text-muted;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Popular Tracks ------------------------------------------
|
||||
if LabelState.top-tracks.length > 0: HorizontalLayout {
|
||||
spacing: 12px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
SectionTitle {
|
||||
text: @tr("Popular Tracks");
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
horizontal-stretch: 0;
|
||||
spacing: 12px;
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/play-fill.svg");
|
||||
primary: true;
|
||||
clicked => {
|
||||
root.media-action("label", LabelState.id, "play-top");
|
||||
}
|
||||
}
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/square-check-big.svg");
|
||||
active: root.multi-select-mode;
|
||||
clicked => {
|
||||
root.multi-select-mode = !root.multi-select-mode;
|
||||
}
|
||||
}
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/ellipsis.svg");
|
||||
clicked => {
|
||||
root.media-action("label", "", "top-tracks-menu");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if LabelState.top-tracks.length > 0: Rectangle { height: 10px; }
|
||||
|
||||
// Select-all row (multi-select mode).
|
||||
if root.multi-select-mode && LabelState.top-tracks.length > 0: HorizontalLayout {
|
||||
height: 36px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: 16px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
Rectangle {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
background: transparent;
|
||||
border-width: 1.5px;
|
||||
border-color: check-all-ta.has-hover
|
||||
? Theme.text-primary
|
||||
: Theme.text-muted;
|
||||
check-all-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.media-action("label", "", "select-all-top-tracks");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: @tr("Select all");
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for track[index] in LabelState.top-tracks: TrackRow {
|
||||
visible: index < root.preview-count;
|
||||
height: self.visible ? 50px : 0px;
|
||||
track: track;
|
||||
index: index;
|
||||
// Label tracks carry the album thumbnail (Tauri shows it).
|
||||
show-artwork: true;
|
||||
multi-select-mode: root.multi-select-mode;
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
// Load more (5 -> 20 -> 50) / View less.
|
||||
if LabelState.top-tracks.length > 5: Rectangle { height: 4px; }
|
||||
if LabelState.top-tracks.length > 5: HorizontalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: reveal-text.preferred-width + 24px;
|
||||
height: 28px;
|
||||
reveal-text := Text {
|
||||
text: (root.preview-count < 50
|
||||
&& LabelState.top-tracks.length > root.preview-count)
|
||||
? @tr("Load more")
|
||||
: @tr("View less");
|
||||
color: reveal-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
||||
font-size: 13px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
reveal-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
if (root.preview-count < 50
|
||||
&& LabelState.top-tracks.length > root.preview-count) {
|
||||
root.preview-count = root.preview-count == 5 ? 20 : 50;
|
||||
} else {
|
||||
root.preview-count = 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Releases carousel (+ See all) ---------------------------
|
||||
if LabelState.releases-section.albums.length > 0: Rectangle { height: 32px; }
|
||||
if LabelState.releases-section.albums.length > 0: Carousel {
|
||||
section: LabelState.releases-section;
|
||||
show-view-all: true;
|
||||
album-clicked(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
view-all-clicked => {
|
||||
root.media-action("label", LabelState.id, "see-all-releases");
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
if (kind == "artist") {
|
||||
root.open-artist(id);
|
||||
} else {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Critics' Picks carousel ---------------------------------
|
||||
if LabelState.critics-section.albums.length > 0: Rectangle { height: 32px; }
|
||||
if LabelState.critics-section.albums.length > 0: Carousel {
|
||||
section: LabelState.critics-section;
|
||||
show-view-all: false;
|
||||
album-clicked(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
if (kind == "artist") {
|
||||
root.open-artist(id);
|
||||
} else {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Playlists carousel --------------------------------------
|
||||
if LabelState.playlists.length > 0: Rectangle { height: 32px; }
|
||||
if LabelState.playlists.length > 0: PlaylistCarousel {
|
||||
title: @tr("Playlists");
|
||||
playlists: LabelState.playlists;
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Artists carousel ----------------------------------------
|
||||
if LabelState.artists.length > 0: Rectangle { height: 32px; }
|
||||
if LabelState.artists.length > 0: ArtistCarousel {
|
||||
title: @tr("Artists");
|
||||
artists: LabelState.artists;
|
||||
// No follow chip in the label Artists carousel (matches Tauri).
|
||||
card-follow-mode: "none";
|
||||
artist-clicked(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
|
||||
// --- More Labels carousel ------------------------------------
|
||||
if LabelState.more-labels.length > 0: Rectangle { height: 32px; }
|
||||
if LabelState.more-labels.length > 0: ArtistCarousel {
|
||||
title: @tr("More labels");
|
||||
artists: LabelState.more-labels;
|
||||
// Round cards with a Follow/Following toggle, kind = label.
|
||||
card-follow-mode: "toggle";
|
||||
card-follow-kind: "label";
|
||||
artist-clicked(id) => {
|
||||
root.media-action("label", id, "open");
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared scrollbar in the right gutter (homologated cross-view).
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: page-flickable.viewport-height;
|
||||
visible-height: page-flickable.height;
|
||||
viewport-y <=> page-flickable.viewport-y;
|
||||
}
|
||||
|
||||
// --- Sticky JUMP TO bar -------------------------------------------------
|
||||
// Same sticky math as ArtistPageView: tracks the page scroll until the
|
||||
// header scrolls away, then pins to y = 0. No inline search (Tauri's
|
||||
// label jump-nav has none).
|
||||
if LabelState.jump-tabs.length > 1: JumpNavBar {
|
||||
property <length> natural-top:
|
||||
jump-anchor.absolute-position.y - page-flickable.absolute-position.y;
|
||||
x: 0;
|
||||
y: max(self.natural-top, 0px);
|
||||
width: root.width;
|
||||
show-search: false;
|
||||
tabs: LabelState.jump-tabs;
|
||||
active-tab-id: root.active-jump-tab;
|
||||
tab-clicked(id, anchor-y) => {
|
||||
root.active-jump-tab = id;
|
||||
page-flickable.viewport-y = -(anchor-y - self.height);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full-description modal ---------------------------------------------
|
||||
if root.show-desc: Rectangle {
|
||||
background: #000000bf;
|
||||
TouchArea {
|
||||
mouse-cursor: default;
|
||||
clicked => {
|
||||
root.show-desc = false;
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: Math.min(root.width - 80px, 560px);
|
||||
height: Math.min(root.height - 120px, 460px);
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: Radius.md;
|
||||
background: Theme.surface-card;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-color: #00000080;
|
||||
TouchArea { }
|
||||
VerticalLayout {
|
||||
padding: 24px;
|
||||
spacing: 14px;
|
||||
HorizontalLayout {
|
||||
Text {
|
||||
text: @tr("About");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: Typography.semibold;
|
||||
horizontal-stretch: 1;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
close-desc := TouchArea {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.show-desc = false;
|
||||
}
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: close-desc.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-width: self.width;
|
||||
viewport-height: desc-content.preferred-height;
|
||||
desc-content := VerticalLayout {
|
||||
width: parent.width;
|
||||
Text {
|
||||
text: LabelState.description;
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
// 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.
|
||||
// LabelReleasesView — the "See all releases" sub-view reached from the
|
||||
// label landing's Releases carousel. A circular label header over the
|
||||
// full album catalog, with a toolbar (search / group-by-artist / Hi-Res
|
||||
// filter / sort) and a grid/list toggle, plus load-more pagination.
|
||||
//
|
||||
// The rendered set (`visible` / `grouped`) is derived in Rust from the
|
||||
// loaded catalog (crate::label::derive_releases). Search is a local
|
||||
// filter over the loaded catalog.
|
||||
|
||||
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 { LabelState, LabelActions, AlbumCardItem, DiscoverSection } from "../state.slint";
|
||||
import { AlbumCard } from "../discover/AlbumCard.slint";
|
||||
import { AlbumListRow, AlbumListHeader } from "../primitives/AlbumListRow.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
||||
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
|
||||
// One half of the grid/list segmented toggle — identical to
|
||||
// DiscoverBrowseView's ToggleButton so the two views read the same.
|
||||
// One half of a segmented toggle (grid/list) or a standalone toolbar
|
||||
// toggle (group / Hi-Res). Identical surface to DiscoverBrowseView's.
|
||||
component ToggleButton inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <bool> active: false;
|
||||
@@ -45,14 +50,49 @@ component ToggleButton inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapping album grid over a model — shared by the flat view and each
|
||||
// group-by-artist section so the column math lives in one place.
|
||||
component AlbumGrid inherits Rectangle {
|
||||
in property <[AlbumCardItem]> albums;
|
||||
in property <length> card-width: 220px;
|
||||
in property <length> card-height: 266px;
|
||||
in property <length> card-gap: 24px;
|
||||
callback open-album(string);
|
||||
callback open-artist(string);
|
||||
callback media-action(string, string, string);
|
||||
|
||||
property <int> columns: Math.max(
|
||||
1,
|
||||
Math.floor((self.width + root.card-gap) / (root.card-width + root.card-gap)));
|
||||
property <int> rows: Math.ceil(root.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 root.albums: AlbumCard {
|
||||
x: Math.mod(i, root.columns) * (root.card-width + root.card-gap);
|
||||
y: Math.floor(i / root.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) => {
|
||||
if (kind == "artist") {
|
||||
root.open-artist(id);
|
||||
} else {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component LabelReleasesView inherits Rectangle {
|
||||
callback open-album(string);
|
||||
callback open-artist(string);
|
||||
callback media-action(string, string, string);
|
||||
|
||||
property <length> card-width: 220px;
|
||||
property <length> card-height: 266px;
|
||||
property <length> card-gap: 24px;
|
||||
property <length> list-row-gap: 4px;
|
||||
|
||||
background: Theme.surface-main;
|
||||
@@ -77,30 +117,33 @@ export component LabelReleasesView inherits Rectangle {
|
||||
|
||||
Rectangle { height: 22px; }
|
||||
|
||||
// --- Label header ------------------------------------------
|
||||
// --- Label header (circular 180px portrait) ------------------
|
||||
HorizontalLayout {
|
||||
spacing: 24px;
|
||||
alignment: start;
|
||||
Rectangle {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: Radius.md;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 90px;
|
||||
background: Theme.surface-elevated;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-color: #00000080;
|
||||
drop-shadow-offset-y: 8px;
|
||||
clip: true;
|
||||
if LabelState.image-url == "": QbzIcon {
|
||||
source: @image-url("../assets/icons/disc-3.svg");
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
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;
|
||||
@@ -120,35 +163,122 @@ export component LabelReleasesView inherits Rectangle {
|
||||
font-weight: Typography.bold;
|
||||
}
|
||||
if LabelState.total > 0: Text {
|
||||
text: LabelState.total + " " + @tr("releases");
|
||||
text: LabelState.total + " " + @tr("albums");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Grid/list segmented toggle — floats right of the header,
|
||||
// same widget + behavior as DiscoverBrowseView.
|
||||
Rectangle { height: 24px; }
|
||||
|
||||
// --- Toolbar: title + count, then search / group / Hi-Res /
|
||||
// sort / grid-list (floated right) ---------------------------
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
alignment: stretch;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 68px;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
background: Theme.surface-elevated;
|
||||
clip: true;
|
||||
HorizontalLayout {
|
||||
ToggleButton {
|
||||
icon: @image-url("../assets/icons/layout-grid.svg");
|
||||
active: LabelState.view-mode == "grid";
|
||||
clicked => {
|
||||
LabelState.view-mode = "grid";
|
||||
}
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: @tr("Releases");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: Typography.semibold;
|
||||
}
|
||||
Text {
|
||||
text: LabelState.shown + " " + @tr("albums")
|
||||
+ (LabelState.hires-count > 0
|
||||
? " · " + LabelState.hires-count + " Hi-Res"
|
||||
: "");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
horizontal-stretch: 0;
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ExpandableSearch {
|
||||
placeholder: @tr("Search this label…");
|
||||
query <=> LabelState.search-query;
|
||||
edited(q) => {
|
||||
LabelActions.search(q);
|
||||
}
|
||||
ToggleButton {
|
||||
icon: @image-url("../assets/icons/list.svg");
|
||||
active: LabelState.view-mode == "list";
|
||||
clicked => {
|
||||
LabelState.view-mode = "list";
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ToggleButton {
|
||||
icon: @image-url("../assets/icons/user.svg");
|
||||
active: LabelState.group-by-artist;
|
||||
clicked => {
|
||||
LabelState.group-by-artist = !LabelState.group-by-artist;
|
||||
LabelActions.set-group(LabelState.group-by-artist);
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ToggleButton {
|
||||
icon: @image-url("../assets/icons/list-filter.svg");
|
||||
active: LabelState.filter-hires;
|
||||
clicked => {
|
||||
LabelState.filter-hires = !LabelState.filter-hires;
|
||||
LabelActions.set-hires(LabelState.filter-hires);
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzSelect {
|
||||
menu-width: 168px;
|
||||
options: [
|
||||
@tr("Newest"),
|
||||
@tr("Oldest"),
|
||||
@tr("Title A-Z"),
|
||||
@tr("Title Z-A"),
|
||||
@tr("Artist A-Z"),
|
||||
];
|
||||
current-index: LabelState.sort-by == "newest" ? 0
|
||||
: LabelState.sort-by == "oldest" ? 1
|
||||
: LabelState.sort-by == "title-asc" ? 2
|
||||
: LabelState.sort-by == "title-desc" ? 3
|
||||
: 4;
|
||||
selected(i) => {
|
||||
LabelState.sort-by = i == 0 ? "newest"
|
||||
: i == 1 ? "oldest"
|
||||
: i == 2 ? "title-asc"
|
||||
: i == 3 ? "title-desc"
|
||||
: "artist-asc";
|
||||
LabelActions.set-sort(LabelState.sort-by);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Grid / list segmented toggle.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 68px;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
background: Theme.surface-elevated;
|
||||
clip: true;
|
||||
HorizontalLayout {
|
||||
ToggleButton {
|
||||
icon: @image-url("../assets/icons/layout-grid.svg");
|
||||
active: LabelState.view-mode == "grid";
|
||||
clicked => {
|
||||
LabelState.view-mode = "grid";
|
||||
}
|
||||
}
|
||||
ToggleButton {
|
||||
icon: @image-url("../assets/icons/list.svg");
|
||||
active: LabelState.view-mode == "list";
|
||||
clicked => {
|
||||
LabelState.view-mode = "list";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,52 +286,85 @@ export component LabelReleasesView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 32px; }
|
||||
Rectangle { height: 24px; }
|
||||
|
||||
// --- States --------------------------------------------------
|
||||
if LabelState.loading && LabelState.albums.length == 0: HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
|
||||
// --- Album grid --------------------------------------------
|
||||
if LabelState.albums.length > 0
|
||||
&& LabelState.view-mode == "grid": 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);
|
||||
// No search/filter results.
|
||||
if !LabelState.loading && LabelState.albums.length > 0
|
||||
&& LabelState.shown == 0: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 10px;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/search.svg");
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
if (kind == "artist") {
|
||||
}
|
||||
Text {
|
||||
text: @tr("No results");
|
||||
color: Theme.text-muted;
|
||||
font-size: 14px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Grouped by artist ---------------------------------------
|
||||
if LabelState.group-by-artist: VerticalLayout {
|
||||
spacing: 0px;
|
||||
for section[s] in LabelState.grouped: VerticalLayout {
|
||||
spacing: 12px;
|
||||
if s > 0: Rectangle { height: 24px; }
|
||||
Text {
|
||||
text: section.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.heading;
|
||||
font-weight: Typography.semibold;
|
||||
}
|
||||
AlbumGrid {
|
||||
albums: section.albums;
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
open-artist(id) => {
|
||||
root.open-artist(id);
|
||||
} else {
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Album list --------------------------------------------
|
||||
// Reusable AlbumListRow; SOURCE column hidden (single-source
|
||||
// Qobuz catalog). Header labels the columns.
|
||||
if LabelState.albums.length > 0
|
||||
// --- Flat grid -----------------------------------------------
|
||||
if !LabelState.group-by-artist && LabelState.visible.length > 0
|
||||
&& LabelState.view-mode == "grid": AlbumGrid {
|
||||
albums: LabelState.visible;
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
open-artist(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Flat list -----------------------------------------------
|
||||
if !LabelState.group-by-artist && LabelState.visible.length > 0
|
||||
&& LabelState.view-mode == "list": VerticalLayout {
|
||||
spacing: root.list-row-gap;
|
||||
AlbumListHeader {
|
||||
show-source: false;
|
||||
}
|
||||
for album[i] in LabelState.albums: AlbumListRow {
|
||||
for album[i] in LabelState.visible: AlbumListRow {
|
||||
album: album;
|
||||
index: i;
|
||||
show-source: false;
|
||||
@@ -218,6 +381,7 @@ export component LabelReleasesView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Load more -----------------------------------------------
|
||||
if LabelState.albums.length > 0
|
||||
&& LabelState.albums.length < LabelState.total: HorizontalLayout {
|
||||
alignment: center;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Ghost circular action button — translucent fill with a faint ring,
|
||||
// icon tinted on hover/active (never a solid accent fill, mirroring the
|
||||
// Tauri transport controls). Shared by the artist + label page headers
|
||||
// and their Popular Tracks clusters (Play all / Select / More).
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { QbzIcon } from "QbzIcon.slint";
|
||||
|
||||
export component CircleAction inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <bool> primary: false;
|
||||
// Stays "pressed" — same surface as hover, icon tinted accent.
|
||||
in property <bool> active: false;
|
||||
in property <length> diameter: 40px;
|
||||
callback clicked();
|
||||
|
||||
width: root.diameter;
|
||||
height: root.diameter;
|
||||
border-radius: root.diameter / 2;
|
||||
background: (ta.has-hover || root.active) ? Theme.surface-hover : transparent;
|
||||
border-width: 1px;
|
||||
border-color: (ta.has-hover || root.active) ? Theme.text-disabled : Theme.border-subtle;
|
||||
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
width: root.primary ? 19px : 17px;
|
||||
height: root.primary ? 19px : 17px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: root.active
|
||||
? Theme.accent
|
||||
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ export struct JumpNavTab {
|
||||
export component JumpNavBar inherits Rectangle {
|
||||
in property <[JumpNavTab]> tabs;
|
||||
in property <string> active-tab-id;
|
||||
// Whether the inline page-search affordance is offered. Artist pages
|
||||
// have it; the label page does not (Tauri's label jump-nav has no
|
||||
// search), so the label view passes show-search: false.
|
||||
in property <bool> show-search: true;
|
||||
in-out property <bool> search-open: false;
|
||||
in-out property <string> search-text;
|
||||
|
||||
@@ -244,7 +248,7 @@ export component JumpNavBar inherits Rectangle {
|
||||
// expanded input run all the way to the bar's right padding
|
||||
// (right where the user just clicked the lupa). The inline
|
||||
// input carries its own X close button.
|
||||
if !root.search-open: VerticalLayout {
|
||||
if root.show-search && !root.search-open: VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 0;
|
||||
Rectangle {
|
||||
|
||||
@@ -25,6 +25,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 { LabelPageView } from "../label/LabelPageView.slint";
|
||||
import { ArtistsByLocationView } from "../location/ArtistsByLocationView.slint";
|
||||
import { FavoritesView } from "../favorites/FavoritesView.slint";
|
||||
import { MixView } from "../mix/MixView.slint";
|
||||
@@ -132,7 +133,18 @@ export component AppShell inherits Rectangle {
|
||||
root.open-album(id);
|
||||
}
|
||||
}
|
||||
if NavState.view == ContentView.label: LabelReleasesView {
|
||||
if NavState.view == ContentView.label: LabelPageView {
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
open-artist(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
if NavState.view == ContentView.label-releases: LabelReleasesView {
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,25 @@ export struct SlimItem {
|
||||
following: bool,
|
||||
}
|
||||
|
||||
// A playlist result in search (and the label landing's Playlists row).
|
||||
// Qobuz returns up to four cover URLs per playlist; the card renders them
|
||||
// as a count-based collage. Defined here (before LabelState) so the label
|
||||
// landing can reuse it — Slint structs must be declared before first use.
|
||||
export struct SearchPlaylistItem {
|
||||
id: string,
|
||||
title: string,
|
||||
subtitle: string, // owner name + track count
|
||||
cover-count: int, // number of cover images, 0-4
|
||||
url1: string,
|
||||
url2: string,
|
||||
url3: string,
|
||||
url4: string,
|
||||
cover1: image,
|
||||
cover2: image,
|
||||
cover3: image,
|
||||
cover4: image,
|
||||
}
|
||||
|
||||
// Discover / Home screen state.
|
||||
export global HomeState {
|
||||
in property <[DiscoverSection]> sections: [];
|
||||
@@ -273,7 +292,7 @@ export global DragActions {
|
||||
}
|
||||
|
||||
// Which content view the shell shows.
|
||||
export enum ContentView { home, album, artist, settings, search, musician, label, location, favorites, mix, playlist, discover-browse, playlist-manager }
|
||||
export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager }
|
||||
|
||||
// In-app toast (transient notification). Mirrors Tauri's toastStore:
|
||||
// a single toast at a time (a new one replaces the current), auto-hidden
|
||||
@@ -1131,17 +1150,77 @@ export global LabelState {
|
||||
in property <string> name;
|
||||
in property <string> image-url;
|
||||
in property <image> image;
|
||||
// Releases sub-view (LabelReleasesView) — the paginated album grid.
|
||||
// `albums` is the full loaded catalog + the artwork-pipeline target
|
||||
// (stable indices); `visible` is the sorted/filtered/searched subset
|
||||
// the view actually renders (shares `albums` when no filter is active,
|
||||
// so artwork stays live — mirrors DiscoverBrowseState).
|
||||
in property <[AlbumCardItem]> albums: [];
|
||||
in property <[AlbumCardItem]> visible: [];
|
||||
in property <int> total: 0;
|
||||
in property <bool> loading: false;
|
||||
in property <bool> load-more-loading: false;
|
||||
// "grid" | "list" — the view toggle (mirrors DiscoverBrowseView).
|
||||
// UI-owned, like Discover's; no Rust round-trip needed.
|
||||
in-out property <string> view-mode: "grid";
|
||||
|
||||
// --- Landing page (LabelPageView) -----------------------------------
|
||||
// Header description (HTML-stripped) + truncated form + the flag that
|
||||
// gates the READ MORE affordance (same pattern as ArtistState.bio).
|
||||
in property <string> description;
|
||||
in property <string> description-short;
|
||||
in property <bool> description-truncated: false;
|
||||
// Header follow state — true when the label is in the user's favorites.
|
||||
in property <bool> is-following: false;
|
||||
// Popular tracks (album-thumbnail rows, multi-select capable).
|
||||
in property <[TrackItem]> top-tracks: [];
|
||||
// Releases + Critics' Picks carousels (reuse Carousel).
|
||||
in property <DiscoverSection> releases-section;
|
||||
in property <DiscoverSection> critics-section;
|
||||
// Playlists / Artists / More-labels carousels. More-labels reuses
|
||||
// ArtistCarousel (SlimItem already carries `following`).
|
||||
in property <[SearchPlaylistItem]> playlists: [];
|
||||
in property <[SlimItem]> artists: [];
|
||||
in property <[SlimItem]> more-labels: [];
|
||||
// Sticky JUMP TO tabs — only the sections that have content.
|
||||
in property <[JumpNavTab]> jump-tabs: [];
|
||||
// True once the landing page-data load resolves (gates the spinner).
|
||||
in property <bool> page-loaded: false;
|
||||
|
||||
// --- Releases sub-view toolbar (UI-owned, like Discover) ------------
|
||||
// "newest" | "oldest" | "title-asc" | "title-desc" | "artist-asc".
|
||||
in-out property <string> sort-by: "newest";
|
||||
in-out property <bool> filter-hires: false;
|
||||
in-out property <bool> group-by-artist: false;
|
||||
// Releases sub-view in-view search query (drives the count line).
|
||||
in-out property <string> search-query: "";
|
||||
// Group-by-artist rendering — one section per artist (reuses the
|
||||
// DiscoverSection + grid pattern). Empty unless group-by-artist is on.
|
||||
in property <[DiscoverSection]> grouped: [];
|
||||
// Count of albums actually shown after filter/search (for the count line).
|
||||
in property <int> shown: 0;
|
||||
// Count of Hi-Res albums in the loaded catalog (count line).
|
||||
in property <int> hires-count: 0;
|
||||
}
|
||||
|
||||
export global LabelActions {
|
||||
// Releases sub-view: fetch the next album page.
|
||||
callback load-more();
|
||||
// Landing: toggle follow on the header label or a more-labels card.
|
||||
callback toggle-follow(string /* label id */);
|
||||
// Landing: play all popular tracks.
|
||||
callback play-top();
|
||||
// Landing: top-tracks header overflow menu (play-next / queue / shuffle).
|
||||
callback top-tracks-menu();
|
||||
// Landing: enter/leave multi-select for popular tracks (select-all).
|
||||
callback select-all-top-tracks();
|
||||
// Landing: open the full releases sub-view ("See all").
|
||||
callback open-releases();
|
||||
// Releases sub-view toolbar.
|
||||
callback set-sort(string /* option */);
|
||||
callback set-hires(bool);
|
||||
callback set-group(bool);
|
||||
callback search(string /* query */);
|
||||
}
|
||||
|
||||
// One artist in the scene-discovery grid (artists from the same
|
||||
@@ -1322,23 +1401,6 @@ export global PlaylistPickerActions {
|
||||
callback close();
|
||||
}
|
||||
|
||||
// A playlist result in search. Qobuz returns up to four cover URLs per
|
||||
// playlist; the card renders them as a count-based collage.
|
||||
export struct SearchPlaylistItem {
|
||||
id: string,
|
||||
title: string,
|
||||
subtitle: string, // owner name + track count
|
||||
cover-count: int, // number of cover images, 0-4
|
||||
url1: string,
|
||||
url2: string,
|
||||
url3: string,
|
||||
url4: string,
|
||||
cover1: image,
|
||||
cover2: image,
|
||||
cover3: image,
|
||||
cover4: image,
|
||||
}
|
||||
|
||||
// One artist tile in the Favorites > Artists grid.
|
||||
export struct FavoriteArtistItem {
|
||||
id: string,
|
||||
|
||||
Reference in new issue
Block a user