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:
6 files changed
+165
-42
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user