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:
vicrodh committed 2026-05-26 18:35:42 -06:00
1 parent b2004e6867
commit 56c924d8ea
13 files changed
+1986 -152

No files matched your search

+55 -1
View File
@@ -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) {
+842 -3
View File
@@ -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
View File
@@ -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.
{
+9 -2
View File
@@ -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;
+18 -7
View File
@@ -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;
}
}
}
}
}
}
}
+236 -72
View File
@@ -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 {
+13 -1
View File
@@ -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);
}
+80 -18
View File
@@ -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,