feat(slint): offline manager 1:1 — covers, artist rail, sort, failed-only, persisted limit
Closes the v1 gaps in the offline manager toward Tauri parity: - Album COVERS: rows load the on-disk cover.jpg (per album). Built Send-safely — the worker passes the cover PATH (string), the image is decoded on the UI thread (slint::Image is not Send). - Two-pane layout with an A-Z ARTIST RAIL (left, "All artists" + per-artist "N albums · M tracks", selectable) + the discography list (right). - SORT dropdown (A-Z / Recent / Largest / Smallest; Recent uses the DB's last_accessed_at DESC order) and a "Failed only" toggle. Filter state lives Rust-side (offline_manager) so rebuild can read it off-thread; mirrored back onto OfflineManagerState for the UI. - Limit now PERSISTS to disk (<audio>/offline_limit), restored on activate via apply_persisted_limit; the manager's edit-limit writes it. - Manager mutations (remove/redownload/clear) now rebuild on completion (offline_manager::rebuild made pub, called at the end of each cache op) — the 500ms timed reload hack is gone. Deferred (user-accepted): the "track no longer available on Qobuz" case — offline copy still plays, but warn it can't be re-downloaded / won't work on Qobuz Connect. qbz-slint compiles clean. cc
This commit is contained in:
6 files changed
+434
-72
No files matched your search
@@ -3286,6 +3286,33 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
offline_manager::load(weak.clone(), handle.clone());
|
offline_manager::load(weak.clone(), handle.clone());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = handle.clone();
|
||||||
|
window
|
||||||
|
.global::<OfflineManagerActions>()
|
||||||
|
.on_select_artist(move |name| {
|
||||||
|
offline_manager::select_artist(weak.clone(), handle.clone(), name.to_string());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = handle.clone();
|
||||||
|
window
|
||||||
|
.global::<OfflineManagerActions>()
|
||||||
|
.on_set_sort(move |i| {
|
||||||
|
offline_manager::set_sort(weak.clone(), handle.clone(), i);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = handle.clone();
|
||||||
|
window
|
||||||
|
.global::<OfflineManagerActions>()
|
||||||
|
.on_toggle_failed(move || {
|
||||||
|
offline_manager::toggle_failed(weak.clone(), handle.clone());
|
||||||
|
});
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
@@ -3295,7 +3322,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.on_remove_track(move |id| {
|
.on_remove_track(move |id| {
|
||||||
if let Ok(tid) = id.parse::<u64>() {
|
if let Ok(tid) = id.parse::<u64>() {
|
||||||
offline_cache::remove_cached(runtime.clone(), weak.clone(), handle.clone(), tid);
|
offline_cache::remove_cached(runtime.clone(), weak.clone(), handle.clone(), tid);
|
||||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3306,7 +3332,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.global::<OfflineManagerActions>()
|
.global::<OfflineManagerActions>()
|
||||||
.on_remove_album(move |aid| {
|
.on_remove_album(move |aid| {
|
||||||
offline_cache::remove_album(weak.clone(), handle.clone(), aid.to_string());
|
offline_cache::remove_album(weak.clone(), handle.clone(), aid.to_string());
|
||||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -3323,7 +3348,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
handle.clone(),
|
handle.clone(),
|
||||||
tid,
|
tid,
|
||||||
);
|
);
|
||||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3341,7 +3365,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
aid.to_string(),
|
aid.to_string(),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -3358,7 +3381,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
aid.to_string(),
|
aid.to_string(),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -3375,7 +3397,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
window.global::<OfflineManagerActions>().on_clear_all(move || {
|
window.global::<OfflineManagerActions>().on_clear_all(move || {
|
||||||
offline_cache::clear_all(weak.clone(), handle.clone());
|
offline_cache::clear_all(weak.clone(), handle.clone());
|
||||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ pub async fn activate(user_id: u64) {
|
|||||||
log::error!("[qbz-slint] offline: init_at failed: {e}");
|
log::error!("[qbz-slint] offline: init_at failed: {e}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Restore the persisted cache size limit (written by the manager's
|
||||||
|
// edit-limit). Falls back to the 5 GB default when absent.
|
||||||
|
if let Some(bytes) = read_persisted_limit(&state.get_cache_path()) {
|
||||||
|
state.apply_persisted_limit(Some(bytes)).await;
|
||||||
|
}
|
||||||
if let Err(e) = state.init_library_connection(&data_dir).await {
|
if let Err(e) = state.init_library_connection(&data_dir).await {
|
||||||
// Non-fatal: cached playback still resolves from index.db; only the
|
// Non-fatal: cached playback still resolves from index.db; only the
|
||||||
// library-row sync (show-in-library) needs this connection.
|
// library-row sync (show-in-library) needs this connection.
|
||||||
@@ -68,6 +73,28 @@ pub async fn activate(user_id: u64) {
|
|||||||
log::info!("[qbz-slint] offline cache activated for user {user_id}");
|
log::info!("[qbz-slint] offline cache activated for user {user_id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The on-disk limit file (next to index.db, in the per-user audio dir).
|
||||||
|
fn limit_file(audio_dir: &str) -> std::path::PathBuf {
|
||||||
|
std::path::Path::new(audio_dir).join("offline_limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_persisted_limit(audio_dir: &str) -> Option<u64> {
|
||||||
|
std::fs::read_to_string(limit_file(audio_dir))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the cache size limit (bytes) to disk for the active cache, so the
|
||||||
|
/// manager's edit-limit survives restarts.
|
||||||
|
pub async fn persist_limit(bytes: u64) {
|
||||||
|
if let Some(off) = get().await {
|
||||||
|
let path = limit_file(&off.get_cache_path());
|
||||||
|
if let Err(e) = std::fs::write(&path, bytes.to_string()) {
|
||||||
|
log::warn!("[qbz-slint] offline: persist limit failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tear down the offline cache on logout.
|
/// Tear down the offline cache on logout.
|
||||||
pub async fn deactivate() {
|
pub async fn deactivate() {
|
||||||
if let Some(state) = slot().lock().await.take() {
|
if let Some(state) = slot().lock().await.take() {
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ pub fn remove_album(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
crate::toast::success_weak(&weak, "Removed album from offline");
|
crate::toast::success_weak(&weak, "Removed album from offline");
|
||||||
|
crate::offline_manager::rebuild(weak.clone()).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,6 +420,7 @@ pub fn redownload_track(
|
|||||||
row_sink(weak.clone()),
|
row_sink(weak.clone()),
|
||||||
off.cache_semaphore.clone(),
|
off.cache_semaphore.clone(),
|
||||||
);
|
);
|
||||||
|
crate::offline_manager::rebuild(weak.clone()).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +470,7 @@ pub fn redownload_album(
|
|||||||
off.cache_semaphore.clone(),
|
off.cache_semaphore.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
crate::offline_manager::rebuild(weak.clone()).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,6 +507,7 @@ pub fn clear_all(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
|||||||
s.clear();
|
s.clear();
|
||||||
}
|
}
|
||||||
crate::toast::success_weak(&weak, "Offline cache cleared");
|
crate::toast::success_weak(&weak, "Offline cache cleared");
|
||||||
|
crate::offline_manager::rebuild(weak.clone()).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,5 +555,6 @@ pub fn remove_cached(
|
|||||||
mark_cached(id, false);
|
mark_cached(id, false);
|
||||||
push_status(&weak, id, 0, 0.0);
|
push_status(&weak, id, 0, 0.0);
|
||||||
crate::toast::success_weak(&weak, "Removed from offline");
|
crate::toast::success_weak(&weak, "Removed from offline");
|
||||||
|
crate::offline_manager::rebuild(weak.clone()).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,53 @@
|
|||||||
//! Offline Cache Manager controller — loads the cached-tracks rollup + stats
|
//! Offline Cache Manager controller — loads the artist→album→track rollup +
|
||||||
//! into `OfflineManagerState`. Per-item actions reuse `offline_cache::*`
|
//! stats into `OfflineManagerState`. Per-item actions reuse `offline_cache::*`
|
||||||
//! (Slice 3); this module owns the data load + the size-limit edit.
|
//! (Slice 3); this module owns the data load, the toolbar filters (artist
|
||||||
|
//! rail / sort / show-only-failed), the album covers, and the size-limit edit.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{Mutex as StdMutex, OnceLock};
|
||||||
|
|
||||||
use qbz_offline_cache::{CachedTrackInfo, OfflineCacheStatus};
|
use qbz_offline_cache::{CachedTrackInfo, OfflineCacheStatus};
|
||||||
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
|
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
|
||||||
|
|
||||||
use crate::{AppWindow, OfflineManagerState, OfflineRow};
|
use crate::{AppWindow, OfflineArtist, OfflineManagerState, OfflineRow};
|
||||||
|
|
||||||
const GB: u64 = 1024 * 1024 * 1024;
|
const GB: u64 = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
// --- Toolbar filter state (Rust-side source of truth) -------------------
|
||||||
|
// The rebuild runs on a tokio task and can't read Slint props cross-thread,
|
||||||
|
// so the artist selection / sort / show-only-failed live here. The UI
|
||||||
|
// actions update these + trigger a rebuild; the rebuild mirrors them back
|
||||||
|
// onto OfflineManagerState for the dropdown / toggle / rail display.
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Filters {
|
||||||
|
selected_artist: String, // "" = all
|
||||||
|
sort: i32, // 0 alpha / 1 recent / 2 largest / 3 smallest
|
||||||
|
show_only_failed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Filters {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
selected_artist: String::new(),
|
||||||
|
sort: 0,
|
||||||
|
show_only_failed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static FILTERS: OnceLock<StdMutex<Filters>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn filters() -> &'static StdMutex<Filters> {
|
||||||
|
FILTERS.get_or_init(|| StdMutex::new(Filters::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_filters() -> Filters {
|
||||||
|
filters().lock().map(|f| f.clone()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Formatting ---------------------------------------------------------
|
||||||
|
|
||||||
fn human_size(bytes: u64) -> String {
|
fn human_size(bytes: u64) -> String {
|
||||||
let b = bytes as f64;
|
let b = bytes as f64;
|
||||||
if bytes >= GB {
|
if bytes >= GB {
|
||||||
@@ -28,7 +65,7 @@ fn track_status_int(s: &OfflineCacheStatus) -> i32 {
|
|||||||
match s {
|
match s {
|
||||||
OfflineCacheStatus::Ready => 3,
|
OfflineCacheStatus::Ready => 3,
|
||||||
OfflineCacheStatus::Failed => 4,
|
OfflineCacheStatus::Failed => 4,
|
||||||
_ => 2, // queued / downloading
|
_ => 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,48 +73,128 @@ fn fmt_duration(secs: u64) -> String {
|
|||||||
format!("{}:{:02}", secs / 60, secs % 60)
|
format!("{}:{:02}", secs / 60, secs % 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the index.db, build the album→track rollup + stats, and push them to
|
fn album_size(group: &[CachedTrackInfo]) -> u64 {
|
||||||
/// `OfflineManagerState`.
|
group.iter().map(|t| t.file_size_bytes).sum()
|
||||||
async fn rebuild(weak: slint::Weak<AppWindow>) {
|
}
|
||||||
|
|
||||||
|
/// Path of an album's on-disk `cover.jpg` (next to one track's CMAF bundle),
|
||||||
|
/// or "" when it doesn't exist. Computed on the worker (a `String` is `Send`);
|
||||||
|
/// the image itself is loaded on the UI thread (`slint::Image` is NOT `Send`).
|
||||||
|
fn cover_path(cache_path: &str, track_id: u64) -> String {
|
||||||
|
let p = std::path::Path::new(cache_path)
|
||||||
|
.join("tracks-cmaf")
|
||||||
|
.join(track_id.to_string())
|
||||||
|
.join("cover.jpg");
|
||||||
|
if p.exists() {
|
||||||
|
p.to_string_lossy().to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a cover image from a path on the UI thread (empty path / missing -> default).
|
||||||
|
fn load_cover(path: &str) -> slint::Image {
|
||||||
|
if path.is_empty() {
|
||||||
|
return slint::Image::default();
|
||||||
|
}
|
||||||
|
slint::Image::load_from_path(std::path::Path::new(path)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Worker-built, `Send` row data. Converted to the (non-`Send`) `OfflineRow`
|
||||||
|
/// on the UI thread, where the cover image is decoded.
|
||||||
|
struct RowData {
|
||||||
|
kind: &'static str,
|
||||||
|
album_id: String,
|
||||||
|
track_id: String,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
meta: String,
|
||||||
|
status: i32,
|
||||||
|
progress: f32,
|
||||||
|
cover_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data load ----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Read the index.db, build the artist→album→track rollup + stats (applying
|
||||||
|
/// the current toolbar filters), and push them to `OfflineManagerState`.
|
||||||
|
/// `pub` so the cache mutation fns refresh the manager after their DB op.
|
||||||
|
pub async fn rebuild(weak: slint::Weak<AppWindow>) {
|
||||||
|
let f = current_filters();
|
||||||
let off = crate::offline::get().await;
|
let off = crate::offline::get().await;
|
||||||
let (tracks, limit): (Vec<CachedTrackInfo>, Option<u64>) = match off {
|
let (tracks, limit, cache_path): (Vec<CachedTrackInfo>, Option<u64>, String) = match off {
|
||||||
Some(ref o) => {
|
Some(ref o) => {
|
||||||
let limit = *o.limit_bytes.lock().await;
|
let limit = *o.limit_bytes.lock().await;
|
||||||
|
let cp = o.get_cache_path();
|
||||||
let guard = o.db.lock().await;
|
let guard = o.db.lock().await;
|
||||||
let tracks = guard
|
let tracks = guard
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|db| db.get_all_tracks().ok())
|
.and_then(|db| db.get_all_tracks().ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
(tracks, limit)
|
(tracks, limit, cp)
|
||||||
}
|
}
|
||||||
None => (Vec::new(), None),
|
None => (Vec::new(), None, String::new()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let total_size: u64 = tracks.iter().map(|t| t.file_size_bytes).sum();
|
let total_size: u64 = tracks.iter().map(|t| t.file_size_bytes).sum();
|
||||||
let tracks_count = tracks.len() as i32;
|
let tracks_count = tracks.len() as i32;
|
||||||
|
|
||||||
// Group by album_id, remembering first-seen order.
|
// album_id -> (artist, album_title, tracks), first-seen order (the DB
|
||||||
let mut order: Vec<String> = Vec::new();
|
// already returns rows most-recently-accessed first, so this order is
|
||||||
let mut groups: BTreeMap<String, (String, String, Vec<CachedTrackInfo>)> = BTreeMap::new();
|
// the "recent" sort).
|
||||||
|
let mut album_order: Vec<String> = Vec::new();
|
||||||
|
let mut albums: BTreeMap<String, (String, String, Vec<CachedTrackInfo>)> = BTreeMap::new();
|
||||||
for t in tracks {
|
for t in tracks {
|
||||||
let aid = t.album_id.clone().unwrap_or_else(|| "__singles__".to_string());
|
let aid = t.album_id.clone().unwrap_or_else(|| "__singles__".to_string());
|
||||||
if !groups.contains_key(&aid) {
|
if !albums.contains_key(&aid) {
|
||||||
order.push(aid.clone());
|
album_order.push(aid.clone());
|
||||||
}
|
}
|
||||||
let album = t.album.clone().unwrap_or_else(|| "Singles".to_string());
|
let title = t.album.clone().unwrap_or_else(|| "Singles".to_string());
|
||||||
groups
|
albums
|
||||||
.entry(aid)
|
.entry(aid)
|
||||||
.or_insert_with(|| (album, t.artist.clone(), Vec::new()))
|
.or_insert_with(|| (t.artist.clone(), title, Vec::new()))
|
||||||
.2
|
.2
|
||||||
.push(t);
|
.push(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut rows: Vec<OfflineRow> = Vec::new();
|
// Artist rail: name -> (album_count, track_count), A-Z (BTreeMap order).
|
||||||
|
let mut artist_stats: BTreeMap<String, (usize, usize)> = BTreeMap::new();
|
||||||
|
for aid in &album_order {
|
||||||
|
let (artist, _title, group) = &albums[aid];
|
||||||
|
let e = artist_stats.entry(artist.clone()).or_insert((0, 0));
|
||||||
|
e.0 += 1;
|
||||||
|
e.1 += group.len();
|
||||||
|
}
|
||||||
|
let artists: Vec<OfflineArtist> = artist_stats
|
||||||
|
.iter()
|
||||||
|
.map(|(name, (albums_n, tracks_n))| OfflineArtist {
|
||||||
|
name: name.clone().into(),
|
||||||
|
meta: format!("{} albums · {} tracks", albums_n, tracks_n).into(),
|
||||||
|
selected: *name == f.selected_artist,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Album display order per the sort.
|
||||||
|
let mut order = album_order.clone();
|
||||||
|
match f.sort {
|
||||||
|
0 => order.sort_by(|a, b| albums[a].1.to_lowercase().cmp(&albums[b].1.to_lowercase())),
|
||||||
|
2 => order.sort_by(|a, b| album_size(&albums[b].2).cmp(&album_size(&albums[a].2))),
|
||||||
|
3 => order.sort_by(|a, b| album_size(&albums[a].2).cmp(&album_size(&albums[b].2))),
|
||||||
|
_ => {} // 1 recent — keep the DB's last_accessed_at DESC order
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows: Vec<RowData> = Vec::new();
|
||||||
for aid in &order {
|
for aid in &order {
|
||||||
let (album, artist, group) = groups.get(aid).unwrap();
|
let (artist, title, group) = &albums[aid];
|
||||||
|
if !f.selected_artist.is_empty() && *artist != f.selected_artist {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let any_failed = group
|
let any_failed = group
|
||||||
.iter()
|
.iter()
|
||||||
.any(|t| matches!(t.status, OfflineCacheStatus::Failed));
|
.any(|t| matches!(t.status, OfflineCacheStatus::Failed));
|
||||||
|
if f.show_only_failed && !any_failed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let any_active = group.iter().any(|t| {
|
let any_active = group.iter().any(|t| {
|
||||||
matches!(
|
matches!(
|
||||||
t.status,
|
t.status,
|
||||||
@@ -96,27 +213,35 @@ async fn rebuild(weak: slint::Weak<AppWindow>) {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let album_size: u64 = group.iter().map(|t| t.file_size_bytes).sum();
|
let cover_path = group
|
||||||
rows.push(OfflineRow {
|
.first()
|
||||||
kind: "album".into(),
|
.map(|t| cover_path(&cache_path, t.track_id))
|
||||||
album_id: aid.clone().into(),
|
.unwrap_or_default();
|
||||||
track_id: SharedString::new(),
|
rows.push(RowData {
|
||||||
title: album.clone().into(),
|
kind: "album",
|
||||||
subtitle: artist.clone().into(),
|
album_id: aid.clone(),
|
||||||
meta: format!("{} tracks · {}", group.len(), human_size(album_size)).into(),
|
track_id: String::new(),
|
||||||
|
title: title.clone(),
|
||||||
|
subtitle: artist.clone(),
|
||||||
|
meta: format!("{} tracks · {}", group.len(), human_size(album_size(group))),
|
||||||
status: album_status,
|
status: album_status,
|
||||||
progress: 0.0,
|
progress: 0.0,
|
||||||
|
cover_path,
|
||||||
});
|
});
|
||||||
for t in group {
|
for t in group {
|
||||||
rows.push(OfflineRow {
|
if f.show_only_failed && !matches!(t.status, OfflineCacheStatus::Failed) {
|
||||||
kind: "track".into(),
|
continue;
|
||||||
album_id: aid.clone().into(),
|
}
|
||||||
track_id: t.track_id.to_string().into(),
|
rows.push(RowData {
|
||||||
title: t.title.clone().into(),
|
kind: "track",
|
||||||
subtitle: t.artist.clone().into(),
|
album_id: aid.clone(),
|
||||||
meta: fmt_duration(t.duration_secs).into(),
|
track_id: t.track_id.to_string(),
|
||||||
|
title: t.title.clone(),
|
||||||
|
subtitle: t.artist.clone(),
|
||||||
|
meta: fmt_duration(t.duration_secs),
|
||||||
status: track_status_int(&t.status),
|
status: track_status_int(&t.status),
|
||||||
progress: t.progress_percent as f32 / 100.0,
|
progress: t.progress_percent as f32 / 100.0,
|
||||||
|
cover_path: String::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,12 +258,32 @@ async fn rebuild(weak: slint::Weak<AppWindow>) {
|
|||||||
|
|
||||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
let st = w.global::<OfflineManagerState>();
|
let st = w.global::<OfflineManagerState>();
|
||||||
st.set_rows(ModelRc::new(VecModel::from(rows)));
|
// Build OfflineRow on the UI thread (decodes the cover images here —
|
||||||
|
// slint::Image is not Send, so it can't be built on the worker).
|
||||||
|
let offline_rows: Vec<OfflineRow> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|rd| OfflineRow {
|
||||||
|
kind: rd.kind.into(),
|
||||||
|
album_id: rd.album_id.into(),
|
||||||
|
track_id: rd.track_id.into(),
|
||||||
|
title: rd.title.into(),
|
||||||
|
subtitle: rd.subtitle.into(),
|
||||||
|
meta: rd.meta.into(),
|
||||||
|
status: rd.status,
|
||||||
|
progress: rd.progress,
|
||||||
|
cover: load_cover(&rd.cover_path),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
st.set_rows(ModelRc::new(VecModel::from(offline_rows)));
|
||||||
|
st.set_artists(ModelRc::new(VecModel::from(artists)));
|
||||||
st.set_tracks_count(tracks_count);
|
st.set_tracks_count(tracks_count);
|
||||||
st.set_size_text(SharedString::from(size_text));
|
st.set_size_text(SharedString::from(size_text));
|
||||||
st.set_limit_text(SharedString::from(limit_text));
|
st.set_limit_text(SharedString::from(limit_text));
|
||||||
st.set_usage(usage);
|
st.set_usage(usage);
|
||||||
st.set_limit_gb(limit_gb);
|
st.set_limit_gb(limit_gb);
|
||||||
|
st.set_selected_artist(SharedString::from(f.selected_artist));
|
||||||
|
st.set_sort_index(f.sort);
|
||||||
|
st.set_show_only_failed(f.show_only_failed);
|
||||||
st.set_loading(false);
|
st.set_loading(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -151,22 +296,37 @@ pub fn load(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
|||||||
handle.spawn(rebuild(weak));
|
handle.spawn(rebuild(weak));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild after a brief delay — used after a manager mutation (remove /
|
// --- Toolbar actions ----------------------------------------------------
|
||||||
/// re-download) so the list reflects the async DB op without a manual refresh.
|
|
||||||
pub fn reload_soon(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
pub fn select_artist(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, name: String) {
|
||||||
handle.spawn(async move {
|
if let Ok(mut f) = filters().lock() {
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
f.selected_artist = name;
|
||||||
rebuild(weak).await;
|
}
|
||||||
});
|
handle.spawn(rebuild(weak));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the in-memory cache size limit (GB) and refresh. (Session-scoped for
|
pub fn set_sort(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, index: i32) {
|
||||||
/// now — Slint-side persistence is a follow-up.)
|
if let Ok(mut f) = filters().lock() {
|
||||||
|
f.sort = index;
|
||||||
|
}
|
||||||
|
handle.spawn(rebuild(weak));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_failed(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||||
|
if let Ok(mut f) = filters().lock() {
|
||||||
|
f.show_only_failed = !f.show_only_failed;
|
||||||
|
}
|
||||||
|
handle.spawn(rebuild(weak));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the cache size limit (GB), persist it to disk, and refresh.
|
||||||
pub fn set_limit(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, gb: i32) {
|
pub fn set_limit(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, gb: i32) {
|
||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
|
let bytes = (gb.max(1) as u64) * GB;
|
||||||
if let Some(off) = crate::offline::get().await {
|
if let Some(off) = crate::offline::get().await {
|
||||||
*off.limit_bytes.lock().await = Some((gb.max(1) as u64) * GB);
|
*off.limit_bytes.lock().await = Some(bytes);
|
||||||
}
|
}
|
||||||
|
crate::offline::persist_limit(bytes).await;
|
||||||
rebuild(weak).await;
|
rebuild(weak).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
// Offline Cache Manager — the standalone manage-downloads surface (Tauri's
|
// Offline Cache Manager — the standalone manage-downloads surface (Tauri's
|
||||||
// OfflineCacheManagerView). Fixed header (Back + title) + a stats bar (totals
|
// OfflineCacheManagerView). Header (Back + title) + a stats bar (totals /
|
||||||
// / usage bar / edit-limit / open-folder / clear-all) over a scrolling list
|
// usage bar / edit-limit / open-folder / clear-all) + a toolbar (sort
|
||||||
// of album-header + track rows (per-row remove / re-download).
|
// dropdown / show-only-failed) over a two-pane body: a left A-Z artist rail
|
||||||
|
// and a right scrolling list of album-header (with cover) + track rows
|
||||||
|
// (per-row remove / re-download).
|
||||||
//
|
//
|
||||||
// Rows are precomputed in Rust (crate::offline_manager) and pushed as a flat
|
// Rows + artists are precomputed in Rust (crate::offline_manager) and pushed
|
||||||
// [OfflineRow] model; actions go through OfflineManagerActions.
|
// as models; actions go through OfflineManagerActions.
|
||||||
|
|
||||||
import { ScrollView } from "std-widgets.slint";
|
import { ScrollView } from "std-widgets.slint";
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { OfflineManagerState, OfflineManagerActions, OfflineRow } from "../state.slint";
|
import { OfflineManagerState, OfflineManagerActions, OfflineRow, OfflineArtist } from "../state.slint";
|
||||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
||||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||||
import { NavButtons } from "../shell/NavButtons.slint";
|
import { NavButtons } from "../shell/NavButtons.slint";
|
||||||
|
|
||||||
@@ -40,8 +43,7 @@ component IconBtn inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The cache-status glyph: 2 downloading (spinning loader) / 3 ready (check) /
|
// The cache-status glyph (downloading spins).
|
||||||
// 4 failed (alert) / else cloud.
|
|
||||||
component StatusIcon inherits Rectangle {
|
component StatusIcon inherits Rectangle {
|
||||||
in property <int> status;
|
in property <int> status;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
@@ -69,14 +71,52 @@ component StatusIcon inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One artist-rail row.
|
||||||
|
component ArtistRailRow inherits Rectangle {
|
||||||
|
in property <string> name;
|
||||||
|
in property <string> meta;
|
||||||
|
in property <bool> active;
|
||||||
|
callback clicked;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: root.active ? Theme.surface-elevated : (ta.has-hover ? #ffffff10 : transparent);
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 8px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 2px;
|
||||||
|
Text {
|
||||||
|
text: root.name;
|
||||||
|
color: root.active ? Theme.accent : Theme.text-primary;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
font-weight: root.active ? Typography.semibold : Typography.regular;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: root.meta;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
root.clicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export component OfflineManagerView inherits Rectangle {
|
export component OfflineManagerView inherits Rectangle {
|
||||||
background: Theme.surface-main;
|
background: Theme.surface-main;
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
spacing: 16px;
|
spacing: 14px;
|
||||||
|
|
||||||
// Header — back + title.
|
// Header.
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
vertical-stretch: 0;
|
vertical-stretch: 0;
|
||||||
spacing: 12px;
|
spacing: 12px;
|
||||||
@@ -101,7 +141,6 @@ export component OfflineManagerView inherits Rectangle {
|
|||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
spacing: 20px;
|
spacing: 20px;
|
||||||
// Totals.
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
Text {
|
Text {
|
||||||
@@ -116,7 +155,6 @@ export component OfflineManagerView inherits Rectangle {
|
|||||||
font-size: Typography.legal;
|
font-size: Typography.legal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Usage bar.
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
@@ -136,7 +174,6 @@ export component OfflineManagerView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Edit limit (GB).
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
@@ -178,7 +215,6 @@ export component OfflineManagerView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Open folder + clear all.
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
IconBtn {
|
IconBtn {
|
||||||
@@ -201,7 +237,81 @@ export component OfflineManagerView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading / empty / list.
|
// Toolbar — sort + show-only-failed.
|
||||||
|
HorizontalLayout {
|
||||||
|
vertical-stretch: 0;
|
||||||
|
spacing: 12px;
|
||||||
|
QbzSelect {
|
||||||
|
menu-width: 160px;
|
||||||
|
sm: true;
|
||||||
|
options: [@tr("Sort: A-Z"), @tr("Sort: Recent"), @tr("Sort: Largest"), @tr("Sort: Smallest")];
|
||||||
|
current-index: OfflineManagerState.sort-index;
|
||||||
|
selected(i) => {
|
||||||
|
OfflineManagerActions.set-sort(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: failed-label.preferred-width + 28px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: OfflineManagerState.show-only-failed ? Theme.accent : Theme.border-subtle;
|
||||||
|
background: OfflineManagerState.show-only-failed ? #e0564f22 : (failed-ta.has-hover ? Theme.surface-elevated : transparent);
|
||||||
|
failed-label := Text {
|
||||||
|
x: 14px;
|
||||||
|
text: @tr("Failed only");
|
||||||
|
color: OfflineManagerState.show-only-failed ? #e0564f : Theme.text-secondary;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
vertical-alignment: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
failed-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
OfflineManagerActions.toggle-failed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle { } // spacer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body — artist rail + rows.
|
||||||
|
HorizontalLayout {
|
||||||
|
vertical-stretch: 1;
|
||||||
|
spacing: 14px;
|
||||||
|
|
||||||
|
// Left A-Z artist rail.
|
||||||
|
Rectangle {
|
||||||
|
width: 210px;
|
||||||
|
border-radius: Radius.md;
|
||||||
|
background: Theme.surface-card;
|
||||||
|
ScrollView {
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 6px;
|
||||||
|
spacing: 2px;
|
||||||
|
ArtistRailRow {
|
||||||
|
name: @tr("All artists");
|
||||||
|
meta: OfflineManagerState.tracks-count + " tracks";
|
||||||
|
active: OfflineManagerState.selected-artist == "";
|
||||||
|
clicked => {
|
||||||
|
OfflineManagerActions.select-artist("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for a in OfflineManagerState.artists: ArtistRailRow {
|
||||||
|
name: a.name;
|
||||||
|
meta: a.meta;
|
||||||
|
active: a.selected;
|
||||||
|
clicked => {
|
||||||
|
OfflineManagerActions.select-artist(a.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right list.
|
||||||
|
Rectangle {
|
||||||
|
horizontal-stretch: 1;
|
||||||
if OfflineManagerState.loading: VerticalLayout {
|
if OfflineManagerState.loading: VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
@@ -212,7 +322,7 @@ export component OfflineManagerView inherits Rectangle {
|
|||||||
if !OfflineManagerState.loading && OfflineManagerState.rows.length == 0: VerticalLayout {
|
if !OfflineManagerState.loading && OfflineManagerState.rows.length == 0: VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
Text {
|
Text {
|
||||||
text: "No offline tracks yet";
|
text: @tr("No offline tracks yet");
|
||||||
color: Theme.text-muted;
|
color: Theme.text-muted;
|
||||||
font-size: Typography.body;
|
font-size: Typography.body;
|
||||||
horizontal-alignment: center;
|
horizontal-alignment: center;
|
||||||
@@ -222,17 +332,36 @@ export component OfflineManagerView inherits Rectangle {
|
|||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 2px;
|
spacing: 2px;
|
||||||
for row in OfflineManagerState.rows: Rectangle {
|
for row in OfflineManagerState.rows: Rectangle {
|
||||||
height: row.kind == "album" ? 58px : 44px;
|
height: row.kind == "album" ? 60px : 44px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: row.kind == "album" ? Theme.surface-card : transparent;
|
background: row.kind == "album" ? Theme.surface-card : transparent;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding-left: row.kind == "album" ? 12px : 28px;
|
padding-left: row.kind == "album" ? 10px : 28px;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
spacing: 12px;
|
spacing: 12px;
|
||||||
|
// Album cover (album rows only).
|
||||||
|
if row.kind == "album": VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
clip: true;
|
||||||
|
background: Theme.surface-main;
|
||||||
|
Image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
source: row.cover;
|
||||||
|
image-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Status glyph.
|
// Status glyph.
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
StatusIcon { status: row.status; }
|
StatusIcon {
|
||||||
|
status: row.status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Title + subtitle.
|
// Title + subtitle.
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
@@ -297,3 +426,5 @@ export component OfflineManagerView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -671,10 +671,19 @@ export struct OfflineRow {
|
|||||||
// 0 none | 2 downloading | 3 ready | 4 failed. Album = worst across tracks.
|
// 0 none | 2 downloading | 3 ready | 4 failed. Album = worst across tracks.
|
||||||
status: int,
|
status: int,
|
||||||
progress: float, // track downloading 0..1
|
progress: float, // track downloading 0..1
|
||||||
|
cover: image, // album-header cover (loaded from the on-disk cover.jpg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One entry in the manager's left A-Z artist rail.
|
||||||
|
export struct OfflineArtist {
|
||||||
|
name: string,
|
||||||
|
meta: string, // "3 albums · 21 tracks"
|
||||||
|
selected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
export global OfflineManagerState {
|
export global OfflineManagerState {
|
||||||
in property <[OfflineRow]> rows: [];
|
in property <[OfflineRow]> rows: [];
|
||||||
|
in property <[OfflineArtist]> artists: [];
|
||||||
in property <bool> loading: false;
|
in property <bool> loading: false;
|
||||||
// Header stats.
|
// Header stats.
|
||||||
in property <int> tracks-count: 0;
|
in property <int> tracks-count: 0;
|
||||||
@@ -682,12 +691,21 @@ export global OfflineManagerState {
|
|||||||
in property <string> limit-text: ""; // "of 5 GB" | "Unlimited"
|
in property <string> limit-text: ""; // "of 5 GB" | "Unlimited"
|
||||||
in property <float> usage: 0.0; // 0..1 for the bar (0 = unlimited)
|
in property <float> usage: 0.0; // 0..1 for the bar (0 = unlimited)
|
||||||
in-out property <int> limit-gb: 5; // edit-limit input
|
in-out property <int> limit-gb: 5; // edit-limit input
|
||||||
|
// Toolbar — set by the controller from the Rust-side filter state so the
|
||||||
|
// dropdown selection / toggle / rail highlight reflect the current view.
|
||||||
|
in property <string> selected-artist: ""; // "" = all artists
|
||||||
|
in property <int> sort-index: 0; // 0 alpha / 1 recent / 2 largest / 3 smallest
|
||||||
|
in property <bool> show-only-failed: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export global OfflineManagerActions {
|
export global OfflineManagerActions {
|
||||||
// Navigate to the manager (from the Settings "Offline Playback" entry).
|
// Navigate to the manager (from the Settings "Offline Playback" entry).
|
||||||
callback open();
|
callback open();
|
||||||
callback refresh();
|
callback refresh();
|
||||||
|
// Toolbar.
|
||||||
|
callback select-artist(string /* name, "" = all */);
|
||||||
|
callback set-sort(int /* index */);
|
||||||
|
callback toggle-failed();
|
||||||
callback remove-track(string /* track id */);
|
callback remove-track(string /* track id */);
|
||||||
callback remove-album(string /* album id */);
|
callback remove-album(string /* album id */);
|
||||||
callback redownload-track(string /* track id */);
|
callback redownload-track(string /* track id */);
|
||||||
|
|||||||
Reference in new issue
Block a user