feat(qbz-qt): theme-following icons, the real header atmosphere, sharp covers, playback context
**Icons follow the theme.** The bakes are frozen dark-palette hexes, so on light themes secondary, muted and accent were simply wrong, and accent was wrong on any theme whose accent is not #4285f4. Slint recolours at render time, which this path cannot do — so the tint is generated once per colour into the cache and loaded through the same Image+svg path the bakes already use. No effect, no shader, so it cannot hit the "renders nothing on software" failure. The sweep found the part the diagnosis had missed: "primary" was two meanings welded together — max-contrast-on-theme-surface, and literal white for glyphs on hosts that are dark under every theme. Remapping it globally would have painted dark glyphs on dark scrims in fourteen places. The vocabulary is split instead, with "primary" left as an alias so nothing changes until it is migrated deliberately. **The album header gradient was Slint's fallback, not its design.** Slint paints a blurred artwork field and only falls back to the flat two-band gradient when that image is unavailable; the port shipped the fallback. The field needs no shader — it is a 128x128 image, so the pipeline ports verbatim: the cover down to 8x8, back up to 128, blur, colour adjust, vignette. The 8x8 step is the look, not an optimization; everything after it works from eight pixels of colour, which is why it reads as atmosphere rather than as a blurred cover. It comes out of the same decode that already produced the flat tint. **Covers were point-sampled.** Canvas drawImage does not filter when it scales, measured at RMSE 0.000 against a nearest-neighbour reference — a 600px cover in a 200px cell throws away eight of every nine pixels, and the error is aliasing, not softness. Rust now produces a derivative at the drawn device size so the draw is 1:1 and there is no resample at all. **The now-playing context stack.** It had no Qt counterpart: the model carried context_kind and context_id, some builders stamped them, and nothing ever read them back. The album path only appeared to work by coincidence, because the glyph was hardcoded to open the album. Plus: Discover's missing rails with real play history, Recommendations on the integrations, queue drag reorder, and the playlist detail collage. cc
This commit is contained in:
31 files changed
+3548
-329
No files matched your search
@@ -0,0 +1,307 @@
|
||||
//! Recently-played store — Slint-free port of `crates/qbz/src/recently.rs`.
|
||||
//!
|
||||
//! Backs the two Discover rails the port was missing: "Recently Played
|
||||
//! Albums" (album carousel) and "Recently Played Tracks" (slim carousel), on
|
||||
//! Home AND For You.
|
||||
//!
|
||||
//! It is the SAME file the Slint and Tauri builds use —
|
||||
//! `<data_dir>/qbz/recently_played.json`, an object
|
||||
//! `{ "tracks": [...], "albums": [...] }` with the pre-#567 bare-array form
|
||||
//! migrated on read by deriving the album list from the track window. Nothing
|
||||
//! here is per-user: the path is app-wide, exactly as in the reference, so a
|
||||
//! history recorded by the Slint build shows up here and vice versa.
|
||||
//!
|
||||
//! Read is free (one small JSON file, no network, no session). [`record`] is
|
||||
//! the write side; its call site is the playback track-start edge, which lives
|
||||
//! in `playback_qt.rs` — see the GLUE note on the function.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How many recent tracks to keep (recently.rs `MAX_RECENT`).
|
||||
const MAX_RECENT: usize = 24;
|
||||
/// Independent album cap (#567) so a run of long albums cannot shrink the
|
||||
/// distinct-album history.
|
||||
const MAX_RECENT_ALBUMS: usize = 24;
|
||||
|
||||
/// One recently-played track. Every field defaults so a store written by an
|
||||
/// older build (or a newer one with more fields) stays readable.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct RecentTrack {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub subtitle: String,
|
||||
#[serde(default)]
|
||||
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,
|
||||
#[serde(default)]
|
||||
pub quality_tier: String,
|
||||
#[serde(default)]
|
||||
pub quality_label: String,
|
||||
#[serde(default)]
|
||||
pub genre: String,
|
||||
/// Raw ISO album release date; localized at render time.
|
||||
#[serde(default)]
|
||||
pub release_date: String,
|
||||
#[serde(default)]
|
||||
pub artist_id: Option<u64>,
|
||||
/// "qobuz" | "plex" | "local"; empty = legacy entry, treated as Qobuz.
|
||||
#[serde(default)]
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// One recently-played album (its own history since #567).
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct RecentAlbum {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub artist: String,
|
||||
#[serde(default)]
|
||||
pub artwork_url: String,
|
||||
#[serde(default)]
|
||||
pub quality_tier: String,
|
||||
#[serde(default)]
|
||||
pub quality_label: String,
|
||||
#[serde(default)]
|
||||
pub genre: String,
|
||||
#[serde(default)]
|
||||
pub release_date: String,
|
||||
#[serde(default)]
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct RecentStore {
|
||||
#[serde(default)]
|
||||
tracks: Vec<RecentTrack>,
|
||||
#[serde(default)]
|
||||
albums: Vec<RecentAlbum>,
|
||||
}
|
||||
|
||||
fn store_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("recently_played.json"))
|
||||
}
|
||||
|
||||
/// Pre-#567 derive: first-occurrence de-dup over the track window. Kept ONLY
|
||||
/// for the legacy migration path in [`read_store`].
|
||||
fn derive_albums(tracks: &[RecentTrack]) -> Vec<RecentAlbum> {
|
||||
let mut albums: Vec<RecentAlbum> = Vec::new();
|
||||
for track in tracks {
|
||||
if track.album_id.is_empty() || albums.iter().any(|a| a.id == track.album_id) {
|
||||
continue;
|
||||
}
|
||||
albums.push(RecentAlbum {
|
||||
id: track.album_id.clone(),
|
||||
title: track.album_title.clone(),
|
||||
artist: track.album_artist.clone(),
|
||||
artwork_url: track.album_artwork_url.clone(),
|
||||
quality_tier: track.quality_tier.clone(),
|
||||
quality_label: track.quality_label.clone(),
|
||||
genre: track.genre.clone(),
|
||||
release_date: track.release_date.clone(),
|
||||
source: track.source.clone(),
|
||||
});
|
||||
}
|
||||
albums
|
||||
}
|
||||
|
||||
/// Read the whole store. Missing / unreadable -> empty (the rails then render
|
||||
/// their placeholders, never an error).
|
||||
fn read_store() -> RecentStore {
|
||||
let Some(path) = store_path() else {
|
||||
return RecentStore::default();
|
||||
};
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return RecentStore::default();
|
||||
};
|
||||
if let Ok(store) = serde_json::from_slice::<RecentStore>(&bytes) {
|
||||
return store;
|
||||
}
|
||||
let tracks: Vec<RecentTrack> = serde_json::from_slice(&bytes).unwrap_or_default();
|
||||
let albums = derive_albums(&tracks);
|
||||
RecentStore { tracks, albums }
|
||||
}
|
||||
|
||||
fn write_store(store: &RecentStore) {
|
||||
let Some(path) = store_path() else {
|
||||
return;
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("[qbz-qt] recently-played store dir failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_vec_pretty(store) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&path, json) {
|
||||
log::warn!("[qbz-qt] recently-played write failed: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("[qbz-qt] recently-played serialize failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recently-played tracks, newest first.
|
||||
pub(crate) fn load_tracks() -> Vec<RecentTrack> {
|
||||
read_store().tracks
|
||||
}
|
||||
|
||||
/// Recently-played albums, newest first.
|
||||
pub(crate) fn load_albums() -> Vec<RecentAlbum> {
|
||||
read_store().albums
|
||||
}
|
||||
|
||||
/// Record a played track at the front of the track history (dedup by track
|
||||
/// id, capped at [`MAX_RECENT`]) and its album at the front of the album
|
||||
/// history (dedup by album id, capped at [`MAX_RECENT_ALBUMS`]).
|
||||
///
|
||||
/// GLUE: this is the write half; nothing in the Qt port calls it yet, so the
|
||||
/// rails currently render whatever the Slint/Tauri builds recorded. The call
|
||||
/// site is the playback track-start edge in `playback_qt.rs` — see the "GLUE
|
||||
/// NEEDED" note in the handoff.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn record(track: RecentTrack) {
|
||||
if track.id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut store = read_store();
|
||||
if !track.album_id.is_empty() {
|
||||
store.albums.retain(|a| a.id != track.album_id);
|
||||
store.albums.insert(
|
||||
0,
|
||||
RecentAlbum {
|
||||
id: track.album_id.clone(),
|
||||
title: track.album_title.clone(),
|
||||
artist: track.album_artist.clone(),
|
||||
artwork_url: track.album_artwork_url.clone(),
|
||||
quality_tier: track.quality_tier.clone(),
|
||||
quality_label: track.quality_label.clone(),
|
||||
genre: track.genre.clone(),
|
||||
release_date: track.release_date.clone(),
|
||||
source: track.source.clone(),
|
||||
},
|
||||
);
|
||||
store.albums.truncate(MAX_RECENT_ALBUMS);
|
||||
}
|
||||
store.tracks.retain(|t| t.id != track.id);
|
||||
store.tracks.insert(0, track);
|
||||
store.tracks.truncate(MAX_RECENT);
|
||||
write_store(&store);
|
||||
}
|
||||
|
||||
/// Record the current queue track into BOTH local play stores: the
|
||||
/// recently-played history (this module) and the album play-count history
|
||||
/// (`qbz_app::settings::album_play_history`, which ranks the "Most Played
|
||||
/// Albums" rail). Keeps the playback glue a single line.
|
||||
///
|
||||
/// The queue model carries no album genre / release date (Slint stamps those
|
||||
/// from a side `ALBUM_META` map filled by the album-fetch path), so a card
|
||||
/// recorded here shows title / artist / artwork / quality and no genre-date
|
||||
/// overlay line — never a wrong one.
|
||||
///
|
||||
/// GLUE: call from the DE-DUPED playback track-change edge, next to
|
||||
/// `integrations_qt::on_track_change_edge` — see the handoff.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn record_queue_track(track: &qbz_models::QueueTrack) {
|
||||
let tier = crate::home_qt::quality_tier_from_depth(track.bit_depth);
|
||||
let detail = crate::home_qt::quality_detail_from_parts(track.bit_depth, track.sample_rate);
|
||||
let quality_label = if tier.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"{}: {detail}",
|
||||
if tier == "hires" { "Hi-Res" } else { "CD" }
|
||||
)
|
||||
};
|
||||
let artwork = track.artwork_url.clone().unwrap_or_default();
|
||||
let album_id = track.album_id.clone().unwrap_or_default();
|
||||
let source = track.source.clone().unwrap_or_default();
|
||||
let artist_id = track.artist_id.map(|id| id.to_string()).unwrap_or_default();
|
||||
|
||||
qbz_app::settings::album_play_history::record_album_play(
|
||||
qbz_app::settings::album_play_history::AlbumPlayMeta {
|
||||
album_id: &album_id,
|
||||
title: &track.album,
|
||||
artist: &track.artist,
|
||||
artist_id: &artist_id,
|
||||
artwork_url: &artwork,
|
||||
quality_tier: tier,
|
||||
quality_label: &quality_label,
|
||||
year: "",
|
||||
source: &source,
|
||||
},
|
||||
);
|
||||
|
||||
record(RecentTrack {
|
||||
id: track.id.to_string(),
|
||||
title: track.title.clone(),
|
||||
subtitle: track.artist.clone(),
|
||||
artwork_url: artwork.clone(),
|
||||
album_id,
|
||||
album_title: track.album.clone(),
|
||||
album_artist: track.artist.clone(),
|
||||
album_artwork_url: artwork,
|
||||
quality_tier: tier.to_string(),
|
||||
quality_label,
|
||||
genre: String::new(),
|
||||
release_date: String::new(),
|
||||
artist_id: track.artist_id,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_bare_array_migrates_to_derived_albums() {
|
||||
let tracks = vec![
|
||||
RecentTrack {
|
||||
id: "1".into(),
|
||||
album_id: "a".into(),
|
||||
album_title: "A".into(),
|
||||
..Default::default()
|
||||
},
|
||||
RecentTrack {
|
||||
id: "2".into(),
|
||||
album_id: "a".into(),
|
||||
..Default::default()
|
||||
},
|
||||
RecentTrack {
|
||||
id: "3".into(),
|
||||
album_id: "b".into(),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let derived = derive_albums(&tracks);
|
||||
assert_eq!(derived.len(), 2);
|
||||
assert_eq!(derived[0].id, "a");
|
||||
assert_eq!(derived[0].title, "A");
|
||||
assert_eq!(derived[1].id, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_without_an_album_are_skipped_by_the_derive() {
|
||||
let tracks = vec![RecentTrack {
|
||||
id: "1".into(),
|
||||
..Default::default()
|
||||
}];
|
||||
assert!(derive_albums(&tracks).is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user