feat(slint): Discover "View all" pages + reusable AlbumListRow + recently-played metadata
Per-module discover See-All pages (new releases, press accolades, ideal discography, qobuzissimes, albums of the week, most streamed) reachable from each carousel's "View all" link: a paginated grid OR list view with client-side search, genre filter, and a fixed header. Adds a reusable AlbumListRow (columns #/ITEM/TYPE/SOURCE/QUALITY/TRACKS/YEAR, SOURCE hideable, reuses QualityBadgeFull) with play-on-hover + even/odd zebra. Recently Played albums now carry genre + localized release date + quality (captured at play time, no extra fetch). Shared home::map_album / card_to_item reused; grid-only AlbumCardItem literals get ..Default. cc
This commit is contained in:
16 files changed
+1262
-25
No files matched your search
@@ -349,6 +349,7 @@ fn map_compilation_album(album: PageArtistTrackAlbum) -> CardData {
|
||||
ribbon: String::new(),
|
||||
ribbon_kind: String::new(),
|
||||
artwork_url,
|
||||
..CardData::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +407,7 @@ fn map_release(release: PageArtistRelease) -> CardData {
|
||||
ribbon: String::new(),
|
||||
ribbon_kind: String::new(),
|
||||
artwork_url,
|
||||
..CardData::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +452,7 @@ fn card_to_item(card: CardData) -> AlbumCardItem {
|
||||
ribbon_kind: card.ribbon_kind.into(),
|
||||
artwork_url: card.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +499,8 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) {
|
||||
.into_iter()
|
||||
.map(|section| DiscoverSection {
|
||||
title: section.title.into(),
|
||||
// Artist release sections have no Discover full-list page.
|
||||
endpoint: "".into(),
|
||||
albums: ModelRc::new(VecModel::from(
|
||||
section.cards.into_iter().map(card_to_item).collect::<Vec<_>>(),
|
||||
)),
|
||||
@@ -649,6 +654,7 @@ pub fn filter_artist(window: &AppWindow, query: &str) {
|
||||
}
|
||||
Some(DiscoverSection {
|
||||
title: section.title.clone(),
|
||||
endpoint: section.endpoint.clone(),
|
||||
albums: ModelRc::new(VecModel::from(kept)),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Discover "View all" full-list controller.
|
||||
//!
|
||||
//! Opens a single album module (New Releases, Press Accolades, Ideal
|
||||
//! Discography, Qobuzissimes, Albums of the Week, Most Streamed) as a
|
||||
//! paginated full-grid (or list) page. The Carousel's "View all" link
|
||||
//! fires `discover-view-all(endpoint, title)`; the shell records the
|
||||
//! history entry, switches the view to ContentView::DiscoverBrowse, and
|
||||
//! calls `navigate` here.
|
||||
//!
|
||||
//! Pagination is driven off the backend `has_more` flag (the discover
|
||||
//! endpoints carry no `total`): each page advances `offset` by the
|
||||
//! returned item count and stops once `has_more` is false. Reuses the
|
||||
//! Discover home mappers (`crate::home::map_album` / `card_to_item`) so
|
||||
//! the cards carry the same genre + localized release date as the home
|
||||
//! carousels.
|
||||
//!
|
||||
//! Header tools (mirroring Tauri's DiscoverBrowseView): a client-side
|
||||
//! search filter over the loaded albums (disables load-more while
|
||||
//! active), the shared genre filter (re-fetches from offset 0 with the
|
||||
//! selected genre ids), and a grid/list view toggle.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
||||
|
||||
use crate::adapter::SlintAdapter;
|
||||
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
|
||||
use crate::home::CardData;
|
||||
use crate::{AlbumCardItem, AppWindow, ContentView, DiscoverBrowseState, NavState};
|
||||
|
||||
/// Page size — two carousel pages' worth, fetched per request.
|
||||
const PAGE_SIZE: u32 = 50;
|
||||
|
||||
/// Open the full-list page for `endpoint` and load its first page, then
|
||||
/// fan out artwork. `genre_ids` is the shared genre-filter selection
|
||||
/// (None = no filter). Mirrors `navigate_favorites` in main.rs.
|
||||
pub fn navigate(
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: &tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
endpoint: String,
|
||||
title: String,
|
||||
genre_ids: Option<Vec<u64>>,
|
||||
) {
|
||||
let endpoint_for_fetch = endpoint.clone();
|
||||
let genre_for_fetch = genre_ids.clone();
|
||||
handle.spawn(async move {
|
||||
// Reset the page state and switch the view on the UI thread. The
|
||||
// search query is cleared on a fresh navigation; the view mode is
|
||||
// left as-is so it persists across pages.
|
||||
{
|
||||
let title = title.clone();
|
||||
let endpoint = endpoint.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let state = w.global::<DiscoverBrowseState>();
|
||||
state.set_title(title.into());
|
||||
state.set_endpoint(endpoint.into());
|
||||
state.set_next_offset(0);
|
||||
state.set_search_query("".into());
|
||||
state.set_albums(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
|
||||
state.set_visible(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
|
||||
state.set_loading(true);
|
||||
state.set_loading_more(false);
|
||||
state.set_has_more(true);
|
||||
w.global::<NavState>().set_view(ContentView::DiscoverBrowse);
|
||||
});
|
||||
}
|
||||
|
||||
match runtime
|
||||
.core()
|
||||
.get_discover_albums(&endpoint_for_fetch, genre_for_fetch, 0, PAGE_SIZE)
|
||||
.await
|
||||
{
|
||||
Ok(data) => {
|
||||
let has_more = data.has_more;
|
||||
// CardData is plain/Send — map it to the (non-Send)
|
||||
// AlbumCardItem inside the event-loop closure below.
|
||||
let cards: Vec<CardData> =
|
||||
data.items.into_iter().map(crate::home::map_album).collect();
|
||||
let next_offset = cards.len() as i32;
|
||||
let jobs = artwork_jobs(&cards, 0);
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let items: Vec<AlbumCardItem> =
|
||||
cards.into_iter().map(crate::home::card_to_item).collect();
|
||||
let state = w.global::<DiscoverBrowseState>();
|
||||
state.set_albums(ModelRc::new(VecModel::from(items)));
|
||||
state.set_next_offset(next_offset);
|
||||
state.set_has_more(has_more);
|
||||
state.set_loading(false);
|
||||
apply_filter(&w);
|
||||
});
|
||||
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] discover-browse load failed: {e}");
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
let state = w.global::<DiscoverBrowseState>();
|
||||
state.set_loading(false);
|
||||
state.set_has_more(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch the next page (offset = DiscoverBrowseState.next-offset) and
|
||||
/// append it to the grid. Wired to DiscoverBrowseActions::load-more.
|
||||
/// `genre_ids` is the shared genre-filter selection (None = no filter).
|
||||
pub fn load_more(
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: &tokio::runtime::Handle,
|
||||
image_cache: ImageCache,
|
||||
genre_ids: Option<Vec<u64>>,
|
||||
) {
|
||||
let Some(w) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let state = w.global::<DiscoverBrowseState>();
|
||||
if !state.get_has_more() || state.get_loading_more() {
|
||||
return;
|
||||
}
|
||||
// A non-empty search filters the loaded set client-side; pulling more
|
||||
// pages while filtering matches no UX (Tauri disables load-more too).
|
||||
if !state.get_search_query().is_empty() {
|
||||
return;
|
||||
}
|
||||
let endpoint = state.get_endpoint().to_string();
|
||||
let offset = state.get_next_offset().max(0) as u32;
|
||||
state.set_loading_more(true);
|
||||
|
||||
handle.spawn(async move {
|
||||
match runtime
|
||||
.core()
|
||||
.get_discover_albums(&endpoint, genre_ids, offset, PAGE_SIZE)
|
||||
.await
|
||||
{
|
||||
Ok(data) => {
|
||||
let has_more = data.has_more;
|
||||
let cards: Vec<CardData> =
|
||||
data.items.into_iter().map(crate::home::map_album).collect();
|
||||
let jobs = artwork_jobs(&cards, offset as usize);
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
append_albums(&w, cards, has_more);
|
||||
});
|
||||
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] discover-browse load-more failed: {e}");
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
w.global::<DiscoverBrowseState>().set_loading_more(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Append a freshly-fetched page onto the existing grid, advancing the
|
||||
/// offset by the new item count and updating has-more. UI thread only.
|
||||
fn append_albums(window: &AppWindow, cards: Vec<CardData>, has_more: bool) {
|
||||
let state = window.global::<DiscoverBrowseState>();
|
||||
let model = state.get_albums();
|
||||
let mut combined: Vec<AlbumCardItem> = (0..model.row_count())
|
||||
.filter_map(|i| model.row_data(i))
|
||||
.collect();
|
||||
let added = cards.len() as i32;
|
||||
combined.extend(cards.into_iter().map(crate::home::card_to_item));
|
||||
state.set_albums(ModelRc::new(VecModel::from(combined)));
|
||||
state.set_next_offset(state.get_next_offset() + added);
|
||||
state.set_has_more(has_more);
|
||||
state.set_loading_more(false);
|
||||
apply_filter(window);
|
||||
}
|
||||
|
||||
/// Rebuild `visible` from `albums` honoring the current search query
|
||||
/// (case-insensitive substring over title + artist). UI thread only.
|
||||
/// Wired to DiscoverBrowseActions::search-changed and called after every
|
||||
/// model mutation so the rendered list stays consistent.
|
||||
pub fn apply_filter(window: &AppWindow) {
|
||||
let state = window.global::<DiscoverBrowseState>();
|
||||
let query = state.get_search_query().trim().to_lowercase();
|
||||
let albums = state.get_albums();
|
||||
if query.is_empty() {
|
||||
// No filter — share the SAME model so artwork-pipeline updates
|
||||
// (which mutate `albums[index]`) propagate to the rendered list
|
||||
// without rebuilding it. This is the common case.
|
||||
state.set_visible(albums);
|
||||
return;
|
||||
}
|
||||
let visible: Vec<AlbumCardItem> = (0..albums.row_count())
|
||||
.filter_map(|i| albums.row_data(i))
|
||||
.filter(|a| {
|
||||
a.title.to_lowercase().contains(&query)
|
||||
|| a.artist.to_lowercase().contains(&query)
|
||||
})
|
||||
.collect();
|
||||
state.set_visible(ModelRc::new(VecModel::from(visible)));
|
||||
}
|
||||
|
||||
/// Artwork jobs for a page of cards, targeting their absolute indices
|
||||
/// (`base_index` is the offset of the first card in the model).
|
||||
fn artwork_jobs(cards: &[CardData], base_index: usize) -> Vec<ArtworkJob> {
|
||||
cards
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, card)| !card.artwork_url.is_empty())
|
||||
.map(|(i, card)| ArtworkJob {
|
||||
url: card.artwork_url.clone(),
|
||||
target: ArtworkTarget::DiscoverBrowseAlbum {
|
||||
index: base_index + i,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -345,6 +345,7 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
|
||||
ribbon_kind: "".into(),
|
||||
artwork_url: a.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
state.set_albums(ModelRc::new(VecModel::from(cards)));
|
||||
|
||||
@@ -423,6 +423,7 @@ fn album_items(cards: &[AlbumCard]) -> Vec<AlbumCardItem> {
|
||||
ribbon_kind: "".into(),
|
||||
artwork_url: c.artwork_url.clone().into(),
|
||||
artwork: slint::Image::default(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -445,6 +446,8 @@ fn artist_items(artists: &[ArtistSlim]) -> Vec<SlimItem> {
|
||||
fn section(title: &str, cards: &[AlbumCard]) -> DiscoverSection {
|
||||
DiscoverSection {
|
||||
title: title.into(),
|
||||
// For You sections have no Discover full-list page.
|
||||
endpoint: "".into(),
|
||||
albums: ModelRc::new(VecModel::from(album_items(cards))),
|
||||
}
|
||||
}
|
||||
|
||||
+114
-12
@@ -44,10 +44,12 @@ struct TabSections {
|
||||
#[derive(Clone)]
|
||||
pub struct SectionData {
|
||||
pub title: String,
|
||||
/// Discover endpoint path for the "View all" page ("" = no full-list page).
|
||||
pub endpoint: String,
|
||||
pub albums: Vec<CardData>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CardData {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
@@ -64,6 +66,17 @@ pub struct CardData {
|
||||
pub ribbon: String,
|
||||
pub ribbon_kind: String,
|
||||
pub artwork_url: String,
|
||||
// --- List-row extras (AlbumListRow); empty/default for grid-only data.
|
||||
/// "Album" | "EP" | "Single" | "Live" | "Compilation".
|
||||
pub release_type: String,
|
||||
/// "qobuz" | "local" | "plex" | "" — the hideable SOURCE column.
|
||||
pub source: String,
|
||||
/// "24-bit / 96 kHz" — the bare detail line for QualityBadgeFull.
|
||||
pub quality_detail: String,
|
||||
/// Track count, as a display string ("" = unknown).
|
||||
pub track_count: String,
|
||||
/// Bare 4-digit year for the list-row YEAR column ("" = unknown).
|
||||
pub plain_year: String,
|
||||
}
|
||||
|
||||
/// A compact ranked item for the slim grid sections.
|
||||
@@ -96,33 +109,37 @@ where
|
||||
// Home set and the most-streamed slim grid below. Order mirrors
|
||||
// Tauri's DEFAULT_PREFS.editorPicks.
|
||||
let mut editor_sections = Vec::new();
|
||||
push_section_ref(&mut editor_sections, "New Releases", &containers.new_releases);
|
||||
push_section_ref(&mut editor_sections, "Qobuzissimes", &containers.qobuzissims);
|
||||
push_section_ref(&mut editor_sections, "Press Accolades", &containers.press_awards);
|
||||
push_section_ref(&mut editor_sections, "Most Streamed", &containers.most_streamed);
|
||||
push_section_ref(&mut editor_sections, "New Releases", "/discover/newReleases", &containers.new_releases);
|
||||
push_section_ref(&mut editor_sections, "Qobuzissimes", "/discover/qobuzissims", &containers.qobuzissims);
|
||||
push_section_ref(&mut editor_sections, "Press Accolades", "/discover/pressAward", &containers.press_awards);
|
||||
push_section_ref(&mut editor_sections, "Most Streamed", "/discover/mostStreamed", &containers.most_streamed);
|
||||
push_section_ref(
|
||||
&mut editor_sections,
|
||||
"Ideal Discography",
|
||||
"/discover/idealDiscography",
|
||||
&containers.ideal_discography,
|
||||
);
|
||||
push_section_ref(
|
||||
&mut editor_sections,
|
||||
"Albums of the Week",
|
||||
"/discover/albumOfTheWeek",
|
||||
&containers.album_of_the_week,
|
||||
);
|
||||
|
||||
let mut sections = Vec::new();
|
||||
push_section(&mut sections, "New Releases", containers.new_releases);
|
||||
push_section(&mut sections, "Press Accolades", containers.press_awards);
|
||||
push_section(&mut sections, "New Releases", "/discover/newReleases", containers.new_releases);
|
||||
push_section(&mut sections, "Press Accolades", "/discover/pressAward", containers.press_awards);
|
||||
push_section(
|
||||
&mut sections,
|
||||
"Ideal Discography",
|
||||
"/discover/idealDiscography",
|
||||
containers.ideal_discography,
|
||||
);
|
||||
push_section(&mut sections, "Qobuzissimes", containers.qobuzissims);
|
||||
push_section(&mut sections, "Qobuzissimes", "/discover/qobuzissims", containers.qobuzissims);
|
||||
push_section(
|
||||
&mut sections,
|
||||
"Albums of the Week",
|
||||
"/discover/albumOfTheWeek",
|
||||
containers.album_of_the_week,
|
||||
);
|
||||
|
||||
@@ -163,13 +180,22 @@ where
|
||||
title: album.title,
|
||||
artist: album.artist,
|
||||
artist_id: String::new(),
|
||||
genre: String::new(),
|
||||
year: String::new(),
|
||||
genre: album.genre,
|
||||
// Localize the stored ISO release date to "MMM D, YYYY" the
|
||||
// same way the discover cards do (empty stays empty).
|
||||
year: if album.release_date.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
crate::dates::release_label(Some(&album.release_date))
|
||||
},
|
||||
quality_tier: album.quality_tier,
|
||||
quality_label: album.quality_label,
|
||||
ribbon: String::new(),
|
||||
ribbon_kind: String::new(),
|
||||
artwork_url: album.artwork_url,
|
||||
// Recently-played cards render in the grid only — list-row
|
||||
// extras stay default.
|
||||
..CardData::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -185,6 +211,7 @@ where
|
||||
fn push_section(
|
||||
out: &mut Vec<SectionData>,
|
||||
title: &str,
|
||||
endpoint: &str,
|
||||
container: Option<DiscoverContainer<DiscoverAlbum>>,
|
||||
) {
|
||||
let Some(container) = container else {
|
||||
@@ -195,6 +222,7 @@ fn push_section(
|
||||
}
|
||||
out.push(SectionData {
|
||||
title: title.to_string(),
|
||||
endpoint: endpoint.to_string(),
|
||||
albums: container.data.items.into_iter().map(map_album).collect(),
|
||||
});
|
||||
}
|
||||
@@ -204,6 +232,7 @@ fn push_section(
|
||||
fn push_section_ref(
|
||||
out: &mut Vec<SectionData>,
|
||||
title: &str,
|
||||
endpoint: &str,
|
||||
container: &Option<DiscoverContainer<DiscoverAlbum>>,
|
||||
) {
|
||||
let Some(container) = container else {
|
||||
@@ -214,11 +243,12 @@ fn push_section_ref(
|
||||
}
|
||||
out.push(SectionData {
|
||||
title: title.to_string(),
|
||||
endpoint: endpoint.to_string(),
|
||||
albums: container.data.items.iter().cloned().map(map_album).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
fn map_album(album: DiscoverAlbum) -> CardData {
|
||||
pub(crate) fn map_album(album: DiscoverAlbum) -> CardData {
|
||||
let artist = album
|
||||
.artists
|
||||
.first()
|
||||
@@ -240,12 +270,25 @@ fn map_album(album: DiscoverAlbum) -> CardData {
|
||||
let (ribbon, ribbon_kind) = pick_ribbon(album.awards.as_deref());
|
||||
let quality_tier = quality_tier(album.audio_info.as_ref()).to_string();
|
||||
let quality_label = quality_label(album.audio_info.as_ref());
|
||||
let quality_detail = quality_detail(album.audio_info.as_ref());
|
||||
let artwork_url = album
|
||||
.image
|
||||
.large
|
||||
.or(album.image.thumbnail)
|
||||
.or(album.image.small)
|
||||
.unwrap_or_default();
|
||||
// Bare 4-digit year for the list-row YEAR column (the grid uses the
|
||||
// localized `year`); plus a track-count display string and a release
|
||||
// type heuristic for the list-row TYPE column.
|
||||
let plain_year = album
|
||||
.dates
|
||||
.as_ref()
|
||||
.and_then(|d| d.original.as_ref().or(d.download.as_ref()).or(d.stream.as_ref()))
|
||||
.and_then(|s| s.get(0..4))
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let track_count = album.track_count.map(|n| n.to_string()).unwrap_or_default();
|
||||
let release_type = classify_release_type(album.track_count).to_string();
|
||||
CardData {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
@@ -258,9 +301,44 @@ fn map_album(album: DiscoverAlbum) -> CardData {
|
||||
ribbon,
|
||||
ribbon_kind,
|
||||
artwork_url,
|
||||
release_type,
|
||||
// Discover is always the Qobuz catalog.
|
||||
source: "qobuz".to_string(),
|
||||
quality_detail,
|
||||
track_count,
|
||||
plain_year,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a Discover album's release type for the list-row TYPE column.
|
||||
/// The Discover index carries no explicit release_type, so this mirrors
|
||||
/// DiscographyBuilderView's track-count fallback heuristic (<=3 = Single,
|
||||
/// <=6 = EP, otherwise 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",
|
||||
}
|
||||
}
|
||||
|
||||
/// Bare exact-quality detail for QualityBadgeFull's detail line, e.g.
|
||||
/// "24-bit / 96 kHz" (no "Hi-Res:" prefix — the badge supplies the tier
|
||||
/// label itself). Empty when the entry carries no audio info.
|
||||
fn quality_detail(audio: Option<&DiscoverAudioInfo>) -> String {
|
||||
let Some(audio) = audio else {
|
||||
return String::new();
|
||||
};
|
||||
let hi_res = matches!(audio.maximum_bit_depth, Some(depth) if depth >= 24);
|
||||
let depth = audio
|
||||
.maximum_bit_depth
|
||||
.unwrap_or(if hi_res { 24 } else { 16 });
|
||||
let rate = audio
|
||||
.maximum_sampling_rate
|
||||
.unwrap_or(if hi_res { 96.0 } else { 44.1 });
|
||||
format!("{depth}-bit / {} kHz", format_rate(rate))
|
||||
}
|
||||
|
||||
fn map_slim(index: usize, album: DiscoverAlbum) -> SlimData {
|
||||
let subtitle = album
|
||||
.artists
|
||||
@@ -343,7 +421,7 @@ fn format_rate(rate: f64) -> String {
|
||||
}
|
||||
|
||||
/// Convert one `CardData` into the Slint `AlbumCardItem`.
|
||||
fn card_to_item(card: CardData) -> AlbumCardItem {
|
||||
pub(crate) fn card_to_item(card: CardData) -> AlbumCardItem {
|
||||
AlbumCardItem {
|
||||
id: card.id.into(),
|
||||
title: card.title.into(),
|
||||
@@ -357,6 +435,11 @@ fn card_to_item(card: CardData) -> AlbumCardItem {
|
||||
ribbon_kind: card.ribbon_kind.into(),
|
||||
artwork_url: card.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
release_type: card.release_type.into(),
|
||||
source: card.source.into(),
|
||||
quality_detail: card.quality_detail.into(),
|
||||
track_count: card.track_count.into(),
|
||||
plain_year: card.plain_year.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +449,7 @@ fn build_sections(sections: &[SectionData]) -> Vec<DiscoverSection> {
|
||||
.iter()
|
||||
.map(|section| DiscoverSection {
|
||||
title: section.title.clone().into(),
|
||||
endpoint: section.endpoint.clone().into(),
|
||||
albums: ModelRc::new(VecModel::from(
|
||||
section.albums.iter().cloned().map(card_to_item).collect::<Vec<_>>(),
|
||||
)),
|
||||
@@ -476,6 +560,24 @@ mod tests {
|
||||
assert_eq!(quality_tier(Some(&audio(Some(24)))), "hires");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_release_type_track_count_heuristic() {
|
||||
assert_eq!(classify_release_type(Some(1)), "Single");
|
||||
assert_eq!(classify_release_type(Some(3)), "Single");
|
||||
assert_eq!(classify_release_type(Some(4)), "EP");
|
||||
assert_eq!(classify_release_type(Some(6)), "EP");
|
||||
assert_eq!(classify_release_type(Some(12)), "Album");
|
||||
assert_eq!(classify_release_type(None), "Album");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_detail_bare_without_tier_prefix() {
|
||||
// The list-row QualityBadgeFull supplies the tier label itself,
|
||||
// so the detail line is just "<depth>-bit / <rate> kHz".
|
||||
assert_eq!(quality_detail(Some(&audio(Some(24)))), "24-bit / 96 kHz");
|
||||
assert_eq!(quality_detail(None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_tier_cd_for_16_bit() {
|
||||
assert_eq!(quality_tier(Some(&audio(Some(16)))), "cd");
|
||||
|
||||
@@ -161,6 +161,9 @@ fn to_item(card: AlbumCard) -> AlbumCardItem {
|
||||
ribbon_kind: "".into(),
|
||||
artwork_url: card.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
// Label grid cards render as grid AlbumCards only — list-row
|
||||
// extras stay default.
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,6 +262,30 @@ fn make_queue_track(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the album-level metadata (genre, release date, quality) captured
|
||||
/// when an album is fetched for playback, so `record_recent` can stamp the
|
||||
/// Recently Played card with the same genre + release date + quality badge
|
||||
/// the Discover carousels show. Mirrors Tauri's `album_to_card_meta`, which
|
||||
/// reads these straight off the `Album`.
|
||||
fn album_card_meta(album: &qbz_models::Album) -> crate::recently::AlbumMeta {
|
||||
let genre = album
|
||||
.genre
|
||||
.as_ref()
|
||||
.map(|g| g.name.clone())
|
||||
.unwrap_or_default();
|
||||
let release_date = album.release_date_original.clone().unwrap_or_default();
|
||||
// The album summary carries its own max bit depth / sample rate, which
|
||||
// are more reliable than a single track's for the card badge.
|
||||
let (quality_tier, quality_label) =
|
||||
recent_quality(album.maximum_bit_depth, album.maximum_sampling_rate);
|
||||
crate::recently::AlbumMeta {
|
||||
genre,
|
||||
release_date,
|
||||
quality_tier,
|
||||
quality_label,
|
||||
}
|
||||
}
|
||||
|
||||
/// Quality tier + exact-quality label from a queue track's bit depth /
|
||||
/// sample rate, matching the discover card badge format.
|
||||
fn recent_quality(bit_depth: Option<u32>, sample_rate: Option<f64>) -> (String, String) {
|
||||
@@ -293,19 +317,35 @@ async fn record_recent(runtime: &Runtime) {
|
||||
return;
|
||||
};
|
||||
let artwork = track.artwork_url.clone().unwrap_or_default();
|
||||
let (quality_tier, quality_label) =
|
||||
recent_quality(track.bit_depth, track.sample_rate);
|
||||
let album_id = track.album_id.clone().unwrap_or_default();
|
||||
// Prefer the album-level metadata captured at album-fetch time (genre,
|
||||
// release date, and the album's own max quality) over the single
|
||||
// track's values — the `album/get` track summaries are often partial.
|
||||
let meta = crate::recently::album_meta(&album_id).unwrap_or_default();
|
||||
let (track_tier, track_label) = recent_quality(track.bit_depth, track.sample_rate);
|
||||
let quality_tier = if !meta.quality_tier.is_empty() {
|
||||
meta.quality_tier
|
||||
} else {
|
||||
track_tier
|
||||
};
|
||||
let quality_label = if !meta.quality_label.is_empty() {
|
||||
meta.quality_label
|
||||
} else {
|
||||
track_label
|
||||
};
|
||||
crate::recently::record(crate::recently::RecentTrack {
|
||||
id: track.id.to_string(),
|
||||
title: track.title.clone(),
|
||||
subtitle: track.artist.clone(),
|
||||
artwork_url: artwork.clone(),
|
||||
album_id: track.album_id.clone().unwrap_or_default(),
|
||||
album_id,
|
||||
album_title: track.album.clone(),
|
||||
album_artist: track.artist.clone(),
|
||||
album_artwork_url: artwork,
|
||||
quality_tier,
|
||||
quality_label,
|
||||
genre: meta.genre,
|
||||
release_date: meta.release_date,
|
||||
artist_id: track.artist_id,
|
||||
});
|
||||
// Per-artist play count — feeds the discovery filter "skip
|
||||
@@ -337,6 +377,9 @@ pub fn play_album(
|
||||
let album_title = album.title.clone();
|
||||
let album_artist = album.artist.name.clone();
|
||||
let album_artwork = album.image.best().cloned().unwrap_or_default();
|
||||
// Cache the album's genre / release date / quality so the Recently
|
||||
// Played card the play records below carries them (no extra fetch).
|
||||
crate::recently::remember_album_meta(&album.id, album_card_meta(&album));
|
||||
|
||||
let tracks: Vec<QueueTrack> = album
|
||||
.tracks
|
||||
@@ -667,6 +710,7 @@ pub fn enqueue_album(runtime: Runtime, _weak: slint::Weak<AppWindow>, handle: to
|
||||
let album_title = album.title.clone();
|
||||
let album_artist = album.artist.name.clone();
|
||||
let album_artwork = album.image.best().cloned().unwrap_or_default();
|
||||
crate::recently::remember_album_meta(&album.id, album_card_meta(&album));
|
||||
let tracks: Vec<QueueTrack> = album
|
||||
.tracks
|
||||
.as_ref()
|
||||
@@ -707,6 +751,7 @@ pub fn enqueue_album_next(
|
||||
let album_title = album.title.clone();
|
||||
let album_artist = album.artist.name.clone();
|
||||
let album_artwork = album.image.best().cloned().unwrap_or_default();
|
||||
crate::recently::remember_album_meta(&album.id, album_card_meta(&album));
|
||||
let tracks: Vec<QueueTrack> = album
|
||||
.tracks
|
||||
.as_ref()
|
||||
|
||||
@@ -10,13 +10,52 @@
|
||||
//! sections that read it hide themselves — the data path exists end to
|
||||
//! end so playback only has to call `record`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How many recent tracks to keep.
|
||||
const MAX_RECENT: usize = 24;
|
||||
|
||||
/// Album-level metadata captured when an album is fetched for playback,
|
||||
/// keyed by album id. The queue track itself (a `qbz_models::QueueTrack`)
|
||||
/// carries no genre / release-date and — for the `album/get` path — no
|
||||
/// per-track quality, so `record_recent` looks the album up here to stamp
|
||||
/// the Recently Played card with genre, release date, and quality badge.
|
||||
/// Matches Tauri's `album_to_card_meta`, which reads these off the `Album`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AlbumMeta {
|
||||
pub genre: String,
|
||||
/// Raw ISO release date (e.g. "2021-05-14"); localized at render time.
|
||||
pub release_date: String,
|
||||
/// "hires" | "cd" | "" — drives the album card quality badge.
|
||||
pub quality_tier: String,
|
||||
/// "Hi-Res: 24-bit / 96 kHz" — quality badge hover tooltip.
|
||||
pub quality_label: String,
|
||||
}
|
||||
|
||||
static ALBUM_META: LazyLock<Mutex<HashMap<String, AlbumMeta>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Cache album-level metadata for `album_id`, so a subsequent play of any
|
||||
/// of its tracks records genre / release date / quality on the recent card.
|
||||
/// Called from the playback album-fetch paths.
|
||||
pub fn remember_album_meta(album_id: &str, meta: AlbumMeta) {
|
||||
if album_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut map) = ALBUM_META.lock() {
|
||||
map.insert(album_id.to_string(), meta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up cached album-level metadata for `album_id` (if any).
|
||||
pub fn album_meta(album_id: &str) -> Option<AlbumMeta> {
|
||||
ALBUM_META.lock().ok().and_then(|map| map.get(album_id).cloned())
|
||||
}
|
||||
|
||||
/// One recently-played track, with the album it belongs to and enough
|
||||
/// context (quality, ids) that re-playing it or rendering its album
|
||||
/// card does not depend on a re-fetch.
|
||||
@@ -40,6 +79,14 @@ pub struct RecentTrack {
|
||||
/// "Hi-Res: 24-bit / 96 kHz" — quality badge hover tooltip.
|
||||
#[serde(default)]
|
||||
pub quality_label: String,
|
||||
/// Album genre, for the Recently Played album card overlay. Empty for
|
||||
/// entries recorded before genre capture (serde default).
|
||||
#[serde(default)]
|
||||
pub genre: String,
|
||||
/// Raw ISO album release date, localized to "MMM D, YYYY" at render.
|
||||
/// Empty for entries recorded before release-date capture.
|
||||
#[serde(default)]
|
||||
pub release_date: String,
|
||||
/// Artist id for navigation / scrobble context.
|
||||
#[serde(default)]
|
||||
pub artist_id: Option<u64>,
|
||||
@@ -54,6 +101,9 @@ pub struct RecentAlbum {
|
||||
pub artwork_url: String,
|
||||
pub quality_tier: String,
|
||||
pub quality_label: String,
|
||||
pub genre: String,
|
||||
/// Raw ISO release date; localized at render time.
|
||||
pub release_date: String,
|
||||
}
|
||||
|
||||
fn store_path() -> Option<PathBuf> {
|
||||
@@ -87,6 +137,8 @@ pub fn load_albums() -> Vec<RecentAlbum> {
|
||||
artwork_url: track.album_artwork_url,
|
||||
quality_tier: track.quality_tier,
|
||||
quality_label: track.quality_label,
|
||||
genre: track.genre,
|
||||
release_date: track.release_date,
|
||||
});
|
||||
}
|
||||
albums
|
||||
|
||||
@@ -335,6 +335,7 @@ fn album_item(row: AlbumRow) -> AlbumCardItem {
|
||||
ribbon_kind: Default::default(),
|
||||
artwork_url: row.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m21 16-4 4-4-4" />
|
||||
<path d="M17 20V4" />
|
||||
<path d="m3 8 4-4 4 4" />
|
||||
<path d="M7 4v16" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 289 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12h.01" />
|
||||
<path d="M3 18h.01" />
|
||||
<path d="M3 6h.01" />
|
||||
<path d="M8 12h13" />
|
||||
<path d="M8 18h13" />
|
||||
<path d="M8 6h13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 329 B |
@@ -49,8 +49,10 @@ component ViewAllLink inherits Rectangle {
|
||||
border-radius: 4px;
|
||||
background: ta.has-hover ? Theme.surface-hover : transparent;
|
||||
link-text := Text {
|
||||
text: @tr("View all");
|
||||
color: Theme.accent;
|
||||
// Muted, close to normal text (not the theme accent), with the
|
||||
// trailing arrow — matches Tauri's "See all →".
|
||||
text: @tr("View all →");
|
||||
color: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
font-weight: Typography.medium;
|
||||
x: (parent.width - self.width) / 2;
|
||||
@@ -100,6 +102,16 @@ export component Carousel inherits VerticalLayout {
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 4px;
|
||||
// "View all" sits to the LEFT of the carousel page chevrons,
|
||||
// matching the Tauri DiscoverySection header.
|
||||
if root.show-view-all: VerticalLayout {
|
||||
alignment: center;
|
||||
ViewAllLink {
|
||||
clicked => {
|
||||
root.view-all-clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
NavButton {
|
||||
@@ -120,14 +132,6 @@ export component Carousel inherits VerticalLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.show-view-all: VerticalLayout {
|
||||
alignment: center;
|
||||
ViewAllLink {
|
||||
clicked => {
|
||||
root.view-all-clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
// Discover "View all" full-list page — one album module (New Releases,
|
||||
// Press Accolades, Ideal Discography, Qobuzissimes, Albums of the Week,
|
||||
// Most Streamed) shown as a paginated full grid or list. Opened from a
|
||||
// Carousel's "View all" link. Mirrors Tauri's DiscoverBrowseView: the
|
||||
// back/forward NavButtons + title sit on the LEFT, the header tools
|
||||
// (search, genre filter, grid/list toggle) float RIGHT on the same row.
|
||||
// Pagination is infinite-scroll, driven by DiscoverBrowseState.has-more
|
||||
// off the backend (no total); load-more is disabled while a search is
|
||||
// active (Tauri does the same).
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { DiscoverBrowseState, DiscoverBrowseActions, GenreFilterState, AlbumCardItem } from "../state.slint";
|
||||
import { AlbumCard } from "AlbumCard.slint";
|
||||
import { AlbumListRow, AlbumListHeader } from "../primitives/AlbumListRow.slint";
|
||||
import { GenreFilterPopup } from "GenreFilterPopup.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
|
||||
// Search field — a leading magnifier, the input, and a trailing X that
|
||||
// clears (mirrors Tauri's .search-wrapper).
|
||||
component BrowseSearch inherits Rectangle {
|
||||
height: 34px;
|
||||
width: 200px;
|
||||
border-radius: 6px;
|
||||
background: Theme.surface-elevated;
|
||||
border-width: 1px;
|
||||
border-color: input.has-focus ? Theme.accent : Theme.border-subtle;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 10px;
|
||||
padding-right: 6px;
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/search.svg");
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
clip: true;
|
||||
input := TextInput {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: Theme.text-primary;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
single-line: true;
|
||||
text <=> DiscoverBrowseState.search-query;
|
||||
edited => {
|
||||
DiscoverBrowseActions.search-changed();
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: input.text == "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text: @tr("Search…");
|
||||
color: Theme.text-muted;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
// Trailing X — clears the query.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
visible: DiscoverBrowseState.search-query != "";
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
background: clear-ta.has-hover ? Theme.surface-hover : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/x.svg");
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
clear-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
DiscoverBrowseState.search-query = "";
|
||||
DiscoverBrowseActions.search-changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Genre-filter trigger — same visual as HomeView's GenreButton: accent
|
||||
// fill + "N genres" when a selection is active.
|
||||
component GenreButton inherits Rectangle {
|
||||
callback clicked();
|
||||
property <bool> active: GenreFilterState.selected-count > 0;
|
||||
|
||||
width: genre-row.preferred-width;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
background: root.active
|
||||
? Theme.accent
|
||||
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
|
||||
|
||||
genre-row := HorizontalLayout {
|
||||
padding-left: 12px;
|
||||
padding-right: 14px;
|
||||
spacing: 7px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/list-filter.svg");
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
tint: root.active ? #ffffff : Theme.text-secondary;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: root.active
|
||||
? (GenreFilterState.selected-count == 1
|
||||
? @tr("1 genre")
|
||||
: @tr("{} genres", GenreFilterState.selected-count))
|
||||
: @tr("Filter by genre");
|
||||
color: root.active ? #ffffff : Theme.text-secondary;
|
||||
font-size: 13px;
|
||||
font-weight: root.active ? Typography.medium : Typography.regular;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One half of the grid/list segmented toggle.
|
||||
component ToggleButton inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <bool> active: false;
|
||||
callback clicked();
|
||||
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: root.active
|
||||
? Theme.surface-hover
|
||||
: (ta.has-hover ? Theme.surface-elevated : transparent);
|
||||
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: root.active ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component DiscoverBrowseView inherits Rectangle {
|
||||
callback open-album(string);
|
||||
callback open-artist(string);
|
||||
callback media-action(string, string, string);
|
||||
|
||||
property <length> album-w: 220px;
|
||||
property <length> album-h: 266px;
|
||||
property <length> gap: 24px;
|
||||
property <length> list-row-h: 64px;
|
||||
property <length> list-row-gap: 4px;
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
// --- Fixed header (always visible) -------------------------------
|
||||
// NavButtons + section title on the LEFT (title inline, right after
|
||||
// the nav buttons); search / genre / grid-list tools as one group
|
||||
// floating RIGHT. Like the Discover toolbar: a fixed bar whose
|
||||
// back/forward buttons keep the same screen position as every page
|
||||
// (center at y=25 = 11px top + 14px half-button). Only the list
|
||||
// below scrolls.
|
||||
Rectangle {
|
||||
height: 56px;
|
||||
background: Theme.surface-main;
|
||||
|
||||
// Left cluster — nav buttons then the title, inline.
|
||||
HorizontalLayout {
|
||||
x: 32px;
|
||||
y: 25px - self.height / 2;
|
||||
width: self.preferred-width;
|
||||
height: self.preferred-height;
|
||||
spacing: 16px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
NavButtons { }
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: DiscoverBrowseState.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.section;
|
||||
font-weight: Typography.bold;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right cluster — the header tools as one group, anchored right.
|
||||
HorizontalLayout {
|
||||
x: parent.width - self.preferred-width - 32px;
|
||||
y: 25px - self.height / 2;
|
||||
width: self.preferred-width;
|
||||
height: self.preferred-height;
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
BrowseSearch { }
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
GenreButton {
|
||||
clicked => {
|
||||
GenreFilterState.open = !GenreFilterState.open;
|
||||
}
|
||||
}
|
||||
}
|
||||
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: DiscoverBrowseState.view-mode == "grid";
|
||||
clicked => {
|
||||
DiscoverBrowseState.view-mode = "grid";
|
||||
}
|
||||
}
|
||||
ToggleButton {
|
||||
icon: @image-url("../assets/icons/list.svg");
|
||||
active: DiscoverBrowseState.view-mode == "list";
|
||||
clicked => {
|
||||
DiscoverBrowseState.view-mode = "list";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scrolling list (only the listing scrolls) ------------------
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-width: self.width;
|
||||
viewport-height: page.preferred-height;
|
||||
|
||||
// Infinite scroll — when the viewport nears the bottom of the
|
||||
// content (within 600px), pull the next page. Guarded by
|
||||
// has-more + the loading flags AND an empty search query, so a
|
||||
// client-side search filter does not keep paging in the catalog.
|
||||
changed viewport-y => {
|
||||
if (-self.viewport-y + self.height >= self.viewport-height - 600px
|
||||
&& DiscoverBrowseState.has-more
|
||||
&& !DiscoverBrowseState.loading-more
|
||||
&& !DiscoverBrowseState.loading
|
||||
&& DiscoverBrowseState.search-query == "") {
|
||||
DiscoverBrowseActions.load-more();
|
||||
}
|
||||
}
|
||||
|
||||
page := VerticalLayout {
|
||||
width: parent.width;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 100px;
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
if DiscoverBrowseState.loading: HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
|
||||
// Empty-state when a search filters everything out.
|
||||
if !DiscoverBrowseState.loading
|
||||
&& DiscoverBrowseState.visible.length == 0
|
||||
&& DiscoverBrowseState.search-query != "": HorizontalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("No results found.");
|
||||
color: Theme.text-muted;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Album grid ----------------------------------------
|
||||
if !DiscoverBrowseState.loading
|
||||
&& DiscoverBrowseState.view-mode == "grid": grid := Rectangle {
|
||||
property <int> columns: Math.max(
|
||||
1,
|
||||
Math.floor((self.width + root.gap) / (root.album-w + root.gap)));
|
||||
property <int> rows: Math.ceil(DiscoverBrowseState.visible.length / self.columns);
|
||||
height: self.rows > 0
|
||||
? self.rows * root.album-h + Math.max(0, self.rows - 1) * root.gap
|
||||
: 0px;
|
||||
for album[i] in DiscoverBrowseState.visible: AlbumCard {
|
||||
x: Math.mod(i, grid.columns) * (root.album-w + root.gap);
|
||||
y: Math.floor(i / grid.columns) * (root.album-h + root.gap);
|
||||
width: root.album-w;
|
||||
height: root.album-h;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Album list ----------------------------------------
|
||||
// Reusable AlbumListRow; SOURCE column hidden (single-source
|
||||
// discover catalog). Header labels the columns.
|
||||
if !DiscoverBrowseState.loading
|
||||
&& DiscoverBrowseState.view-mode == "list": VerticalLayout {
|
||||
spacing: root.list-row-gap;
|
||||
AlbumListHeader {
|
||||
show-source: false;
|
||||
}
|
||||
for album[i] in DiscoverBrowseState.visible: AlbumListRow {
|
||||
album: album;
|
||||
index: i;
|
||||
show-source: false;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom load-more spinner while the next page is in flight.
|
||||
if DiscoverBrowseState.loading-more: HorizontalLayout {
|
||||
alignment: center;
|
||||
Rectangle { height: 24px; }
|
||||
LoadingSpinner { size: 28px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Genre filter overlay --------------------------------------------
|
||||
// Backdrop closes the popup on outside click; the panel anchors to
|
||||
// the top-right under the Filter-by-genre button (mirrors HomeView).
|
||||
if GenreFilterState.open: Rectangle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
TouchArea {
|
||||
clicked => {
|
||||
GenreFilterState.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if GenreFilterState.open: GenreFilterPopup {
|
||||
x: root.width - self.width - 32px;
|
||||
y: 56px;
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,10 @@ export component HomeView inherits Rectangle {
|
||||
callback album-clicked(string);
|
||||
callback open-artist(string);
|
||||
callback media-action(string, string, string);
|
||||
// Open the Discover "View all" full-list page for a section
|
||||
// (endpoint, title). Only sections with a non-empty endpoint show
|
||||
// the link.
|
||||
callback discover-view-all(string, string);
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
@@ -254,12 +258,16 @@ export component HomeView inherits Rectangle {
|
||||
|
||||
for section in HomeState.sections: Carousel {
|
||||
section: section;
|
||||
show-view-all: section.endpoint != "";
|
||||
album-clicked(id) => {
|
||||
root.album-clicked(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
view-all-clicked => {
|
||||
root.discover-view-all(section.endpoint, section.title);
|
||||
}
|
||||
}
|
||||
|
||||
// For You renders its own ordered, heterogeneous
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
// Reusable album list-row — the "list view" counterpart to the grid
|
||||
// AlbumCard. Modeled on Tauri's My QBZ > Collections list (and the
|
||||
// DiscographyBuilderView column grid): a thumbnail + title/artist ITEM
|
||||
// column, then TYPE, SOURCE, QUALITY, TRACKS and YEAR columns plus a
|
||||
// trailing overflow ("…") button.
|
||||
//
|
||||
// Intentionally column-for-column with the Tauri reference, not a
|
||||
// reinterpretation. Reused by Discover "View all", Favorites,
|
||||
// LocalLibrary and My QBZ — every column except ITEM is hideable via a
|
||||
// bool prop so a context can show only the columns it cares about. In
|
||||
// the DiscoverBrowse context the SOURCE column is hidden (single-source
|
||||
// catalog).
|
||||
//
|
||||
// QUALITY reuses the shared QualityBadgeFull — the SAME badge AlbumView
|
||||
// shows below the album art — so quality reads identically everywhere.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { AlbumCardItem } from "../state.slint";
|
||||
import { QbzIcon } from "QbzIcon.slint";
|
||||
import { QualityBadgeFull } from "QualityBadgeFull.slint";
|
||||
import { ContextMenu } from "ContextMenu.slint";
|
||||
import { ContextMenuItem } from "ContextMenuItem.slint";
|
||||
|
||||
// Fixed column widths shared by the header and the rows so they align.
|
||||
// (rank · art · type · source · quality · tracks · year · overflow;
|
||||
// the ITEM title/artist stack takes the remaining stretch space.)
|
||||
export global AlbumListCols {
|
||||
out property <length> rank: 36px;
|
||||
out property <length> art: 52px;
|
||||
out property <length> type: 84px;
|
||||
out property <length> source: 72px;
|
||||
out property <length> quality: 150px;
|
||||
out property <length> tracks: 64px;
|
||||
out property <length> year: 64px;
|
||||
out property <length> overflow: 36px;
|
||||
out property <length> gap: 12px;
|
||||
}
|
||||
|
||||
// Column header row — labels the columns. Mirrors the
|
||||
// DiscographyBuilderView / MixtapeCollectionDetailView header convention.
|
||||
export component AlbumListHeader inherits Rectangle {
|
||||
in property <bool> show-rank: false;
|
||||
in property <bool> show-source: true;
|
||||
|
||||
height: 32px;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: AlbumListCols.gap;
|
||||
|
||||
if root.show-rank: cell-rank := Text {
|
||||
width: AlbumListCols.rank;
|
||||
text: "#";
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
// Art column placeholder (no label).
|
||||
Rectangle { width: AlbumListCols.art; }
|
||||
// ITEM stretch column.
|
||||
Text {
|
||||
horizontal-stretch: 1;
|
||||
text: @tr("ITEM");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
width: AlbumListCols.type;
|
||||
text: @tr("TYPE");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
if root.show-source: Text {
|
||||
width: AlbumListCols.source;
|
||||
text: @tr("SOURCE");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
width: AlbumListCols.quality;
|
||||
text: @tr("QUALITY");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
width: AlbumListCols.tracks;
|
||||
text: @tr("TRACKS");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.5px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
width: AlbumListCols.year;
|
||||
text: @tr("YEAR");
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 0.5px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
// Overflow column placeholder (no label).
|
||||
Rectangle { width: AlbumListCols.overflow; }
|
||||
}
|
||||
|
||||
// Bottom hairline separator.
|
||||
Rectangle {
|
||||
y: parent.height - self.height;
|
||||
height: 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
export component AlbumListRow inherits Rectangle {
|
||||
in property <AlbumCardItem> album;
|
||||
// Row index — drives the even/odd zebra striping (coherent with TrackRow).
|
||||
in property <int> index;
|
||||
// Optional leading rank ("#1" etc) for ranked lists (Most Streamed).
|
||||
in property <bool> show-rank: false;
|
||||
in property <string> rank;
|
||||
// The SOURCE column is hidden in single-source contexts (Discover).
|
||||
in property <bool> show-source: true;
|
||||
|
||||
callback clicked(string);
|
||||
// kind ("album" | "artist"), id, action ("play" | "queue" |
|
||||
// "play-next" | "favorite" | "open").
|
||||
callback media-action(string, string, string);
|
||||
|
||||
// Hover spans the row body + the art / artist / overflow affordances so
|
||||
// the row highlights as one (mirrors TrackRow's row-hovered).
|
||||
property <bool> row-hovered:
|
||||
ta.has-hover || art-ta.has-hover || artist-ta.has-hover || more-ta.has-hover;
|
||||
|
||||
height: 64px;
|
||||
border-radius: Radius.sm;
|
||||
// Even/odd zebra (odd rows faintly tinted), coherent with TrackRow.
|
||||
background: root.row-hovered
|
||||
? Theme.surface-hover
|
||||
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked(root.album.id);
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: AlbumListCols.gap;
|
||||
|
||||
// --- # ---------------------------------------------------------
|
||||
if root.show-rank: Text {
|
||||
width: AlbumListCols.rank;
|
||||
text: root.rank;
|
||||
color: Theme.text-muted;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// --- Artwork thumb (play on hover) ----------------------------
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: AlbumListCols.art;
|
||||
height: AlbumListCols.art;
|
||||
border-radius: 6px;
|
||||
background: Theme.surface-elevated;
|
||||
clip: true;
|
||||
Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: root.album.artwork;
|
||||
image-fit: cover;
|
||||
}
|
||||
// Hover scrim + centered play glyph over the cover.
|
||||
Rectangle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000000;
|
||||
opacity: root.row-hovered ? 0.45 : 0.0;
|
||||
animate opacity { duration: 120ms; }
|
||||
}
|
||||
if root.row-hovered: QbzIcon {
|
||||
source: @image-url("../assets/icons/play-fill.svg");
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: #ffffff;
|
||||
}
|
||||
art-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.media-action("album", root.album.id, "play");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ITEM: title + artist -------------------------------------
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
alignment: center;
|
||||
spacing: 2px;
|
||||
Text {
|
||||
text: root.album.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
overflow: elide;
|
||||
}
|
||||
// Clickable artist name → artist page when an id is known.
|
||||
artist-ta := TouchArea {
|
||||
height: 18px;
|
||||
mouse-cursor: root.album.artist-id != "" ? pointer : default;
|
||||
clicked => {
|
||||
if (root.album.artist-id != "") {
|
||||
root.media-action("artist", root.album.artist-id, "open");
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: 100%;
|
||||
text: root.album.artist;
|
||||
color: root.album.artist-id != "" && artist-ta.has-hover
|
||||
? Theme.text-primary
|
||||
: Theme.text-muted;
|
||||
font-size: Typography.link;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TYPE ------------------------------------------------------
|
||||
Text {
|
||||
width: AlbumListCols.type;
|
||||
text: root.album.release-type;
|
||||
color: Theme.text-secondary;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// --- SOURCE (hideable) ----------------------------------------
|
||||
if root.show-source: Text {
|
||||
width: AlbumListCols.source;
|
||||
text: root.album.source == "qobuz"
|
||||
? @tr("Qobuz")
|
||||
: (root.album.source == "plex"
|
||||
? @tr("Plex")
|
||||
: (root.album.source == "local" ? @tr("Local") : ""));
|
||||
color: Theme.text-secondary;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// --- QUALITY (shared QualityBadgeFull) ------------------------
|
||||
Rectangle {
|
||||
width: AlbumListCols.quality;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QualityBadgeFull {
|
||||
tier: root.album.quality-tier;
|
||||
detail: root.album.quality-detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TRACKS ----------------------------------------------------
|
||||
Text {
|
||||
width: AlbumListCols.tracks;
|
||||
text: root.album.track-count;
|
||||
color: Theme.text-secondary;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// --- YEAR ------------------------------------------------------
|
||||
Text {
|
||||
width: AlbumListCols.year;
|
||||
text: root.album.plain-year;
|
||||
color: Theme.text-secondary;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// --- Overflow "…" ---------------------------------------------
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: AlbumListCols.overflow;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: more-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/ellipsis.svg");
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: more-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
more-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
row-menu.show();
|
||||
}
|
||||
}
|
||||
row-menu := PopupWindow {
|
||||
x: -170px;
|
||||
y: 34px;
|
||||
width: 196px;
|
||||
height: 142px;
|
||||
ContextMenu {
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/library-big.svg");
|
||||
label: @tr("Open album");
|
||||
clicked => {
|
||||
root.clicked(root.album.id);
|
||||
}
|
||||
}
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/play.svg");
|
||||
label: @tr("Play");
|
||||
clicked => {
|
||||
root.media-action("album", root.album.id, "play");
|
||||
}
|
||||
}
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/list-plus.svg");
|
||||
label: @tr("Play next");
|
||||
clicked => {
|
||||
root.media-action("album", root.album.id, "play-next");
|
||||
}
|
||||
}
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/list-end.svg");
|
||||
label: @tr("Add to queue");
|
||||
clicked => {
|
||||
root.media-action("album", root.album.id, "queue");
|
||||
}
|
||||
}
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/heart.svg");
|
||||
label: @tr("Add to favorites");
|
||||
clicked => {
|
||||
root.media-action("album", root.album.id, "favorite");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user