feat(slint): recently-played albums section on Discover Home

Discover Home now has both recently-played modules: a carousel of
album cards and the slim track grid (renamed "Recently Played
Tracks").

- recently.rs records track plays with their album metadata;
  load_albums() derives the distinct recent albums from that history
- home.rs threads a recent_albums list into HomeData; HomeView renders
  it as a carousel; the artwork pipeline gains ArtworkTarget::RecentAlbum

Also raises the header-gradient contrast: the artwork color is now
saturation-boosted off its average and normalized to a fixed peak
brightness instead of just darkened, so the tint is actually visible.

cc
This commit is contained in:
vicrodh committed 2026-05-18 19:25:33 -06:00
1 parent 1eeaf07a87
commit 9af170db66
6 files changed
+165 -42

No files matched your search

+44 -11
View File
@@ -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);
}
}
}
+42 -22
View File
@@ -19,6 +19,7 @@ pub struct HomeData {
pub sections: Vec<SectionData>,
pub popular: Vec<SlimData>,
pub recent: Vec<SlimData>,
pub recent_albums: Vec<CardData>,
}
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<DiscoverSection> = data
.sections
.into_iter()
.map(|section| {
let albums: Vec<AlbumCardItem> = 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::<Vec<_>>(),
)),
})
.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<AlbumCardItem> =
data.recent_albums.into_iter().map(card_to_item).collect();
let state = window.global::<HomeState>();
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)]
+15
View File
@@ -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);
+45 -8
View File
@@ -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<PathBuf> {
@@ -40,6 +59,24 @@ pub fn load() -> Vec<RecentTrack> {
}
}
/// Recently-played albums, newest first, de-duplicated from the track
/// history. A track with no album id is skipped.
pub fn load_albums() -> Vec<RecentAlbum> {
let mut albums: Vec<RecentAlbum> = 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.
+14 -1
View File
@@ -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");
+5
View File
@@ -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 <bool> loading: false;
}
@@ -59,6 +60,10 @@ export global AlbumState {
in property <string> artist-id;
in property <string> info-line;
in property <string> quality-tier;
// "24-bit / 96 kHz" — quality-badge detail line.
in property <string> quality-detail;
// Editorial description / review (HTML stripped). May be empty.
in property <string> description;
in property <image> artwork;
// Artwork-derived tint for the header gradient.
in property <color> header-color: #181820;