diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index c7a57d4ab..a8b689854 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -37,6 +37,8 @@ pub enum ArtworkTarget { Popular { idx: usize }, /// A card in `HomeState.recent[idx]`. Recent { idx: usize }, + /// A card in `HomeState.recent-albums[idx]`. + RecentAlbum { idx: usize }, } /// An artwork download job: which card, and the image URL. @@ -128,26 +130,49 @@ pub async fn fetch_and_decode( Some((rgba.into_raw(), width, height)) } -/// Average RGB of decoded RGBA pixels, darkened so the result works as a -/// header gradient tint. Mirrors, cheaply, the Tauri header that derives -/// its color from the artwork. Returns a dark fallback for empty input. +/// Representative color of decoded RGBA pixels for the header gradient. +/// +/// A plain average desaturates badly (everything trends grey), so the +/// average is saturation-boosted off its own mean and then normalized to +/// a fixed peak brightness — the result keeps the cover's hue and reads +/// as a clear tinted band against the dark surface. Dark fallback for +/// empty input. pub fn header_tint(pixels: &[u8]) -> (u8, u8, u8) { - let (mut r, mut g, mut b, mut n) = (0u64, 0u64, 0u64, 0u64); + let (mut r, mut g, mut b, mut n) = (0f64, 0f64, 0f64, 0u64); for px in pixels.chunks_exact(4) { if px[3] < 16 { continue; } - r += px[0] as u64; - g += px[1] as u64; - b += px[2] as u64; + r += px[0] as f64; + g += px[1] as f64; + b += px[2] as f64; n += 1; } if n == 0 { - return (24, 24, 30); + return (34, 34, 42); } - // 0.5 keeps the tint dark enough for white text to stay readable. - let darken = |sum: u64| ((sum / n) as f64 * 0.5) as u8; - (darken(r), darken(g), darken(b)) + let nf = n as f64; + let (mut r, mut g, mut b) = (r / nf, g / nf, b / nf); + + // Saturation boost: push each channel away from the average's mean. + let mean = (r + g + b) / 3.0; + let boost = 2.1; + let saturate = |c: f64| (mean + (c - mean) * boost).clamp(0.0, 255.0); + r = saturate(r); + g = saturate(g); + b = saturate(b); + + // Normalize the brightest channel to a fixed peak so the tint is + // always clearly visible — bright enough to perceive, dark enough to + // keep white text readable. Caps the scale so a near-black cover is + // only modestly lifted. + let peak = r.max(g).max(b).max(1.0); + let scale = (138.0 / peak).min(1.7); + ( + (r * scale) as u8, + (g * scale) as u8, + (b * scale) as u8, + ) } /// Apply decoded pixels to a single card. Runs on the Slint event loop. @@ -198,5 +223,13 @@ fn apply_artwork( item.artwork = image; recent.set_row_data(idx, item); } + ArtworkTarget::RecentAlbum { idx } => { + let albums = home.get_recent_albums(); + let Some(mut item) = albums.row_data(idx) else { + return; + }; + item.artwork = image; + albums.set_row_data(idx, item); + } } } diff --git a/crates/qbz-slint/src/home.rs b/crates/qbz-slint/src/home.rs index d23b78720..dfc1f53ec 100644 --- a/crates/qbz-slint/src/home.rs +++ b/crates/qbz-slint/src/home.rs @@ -19,6 +19,7 @@ pub struct HomeData { pub sections: Vec, pub popular: Vec, pub recent: Vec, + pub recent_albums: Vec, } pub struct SectionData { @@ -98,11 +99,27 @@ where artwork_url: track.artwork_url, }) .collect(); + let recent_albums = crate::recently::load_albums() + .into_iter() + .map(|album| CardData { + id: album.id, + title: album.title, + artist: album.artist, + genre: String::new(), + year: String::new(), + quality_tier: String::new(), + quality_label: String::new(), + ribbon: String::new(), + ribbon_kind: String::new(), + artwork_url: album.artwork_url, + }) + .collect(); Ok(HomeData { sections, popular, recent, + recent_albums, }) } @@ -241,34 +258,34 @@ fn format_rate(rate: f64) -> String { } } +/// Convert one `CardData` into the Slint `AlbumCardItem`. +fn card_to_item(card: CardData) -> AlbumCardItem { + AlbumCardItem { + id: card.id.into(), + title: card.title.into(), + artist: card.artist.into(), + genre: card.genre.into(), + year: card.year.into(), + quality_tier: card.quality_tier.into(), + quality_label: card.quality_label.into(), + ribbon: card.ribbon.into(), + ribbon_kind: card.ribbon_kind.into(), + artwork_url: card.artwork_url.into(), + artwork: slint::Image::default(), + } +} + /// Convert worker-thread home data into Slint models and push them onto /// the `HomeState` global. Must run on the Slint event loop. pub fn apply_home(window: &AppWindow, data: HomeData) { let sections: Vec = data .sections .into_iter() - .map(|section| { - let albums: Vec = section - .albums - .into_iter() - .map(|card| AlbumCardItem { - id: card.id.into(), - title: card.title.into(), - artist: card.artist.into(), - genre: card.genre.into(), - year: card.year.into(), - quality_tier: card.quality_tier.into(), - quality_label: card.quality_label.into(), - ribbon: card.ribbon.into(), - ribbon_kind: card.ribbon_kind.into(), - artwork_url: card.artwork_url.into(), - artwork: slint::Image::default(), - }) - .collect(); - DiscoverSection { - title: section.title.into(), - albums: ModelRc::new(VecModel::from(albums)), - } + .map(|section| DiscoverSection { + title: section.title.into(), + albums: ModelRc::new(VecModel::from( + section.albums.into_iter().map(card_to_item).collect::>(), + )), }) .collect(); @@ -287,11 +304,14 @@ pub fn apply_home(window: &AppWindow, data: HomeData) { }; let popular = to_slim_items(data.popular); let recent = to_slim_items(data.recent); + let recent_albums: Vec = + data.recent_albums.into_iter().map(card_to_item).collect(); let state = window.global::(); state.set_sections(ModelRc::new(VecModel::from(sections))); state.set_popular(ModelRc::new(VecModel::from(popular))); state.set_recent(ModelRc::new(VecModel::from(recent))); + state.set_recent_albums(ModelRc::new(VecModel::from(recent_albums))); } #[cfg(test)] diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 235b130eb..183ce297b 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -119,6 +119,21 @@ async fn enter_shell( }) } })); + jobs.extend( + data.recent_albums + .iter() + .enumerate() + .filter_map(|(idx, card)| { + if card.artwork_url.is_empty() { + None + } else { + Some(artwork::ArtworkJob { + target: artwork::ArtworkTarget::RecentAlbum { idx }, + url: card.artwork_url.clone(), + }) + } + }), + ); let weak_for_artwork = weak.clone(); let _ = weak.upgrade_in_event_loop(move |w| { home::apply_home(&w, data); diff --git a/crates/qbz-slint/src/recently.rs b/crates/qbz-slint/src/recently.rs index c43cab174..280a53863 100644 --- a/crates/qbz-slint/src/recently.rs +++ b/crates/qbz-slint/src/recently.rs @@ -1,27 +1,46 @@ -//! Recently-played tracks store. +//! Recently-played store. //! //! A small JSON file at the shared QBZ data path holding the last few -//! played tracks, newest first. The Discover Home renders it as a slim -//! section; the playback session calls [`record`] when a track starts. +//! played tracks, newest first. Discover Home renders two sections from +//! it — recently-played tracks (slim cards) and recently-played albums +//! (derived by de-duplicating the track history by album). The playback +//! session calls [`record`] when a track starts. //! -//! Until playback is wired the store is simply empty, and the Home -//! section that reads it hides itself — the data path exists end to end -//! so playback only has to call `record`. +//! Until playback is wired the store is simply empty and the Home +//! sections that read it hide themselves — the data path exists end to +//! end so playback only has to call `record`. use std::path::PathBuf; use serde::{Deserialize, Serialize}; /// How many recent tracks to keep. -const MAX_RECENT: usize = 12; +const MAX_RECENT: usize = 24; -/// One recently-played track. +/// One recently-played track, with the album it belongs to. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RecentTrack { pub id: String, pub title: String, pub subtitle: String, pub artwork_url: String, + #[serde(default)] + pub album_id: String, + #[serde(default)] + pub album_title: String, + #[serde(default)] + pub album_artist: String, + #[serde(default)] + pub album_artwork_url: String, +} + +/// One recently-played album, derived from the track history. +#[derive(Clone, Debug)] +pub struct RecentAlbum { + pub id: String, + pub title: String, + pub artist: String, + pub artwork_url: String, } fn store_path() -> Option { @@ -40,6 +59,24 @@ pub fn load() -> Vec { } } +/// Recently-played albums, newest first, de-duplicated from the track +/// history. A track with no album id is skipped. +pub fn load_albums() -> Vec { + let mut albums: Vec = Vec::new(); + for track in load() { + if track.album_id.is_empty() || albums.iter().any(|a| a.id == track.album_id) { + continue; + } + albums.push(RecentAlbum { + id: track.album_id, + title: track.album_title, + artist: track.album_artist, + artwork_url: track.album_artwork_url, + }); + } + albums +} + /// Record a played track at the front of the list. Deduplicates by id and /// caps the list at [`MAX_RECENT`]. Called by the playback session when a /// track starts. diff --git a/crates/qbz-slint/ui/discover/HomeView.slint b/crates/qbz-slint/ui/discover/HomeView.slint index c653e6ef0..6550ba9f5 100644 --- a/crates/qbz-slint/ui/discover/HomeView.slint +++ b/crates/qbz-slint/ui/discover/HomeView.slint @@ -188,8 +188,21 @@ export component HomeView inherits Rectangle { } } + if HomeState.recent-albums.length > 0: Carousel { + section: { + title: @tr("Recently Played Albums"), + albums: HomeState.recent-albums, + }; + album-clicked(id) => { + root.album-clicked(id); + } + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + if HomeState.recent.length > 0: GridSection { - title: @tr("Recently Played"); + title: @tr("Recently Played Tracks"); items: HomeState.recent; item-clicked(id) => { root.media-action("track", id, "play"); diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 13309937f..e8ab8546a 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -38,6 +38,7 @@ export global HomeState { in property <[DiscoverSection]> sections: []; in property <[SlimItem]> popular: []; in property <[SlimItem]> recent: []; + in property <[AlbumCardItem]> recent-albums: []; in property loading: false; } @@ -59,6 +60,10 @@ export global AlbumState { in property artist-id; in property info-line; in property quality-tier; + // "24-bit / 96 kHz" — quality-badge detail line. + in property quality-detail; + // Editorial description / review (HTML stripped). May be empty. + in property description; in property artwork; // Artwork-derived tint for the header gradient. in property header-color: #181820;