feat(slint): offline-cache controller + per-row status/unlock plumbing (Slice 2)

The Rust half of the trigger/status UI:

- offline_cache.rs: cache_track / cache_tracks / remove_cached + row_sink — a
  CacheEventSink that reflects download status (queued/downloading%/ready/failed)
  and the unlock padlock onto every visible row, plus terminal toasts. cache_*
  pre-flight the limit, insert the queued row, and spawn the shared
  spawn_track_cache_download with the core client.
- TrackItem gains cache-status / cache-progress / unlocking; set_row_cache_status
  + set_row_unlocking mirror set_row_favorite (walk the 6 track models + search
  hero). All 7 exhaustive initializers updated.
- media-action arms ("track","cache"|"download"|"uncache") dispatch to the
  controller; the play path now passes row_sink so UnlockStart/End fire the
  padlock state during CMAF decrypt.

Compiles clean. Next: the TrackRow.slint affordance (make-offline button + ring
/ check / warn) and the padlock glyph that render these fields.

cc
This commit is contained in:
vicrodh committed 2026-05-27 21:45:26 -06:00
1 parent 1413cec626
commit b65b402ff1
11 files changed
+387 -5

No files matched your search

+3
View File
@@ -257,6 +257,9 @@ pub fn apply_album(window: &AppWindow, data: AlbumData) {
artist_id: track.artist_id.into(), artist_id: track.artist_id.into(),
album_id: album_id.clone(), album_id: album_id.clone(),
removing: false, removing: false,
cache_status: 0,
cache_progress: 0.0,
unlocking: false,
}) })
.collect(); .collect();
+3
View File
@@ -503,6 +503,9 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) {
artist_id: track.artist_id.into(), artist_id: track.artist_id.into(),
album_id: track.album_id.into(), album_id: track.album_id.into(),
removing: false, removing: false,
cache_status: 0,
cache_progress: 0.0,
unlocking: false,
}) })
.collect(); .collect();
let release_sections: Vec<DiscoverSection> = data let release_sections: Vec<DiscoverSection> = data
+3
View File
@@ -419,6 +419,9 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
artist_id: t.artist_id.into(), artist_id: t.artist_id.into(),
album_id: t.album_id.into(), album_id: t.album_id.into(),
removing: false, removing: false,
cache_status: 0,
cache_progress: 0.0,
unlocking: false,
}) })
.collect(); .collect();
// `tracks` is the full set the artwork pipeline targets; // `tracks` is the full set the artwork pipeline targets;
+3
View File
@@ -763,6 +763,9 @@ fn top_track_to_item(t: &TopTrack) -> TrackItem {
artist_id: t.artist_id.clone().into(), artist_id: t.artist_id.clone().into(),
album_id: t.album_id.clone().into(), album_id: t.album_id.clone().into(),
removing: false, removing: false,
cache_status: 0,
cache_progress: 0.0,
unlocking: false,
} }
} }
+82
View File
@@ -44,6 +44,7 @@ mod drag;
mod folders; mod folders;
mod library_db; mod library_db;
mod offline; mod offline;
mod offline_cache;
mod playlist; mod playlist;
mod playlist_manager; mod playlist_manager;
mod playlist_picker; mod playlist_picker;
@@ -284,6 +285,64 @@ fn set_row_favorite(window: &AppWindow, track_id: &str, favorite: bool) {
} }
} }
/// Update the offline cache-status (+ progress) of every visible row matching
/// `track_id`. Mirrors `set_row_favorite`. status: 0 none / 1 queued / 2
/// downloading / 3 ready / 4 failed; `progress` is 0.0..1.0.
fn set_row_cache_status(window: &AppWindow, track_id: &str, status: i32, progress: f32) {
let apply = |model: &slint::ModelRc<TrackItem>| {
if let Some(vm) = model.as_any().downcast_ref::<slint::VecModel<TrackItem>>() {
for i in 0..vm.row_count() {
if let Some(mut item) = vm.row_data(i) {
if item.id == track_id
&& (item.cache_status != status || item.cache_progress != progress)
{
item.cache_status = status;
item.cache_progress = progress;
vm.set_row_data(i, item);
}
}
}
}
};
apply(&window.global::<AlbumState>().get_tracks());
apply(&window.global::<ArtistState>().get_top_tracks());
apply(&window.global::<SearchState>().get_tracks());
apply(&window.global::<PlaylistState>().get_tracks());
apply(&window.global::<MixState>().get_tracks());
apply(&window.global::<FavoritesState>().get_tracks());
let search = window.global::<SearchState>();
let mut hero = search.get_most_popular_track();
if hero.id == track_id {
hero.cache_status = status;
hero.cache_progress = progress;
search.set_most_popular_track(hero);
}
}
/// Toggle the unlocking (padlock) flag of every visible row matching
/// `track_id`. Drives the offline-decrypt animation on the row.
fn set_row_unlocking(window: &AppWindow, track_id: &str, unlocking: bool) {
let apply = |model: &slint::ModelRc<TrackItem>| {
if let Some(vm) = model.as_any().downcast_ref::<slint::VecModel<TrackItem>>() {
for i in 0..vm.row_count() {
if let Some(mut item) = vm.row_data(i) {
if item.id == track_id && item.unlocking != unlocking {
item.unlocking = unlocking;
vm.set_row_data(i, item);
}
}
}
}
};
apply(&window.global::<AlbumState>().get_tracks());
apply(&window.global::<ArtistState>().get_top_tracks());
apply(&window.global::<SearchState>().get_tracks());
apply(&window.global::<PlaylistState>().get_tracks());
apply(&window.global::<MixState>().get_tracks());
apply(&window.global::<FavoritesState>().get_tracks());
}
/// Lazy-load the Discover > For You sections the first time the tab is /// Lazy-load the Discover > For You sections the first time the tab is
/// opened. No-op once loaded (the data persists for the session). /// opened. No-op once loaded (the data persists for the session).
fn ensure_for_you_loaded( fn ensure_for_you_loaded(
@@ -2175,6 +2234,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
} }
// Offline cache: "download"/"cache" make a track available
// offline; "uncache" removes the local copy. The row affordance
// and the context menu both bubble these.
("track", "cache") | ("track", "download") => {
if let Ok(track_id) = id.parse::<u64>() {
offline_cache::cache_track(
runtime.clone(),
weak.clone(),
handle.clone(),
track_id,
);
}
}
("track", "uncache") => {
if let Ok(track_id) = id.parse::<u64>() {
offline_cache::remove_cached(
runtime.clone(),
weak.clone(),
handle.clone(),
track_id,
);
}
}
("track", "create-radio") => playback::play_track_radio( ("track", "create-radio") => playback::play_track_radio(
runtime.clone(), runtime.clone(),
weak.clone(), weak.clone(),
+3
View File
@@ -175,6 +175,9 @@ fn to_item(track: &Track) -> TrackItem {
.unwrap_or_default() .unwrap_or_default()
.into(), .into(),
removing: false, removing: false,
cache_status: 0,
cache_progress: 0.0,
unlocking: false,
} }
} }
+267
View File
@@ -0,0 +1,267 @@
//! Slint offline-cache controller.
//!
//! Triggers caching (single / batch) and removal, and drives the per-row
//! offline status + the unlock padlock through a `CacheEventSink` that
//! pushes updates onto the visible track models (mirrors the favorite-state
//! machinery: `set_row_cache_status` / `set_row_unlocking` in `main.rs`).
//!
//! The heavy lifting (download pipeline, CMAF store, vault) lives in the
//! shared `qbz-offline-cache` crate; this is the thin Slint orchestration.
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_offline_cache::{CacheEvent, CacheEventSink, TrackCacheInfo};
use crate::adapter::SlintAdapter;
use crate::AppWindow;
type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// Build a sink that reflects cache + unlock events onto every visible row
/// matching the event's track id (and surfaces terminal toasts). Shared by
/// the cache trigger AND the play path (UnlockStart/End → padlock).
pub fn row_sink(weak: slint::Weak<AppWindow>) -> CacheEventSink {
Arc::new(move |ev: CacheEvent| match ev {
CacheEvent::Started { track_id } => {
push_status(&weak, track_id, 2, 0.0);
}
CacheEvent::Progress {
track_id,
progress_percent,
..
} => {
let p = (progress_percent as f32 / 100.0).clamp(0.0, 1.0);
push_status(&weak, track_id, 2, p);
}
CacheEvent::Completed { track_id, .. } => {
push_status(&weak, track_id, 3, 1.0);
crate::toast::success_weak(&weak, "Cached for offline");
}
CacheEvent::Processed { .. } => {
// Post-processing done; status already 'ready' from Completed.
}
CacheEvent::Failed { track_id, error } => {
log::warn!("[qbz-slint] offline cache failed for {track_id}: {error}");
push_status(&weak, track_id, 4, 0.0);
crate::toast::error_weak(&weak, "Offline caching failed");
}
CacheEvent::UnlockStart { track_id } => {
push_unlocking(&weak, track_id, true);
}
CacheEvent::UnlockEnd { track_id, .. } => {
push_unlocking(&weak, track_id, false);
}
})
}
fn push_status(weak: &slint::Weak<AppWindow>, track_id: u64, status: i32, progress: f32) {
let id = track_id.to_string();
let _ = weak.upgrade_in_event_loop(move |w| {
crate::set_row_cache_status(&w, &id, status, progress);
});
}
fn push_unlocking(weak: &slint::Weak<AppWindow>, track_id: u64, unlocking: bool) {
let id = track_id.to_string();
let _ = weak.upgrade_in_event_loop(move |w| {
crate::set_row_unlocking(&w, &id, unlocking);
});
}
/// Build the DB row metadata from a catalog track. Offline copies are always
/// fetched at the top quality tier.
fn track_cache_info(track: &qbz_models::Track) -> TrackCacheInfo {
TrackCacheInfo {
track_id: track.id,
title: track.title.clone(),
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default(),
album: track.album.as_ref().map(|a| a.title.clone()),
album_id: track.album.as_ref().map(|a| a.id.clone()),
duration_secs: track.duration as u64,
quality: "UltraHiRes".to_string(),
bit_depth: track.maximum_bit_depth,
sample_rate: track.maximum_sampling_rate,
}
}
/// Cache a single track for offline playback. Fetches the track metadata,
/// pre-flights the cache limit, inserts the queued row, and spawns the
/// download (CMAF-first) with a row-updating sink.
pub fn cache_track(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
id: u64,
) {
handle.spawn(async move {
let Some(off) = crate::offline::get().await else {
crate::toast::error_weak(&weak, "Log in to cache tracks offline");
return;
};
let track = match runtime.core().get_track(id).await {
Ok(t) => t,
Err(e) => {
log::error!("[qbz-slint] cache: get_track {id} failed: {e}");
crate::toast::error_weak(&weak, "Couldn't load that track");
return;
}
};
let info = track_cache_info(&track);
let file_path = off.track_file_path(id, "flac");
let file_path_str = file_path.to_string_lossy().to_string();
// Pre-flight the cache limit, then insert the queued row.
{
let limit = *off.limit_bytes.lock().await;
let guard = off.db.lock().await;
let Some(db) = guard.as_ref() else {
return;
};
let root = std::path::PathBuf::from(off.get_cache_path());
if let Err(e) = qbz_offline_cache::maintenance::check_cache_limit(db, &root, limit) {
log::warn!("[qbz-slint] cache limit reached: {e}");
crate::toast::error_weak(
&weak,
"Offline cache is full — free space or raise the limit",
);
return;
}
if let Err(e) = db.insert_track(&info, &file_path_str) {
log::error!("[qbz-slint] cache insert {id} failed: {e}");
return;
}
}
// Mark the row queued immediately, then spawn the download.
push_status(&weak, id, 1, 0.0);
qbz_offline_cache::spawn_track_cache_download(
id,
file_path,
runtime.core().client(),
off.fetcher.clone(),
off.db.clone(),
off.get_cache_path(),
off.library_db.clone(),
row_sink(weak.clone()),
off.cache_semaphore.clone(),
);
});
}
/// Cache a batch of already-fetched catalog tracks (favorites bulk action).
pub fn cache_tracks(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
tracks: Vec<qbz_models::Track>,
) {
if tracks.is_empty() {
return;
}
handle.spawn(async move {
let Some(off) = crate::offline::get().await else {
crate::toast::error_weak(&weak, "Log in to cache tracks offline");
return;
};
// Pre-flight once for the whole batch (mirrors Tauri).
{
let limit = *off.limit_bytes.lock().await;
let guard = off.db.lock().await;
let Some(db) = guard.as_ref() else {
return;
};
let root = std::path::PathBuf::from(off.get_cache_path());
if let Err(e) = qbz_offline_cache::maintenance::check_cache_limit(db, &root, limit) {
log::warn!("[qbz-slint] batch cache limit reached: {e}");
crate::toast::error_weak(
&weak,
"Offline cache is full — free space or raise the limit",
);
return;
}
}
let count = tracks.len();
for track in &tracks {
let id = track.id;
let info = track_cache_info(track);
let file_path = off.track_file_path(id, "flac");
let file_path_str = file_path.to_string_lossy().to_string();
{
let guard = off.db.lock().await;
let Some(db) = guard.as_ref() else {
return;
};
if db.insert_track(&info, &file_path_str).is_err() {
continue;
}
}
push_status(&weak, id, 1, 0.0);
qbz_offline_cache::spawn_track_cache_download(
id,
file_path,
runtime.core().client(),
off.fetcher.clone(),
off.db.clone(),
off.get_cache_path(),
off.library_db.clone(),
row_sink(weak.clone()),
off.cache_semaphore.clone(),
);
}
crate::toast::success_weak(
&weak,
format!("Caching {count} track{} offline…", if count == 1 { "" } else { "s" }),
);
});
}
/// Remove a track's offline copy (DB row + on-disk bundle/file + library row).
pub fn remove_cached(
_runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
id: u64,
) {
handle.spawn(async move {
let Some(off) = crate::offline::get().await else {
return;
};
let removed_path = {
let guard = off.db.lock().await;
match guard.as_ref() {
Some(db) => db.delete_track(id).ok().flatten(),
None => return,
}
};
if let Some(p) = removed_path {
let path = std::path::Path::new(&p);
// v2 bundles live in `tracks-cmaf/<id>/` — remove the whole dir.
let looks_v2 = path
.parent()
.and_then(|pp| pp.parent())
.and_then(|r| r.file_name())
.and_then(|n| n.to_str())
== Some("tracks-cmaf");
if looks_v2 {
if let Some(dir) = path.parent() {
let _ = std::fs::remove_dir_all(dir);
}
} else {
let _ = std::fs::remove_file(path);
}
}
{
let guard = off.library_db.lock().await;
if let Some(db) = guard.as_ref() {
let _ = db.remove_qobuz_cached_track(id);
}
}
push_status(&weak, id, 0, 0.0);
crate::toast::success_weak(&weak, "Removed from offline");
});
}
+9 -5
View File
@@ -54,7 +54,7 @@ pub async fn after_track_change(
) { ) {
refresh_now_playing_meta(runtime, weak).await; refresh_now_playing_meta(runtime, weak).await;
record_recent(runtime).await; record_recent(runtime).await;
play_audible(runtime, track_id).await; play_audible(runtime, weak, track_id).await;
// Warm the cache for the upcoming tracks so the next transition can be // Warm the cache for the upcoming tracks so the next transition can be
// gapless (a cached track plays via `play_data`, which the audio // gapless (a cached track plays via `play_data`, which the audio
// engine's gapless engine supports; a streamed track does not). // engine's gapless engine supports; a streamed track does not).
@@ -129,14 +129,16 @@ type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// Run the audible step for `track_id`: grab the Qobuz client and call /// Run the audible step for `track_id`: grab the Qobuz client and call
/// the player's self-contained `play_track`. Errors are logged, not /// the player's self-contained `play_track`. Errors are logged, not
/// surfaced — the poll loop keeps the UI consistent regardless. /// surfaced — the poll loop keeps the UI consistent regardless.
async fn play_audible(runtime: &Runtime, track_id: u64) { async fn play_audible(runtime: &Runtime, weak: &slint::Weak<AppWindow>, track_id: u64) {
// Route through the shared tier-walk: an offline-cached copy is preferred // Route through the shared tier-walk: an offline-cached copy is preferred
// (decrypted to FLAC and played via play_data) before the player's own // (decrypted to FLAC and played via play_data) before the player's own
// L1/L2 -> network path. The offline handle is None before login. // L1/L2 -> network path. The offline handle is None before login. The
// sink drives the padlock animation while the CMAF bundle decrypts.
let offline = crate::offline::get().await; let offline = crate::offline::get().await;
let sink = crate::offline_cache::row_sink(weak.clone());
if let Err(e) = runtime if let Err(e) = runtime
.core() .core()
.play_track_resolved(track_id, PLAYBACK_QUALITY, offline.as_deref(), None) .play_track_resolved(track_id, PLAYBACK_QUALITY, offline.as_deref(), Some(&sink))
.await .await
{ {
log::error!("[qbz-slint] playback: play_track {track_id} failed: {e}"); log::error!("[qbz-slint] playback: play_track {track_id} failed: {e}");
@@ -1144,18 +1146,20 @@ pub fn start_poll_loop(
if next.id != track_id && !next.is_local { if next.id != track_id && !next.is_local {
gapless_requested_for = track_id; gapless_requested_for = track_id;
let runtime = runtime.clone(); let runtime = runtime.clone();
let weak = weak.clone();
let next_id = next.id; let next_id = next.id;
tokio::spawn(async move { tokio::spawn(async move {
// Shared tier-walk: L1/L2 (player cache) -> offline // Shared tier-walk: L1/L2 (player cache) -> offline
// -> network, then hand the bytes to play_next. // -> network, then hand the bytes to play_next.
let offline = crate::offline::get().await; let offline = crate::offline::get().await;
let sink = crate::offline_cache::row_sink(weak.clone());
if let Some(data) = runtime if let Some(data) = runtime
.core() .core()
.fetch_for_gapless_resolved( .fetch_for_gapless_resolved(
next_id, next_id,
PLAYBACK_QUALITY, PLAYBACK_QUALITY,
offline.as_deref(), offline.as_deref(),
None, Some(&sink),
) )
.await .await
{ {
+3
View File
@@ -382,6 +382,9 @@ fn to_item(track: &Track) -> TrackItem {
.unwrap_or_default() .unwrap_or_default()
.into(), .into(),
removing: false, removing: false,
cache_status: 0,
cache_progress: 0.0,
unlocking: false,
} }
} }
+3
View File
@@ -372,6 +372,9 @@ fn track_item(row: TrackRowData) -> TrackItem {
artist_id: row.artist_id.into(), artist_id: row.artist_id.into(),
album_id: row.album_id.into(), album_id: row.album_id.into(),
removing: false, removing: false,
cache_status: 0,
cache_progress: 0.0,
unlocking: false,
} }
} }
+8
View File
@@ -253,6 +253,14 @@ export struct TrackItem {
album-id: string, album-id: string,
// Fading out (un-favorited in the favorites view) before removal. // Fading out (un-favorited in the favorites view) before removal.
removing: bool, removing: bool,
// Offline-cache status: 0 none, 1 queued, 2 downloading, 3 ready, 4 failed.
// Drives the per-row offline affordance (make-offline / ring / check / warn).
cache-status: int,
// Download progress 0.0..1.0 — meaningful only while cache-status == 2.
cache-progress: float,
// True while this track's offline CMAF bundle is being decrypted for
// playback — drives the padlock glyph (swaps the play/equalizer icon).
unlocking: bool,
} }
// Album detail screen state. // Album detail screen state.