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(),
|
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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user