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:
vicrodh committed 2026-05-26 01:07:08 -06:00
1 parent 9f1e036308
commit c5c124fb0c
16 files changed
+1262 -25

No files matched your search

+6
View File
@@ -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)),
})
})
+216
View File
@@ -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()
}
+1
View File
@@ -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)));
+3
View File
@@ -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
View File
@@ -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");
+3
View File
@@ -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()
}
}
+48 -3
View File
@@ -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()
+52
View File
@@ -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
+1
View File
@@ -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

+14 -10
View File
@@ -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");
}
}
}
}
}
}
}
}