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:
11 files changed
+387
-5
No files matched your search
@@ -257,6 +257,9 @@ pub fn apply_album(window: &AppWindow, data: AlbumData) {
|
||||
artist_id: track.artist_id.into(),
|
||||
album_id: album_id.clone(),
|
||||
removing: false,
|
||||
cache_status: 0,
|
||||
cache_progress: 0.0,
|
||||
unlocking: false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -503,6 +503,9 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) {
|
||||
artist_id: track.artist_id.into(),
|
||||
album_id: track.album_id.into(),
|
||||
removing: false,
|
||||
cache_status: 0,
|
||||
cache_progress: 0.0,
|
||||
unlocking: false,
|
||||
})
|
||||
.collect();
|
||||
let release_sections: Vec<DiscoverSection> = data
|
||||
|
||||
@@ -419,6 +419,9 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
|
||||
artist_id: t.artist_id.into(),
|
||||
album_id: t.album_id.into(),
|
||||
removing: false,
|
||||
cache_status: 0,
|
||||
cache_progress: 0.0,
|
||||
unlocking: false,
|
||||
})
|
||||
.collect();
|
||||
// `tracks` is the full set the artwork pipeline targets;
|
||||
|
||||
@@ -763,6 +763,9 @@ fn top_track_to_item(t: &TopTrack) -> TrackItem {
|
||||
artist_id: t.artist_id.clone().into(),
|
||||
album_id: t.album_id.clone().into(),
|
||||
removing: false,
|
||||
cache_status: 0,
|
||||
cache_progress: 0.0,
|
||||
unlocking: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ mod drag;
|
||||
mod folders;
|
||||
mod library_db;
|
||||
mod offline;
|
||||
mod offline_cache;
|
||||
mod playlist;
|
||||
mod playlist_manager;
|
||||
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
|
||||
/// opened. No-op once loaded (the data persists for the session).
|
||||
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(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
|
||||
@@ -175,6 +175,9 @@ fn to_item(track: &Track) -> TrackItem {
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
removing: false,
|
||||
cache_status: 0,
|
||||
cache_progress: 0.0,
|
||||
unlocking: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -54,7 +54,7 @@ pub async fn after_track_change(
|
||||
) {
|
||||
refresh_now_playing_meta(runtime, weak).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
|
||||
// gapless (a cached track plays via `play_data`, which the audio
|
||||
// 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
|
||||
/// the player's self-contained `play_track`. Errors are logged, not
|
||||
/// 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
|
||||
// (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 sink = crate::offline_cache::row_sink(weak.clone());
|
||||
if let Err(e) = runtime
|
||||
.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
|
||||
{
|
||||
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 {
|
||||
gapless_requested_for = track_id;
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
let next_id = next.id;
|
||||
tokio::spawn(async move {
|
||||
// Shared tier-walk: L1/L2 (player cache) -> offline
|
||||
// -> network, then hand the bytes to play_next.
|
||||
let offline = crate::offline::get().await;
|
||||
let sink = crate::offline_cache::row_sink(weak.clone());
|
||||
if let Some(data) = runtime
|
||||
.core()
|
||||
.fetch_for_gapless_resolved(
|
||||
next_id,
|
||||
PLAYBACK_QUALITY,
|
||||
offline.as_deref(),
|
||||
None,
|
||||
Some(&sink),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -382,6 +382,9 @@ fn to_item(track: &Track) -> TrackItem {
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
removing: false,
|
||||
cache_status: 0,
|
||||
cache_progress: 0.0,
|
||||
unlocking: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -372,6 +372,9 @@ fn track_item(row: TrackRowData) -> TrackItem {
|
||||
artist_id: row.artist_id.into(),
|
||||
album_id: row.album_id.into(),
|
||||
removing: false,
|
||||
cache_status: 0,
|
||||
cache_progress: 0.0,
|
||||
unlocking: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -253,6 +253,14 @@ export struct TrackItem {
|
||||
album-id: string,
|
||||
// Fading out (un-favorited in the favorites view) before removal.
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user