feat(slint): offline cache manager + remove/redownload + Settings Offline page
Slices 3 + 4 of the offline-cache port. Slice 3 (controller, offline_cache.rs): - remove_album (maintenance::remove_album_cached_tracks + library rows + live row update), redownload_track / redownload_album(failed_only) (via select_redownload_targets + reset_track_for_redownload + spawn), clear_all (purge), open_folder (xdg-open / open the cache dir). Slice 4 (manager + Settings): - OfflineManagerView.slint: a standalone route (NavEntry::OfflineManager -> ContentView.offline-manager, mounted in AppShell, mirrors PlaylistManager). Stats bar (tracks count / size / limit / usage bar + edit-limit GB input + open-folder + clear-all) over a scrolling album-header + track-row list with a spinning/check/alert status glyph and per-row re-download / remove. - offline_manager.rs: builds the album->track rollup + stats from index.db (get_all_tracks), pushes [OfflineRow] + stats to OfflineManagerState; set_limit (in-memory, session-scoped) + reload_soon (refresh after a mutation). - OfflineManagerActions wired in main.rs (open/refresh/remove/redownload/ set-limit/clear-all/open-folder/play). - Settings gains an "Offline" section (OfflineSettings.slint): Manage offline cache (-> the manager), Open cache folder, Clear all. The Local-Library rows (show-in-library, repair) arrive with the Local Library port. qbz-slint compiles clean. cc
This commit is contained in:
10 files changed
+922
-2
No files matched your search
@@ -45,6 +45,7 @@ mod folders;
|
||||
mod library_db;
|
||||
mod offline;
|
||||
mod offline_cache;
|
||||
mod offline_manager;
|
||||
mod playlist;
|
||||
mod playlist_manager;
|
||||
mod playlist_picker;
|
||||
@@ -686,6 +687,13 @@ fn apply_entry(
|
||||
image_cache.clone(),
|
||||
);
|
||||
}
|
||||
nav::NavEntry::OfflineManager => {
|
||||
let w2 = weak.clone();
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
w.global::<NavState>().set_view(ContentView::OfflineManager);
|
||||
});
|
||||
offline_manager::load(w2, handle.clone());
|
||||
}
|
||||
nav::NavEntry::Location {
|
||||
mbid,
|
||||
area_id,
|
||||
@@ -3255,6 +3263,143 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Offline Cache Manager actions.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = handle.clone();
|
||||
window.global::<OfflineManagerActions>().on_open(move || {
|
||||
nav::record(nav::NavEntry::OfflineManager);
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<NavState>().set_view(ContentView::OfflineManager);
|
||||
update_nav_flags(&w);
|
||||
}
|
||||
offline_manager::load(weak.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = handle.clone();
|
||||
window.global::<OfflineManagerActions>().on_refresh(move || {
|
||||
offline_manager::load(weak.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let runtime = runtime.clone();
|
||||
let handle = handle.clone();
|
||||
window
|
||||
.global::<OfflineManagerActions>()
|
||||
.on_remove_track(move |id| {
|
||||
if let Ok(tid) = id.parse::<u64>() {
|
||||
offline_cache::remove_cached(runtime.clone(), weak.clone(), handle.clone(), tid);
|
||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = handle.clone();
|
||||
window
|
||||
.global::<OfflineManagerActions>()
|
||||
.on_remove_album(move |aid| {
|
||||
offline_cache::remove_album(weak.clone(), handle.clone(), aid.to_string());
|
||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let runtime = runtime.clone();
|
||||
let handle = handle.clone();
|
||||
window
|
||||
.global::<OfflineManagerActions>()
|
||||
.on_redownload_track(move |id| {
|
||||
if let Ok(tid) = id.parse::<u64>() {
|
||||
offline_cache::redownload_track(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
tid,
|
||||
);
|
||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let runtime = runtime.clone();
|
||||
let handle = handle.clone();
|
||||
window
|
||||
.global::<OfflineManagerActions>()
|
||||
.on_redownload_album(move |aid| {
|
||||
offline_cache::redownload_album(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
aid.to_string(),
|
||||
false,
|
||||
);
|
||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let runtime = runtime.clone();
|
||||
let handle = handle.clone();
|
||||
window
|
||||
.global::<OfflineManagerActions>()
|
||||
.on_redownload_failed(move |aid| {
|
||||
offline_cache::redownload_album(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
aid.to_string(),
|
||||
true,
|
||||
);
|
||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = handle.clone();
|
||||
window
|
||||
.global::<OfflineManagerActions>()
|
||||
.on_set_limit(move |gb| {
|
||||
offline_manager::set_limit(weak.clone(), handle.clone(), gb);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = handle.clone();
|
||||
window.global::<OfflineManagerActions>().on_clear_all(move || {
|
||||
offline_cache::clear_all(weak.clone(), handle.clone());
|
||||
offline_manager::reload_soon(weak.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let handle = handle.clone();
|
||||
window
|
||||
.global::<OfflineManagerActions>()
|
||||
.on_open_folder(move || {
|
||||
offline_cache::open_folder(handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let runtime = runtime.clone();
|
||||
let handle = handle.clone();
|
||||
window
|
||||
.global::<OfflineManagerActions>()
|
||||
.on_play_track(move |id| {
|
||||
if let Ok(tid) = id.parse::<u64>() {
|
||||
playback::play_track_now(runtime.clone(), weak.clone(), handle.clone(), tid);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Scene (location) view actions — open-artist routes to the
|
||||
// artist page, load-more validates the next page of candidates.
|
||||
{
|
||||
|
||||
@@ -44,6 +44,9 @@ pub enum NavEntry {
|
||||
/// (filter/sort/view/folder-mode) is session-scoped in the
|
||||
/// controller, so the entry carries no payload.
|
||||
PlaylistManager,
|
||||
/// The Offline Cache Manager — the manage-downloads surface (Tauri's
|
||||
/// OfflineCacheManagerView). Session-scoped; no payload.
|
||||
OfflineManager,
|
||||
Album(String),
|
||||
Artist(String),
|
||||
Settings,
|
||||
|
||||
@@ -336,6 +336,177 @@ pub fn cache_playlist(
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a whole album's offline copies (rows + CMAF dirs + library rows).
|
||||
pub fn remove_album(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
album_id: String,
|
||||
) {
|
||||
handle.spawn(async move {
|
||||
let Some(off) = crate::offline::get().await else {
|
||||
return;
|
||||
};
|
||||
let report = {
|
||||
let guard = off.db.lock().await;
|
||||
let Some(db) = guard.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let root = std::path::PathBuf::from(off.get_cache_path());
|
||||
qbz_offline_cache::maintenance::remove_album_cached_tracks(db, &root, &album_id)
|
||||
};
|
||||
let report = match report {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] remove album {album_id} failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
{
|
||||
let guard = off.library_db.lock().await;
|
||||
if let Some(db) = guard.as_ref() {
|
||||
for id in &report.removed_track_ids {
|
||||
let _ = db.remove_qobuz_cached_track(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in &report.removed_track_ids {
|
||||
mark_cached(*id, false);
|
||||
}
|
||||
let ids = report.removed_track_ids.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
for id in ids {
|
||||
crate::set_row_cache_status(&w, &id.to_string(), 0, 0.0);
|
||||
}
|
||||
});
|
||||
crate::toast::success_weak(&weak, "Removed album from offline");
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-download a single track (reset its row, spawn the download). Skips
|
||||
/// in-flight downloads.
|
||||
pub fn redownload_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 {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let guard = off.db.lock().await;
|
||||
let Some(db) = guard.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if let Ok(Some(t)) = db.get_track(id) {
|
||||
if matches!(t.status, OfflineCacheStatus::Downloading) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = db.reset_track_for_redownload(id);
|
||||
}
|
||||
let file_path = off.track_file_path(id, "flac");
|
||||
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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-download an album's tracks. `failed_only` re-queues only the failed
|
||||
/// ones; otherwise all (skipping in-flight).
|
||||
pub fn redownload_album(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
album_id: String,
|
||||
failed_only: bool,
|
||||
) {
|
||||
handle.spawn(async move {
|
||||
let Some(off) = crate::offline::get().await else {
|
||||
return;
|
||||
};
|
||||
let targets: Vec<u64> = {
|
||||
let guard = off.db.lock().await;
|
||||
let Some(db) = guard.as_ref() else {
|
||||
return;
|
||||
};
|
||||
match db.get_album_tracks(&album_id) {
|
||||
Ok(tracks) => {
|
||||
let picked =
|
||||
qbz_offline_cache::maintenance::select_redownload_targets(&tracks, failed_only);
|
||||
let ids: Vec<u64> = picked.iter().map(|t| t.track_id).collect();
|
||||
for id in &ids {
|
||||
let _ = db.reset_track_for_redownload(*id);
|
||||
}
|
||||
ids
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
};
|
||||
for id in targets {
|
||||
let file_path = off.track_file_path(id, "flac");
|
||||
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(),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the offline-cache folder in the system file manager.
|
||||
pub fn open_folder(handle: tokio::runtime::Handle) {
|
||||
handle.spawn(async move {
|
||||
let Some(off) = crate::offline::get().await else {
|
||||
return;
|
||||
};
|
||||
let path = off.get_cache_path();
|
||||
let opener = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else {
|
||||
"xdg-open"
|
||||
};
|
||||
if let Err(e) = std::process::Command::new(opener).arg(&path).spawn() {
|
||||
log::warn!("[qbz-slint] open offline folder failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear the entire offline cache (DB + on-disk bundles + library rows).
|
||||
pub fn clear_all(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||
handle.spawn(async move {
|
||||
let Some(off) = crate::offline::get().await else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = qbz_offline_cache::purge_all_cached_files(&off, &off.library_db).await {
|
||||
log::error!("[qbz-slint] clear offline cache failed: {e}");
|
||||
crate::toast::error_weak(&weak, "Couldn't clear the offline cache");
|
||||
return;
|
||||
}
|
||||
if let Ok(mut s) = cached_ids().lock() {
|
||||
s.clear();
|
||||
}
|
||||
crate::toast::success_weak(&weak, "Offline cache cleared");
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a track's offline copy (DB row + on-disk bundle/file + library row).
|
||||
pub fn remove_cached(
|
||||
_runtime: Runtime,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Offline Cache Manager controller — loads the cached-tracks rollup + stats
|
||||
//! into `OfflineManagerState`. Per-item actions reuse `offline_cache::*`
|
||||
//! (Slice 3); this module owns the data load + the size-limit edit.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use qbz_offline_cache::{CachedTrackInfo, OfflineCacheStatus};
|
||||
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
|
||||
|
||||
use crate::{AppWindow, OfflineManagerState, OfflineRow};
|
||||
|
||||
const GB: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
fn human_size(bytes: u64) -> String {
|
||||
let b = bytes as f64;
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", b / GB as f64)
|
||||
} else if bytes >= 1024 * 1024 {
|
||||
format!("{:.0} MB", b / (1024.0 * 1024.0))
|
||||
} else if bytes >= 1024 {
|
||||
format!("{:.0} KB", b / 1024.0)
|
||||
} else {
|
||||
format!("{} B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn track_status_int(s: &OfflineCacheStatus) -> i32 {
|
||||
match s {
|
||||
OfflineCacheStatus::Ready => 3,
|
||||
OfflineCacheStatus::Failed => 4,
|
||||
_ => 2, // queued / downloading
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_duration(secs: u64) -> String {
|
||||
format!("{}:{:02}", secs / 60, secs % 60)
|
||||
}
|
||||
|
||||
/// Read the index.db, build the album→track rollup + stats, and push them to
|
||||
/// `OfflineManagerState`.
|
||||
async fn rebuild(weak: slint::Weak<AppWindow>) {
|
||||
let off = crate::offline::get().await;
|
||||
let (tracks, limit): (Vec<CachedTrackInfo>, Option<u64>) = match off {
|
||||
Some(ref o) => {
|
||||
let limit = *o.limit_bytes.lock().await;
|
||||
let guard = o.db.lock().await;
|
||||
let tracks = guard
|
||||
.as_ref()
|
||||
.and_then(|db| db.get_all_tracks().ok())
|
||||
.unwrap_or_default();
|
||||
(tracks, limit)
|
||||
}
|
||||
None => (Vec::new(), None),
|
||||
};
|
||||
|
||||
let total_size: u64 = tracks.iter().map(|t| t.file_size_bytes).sum();
|
||||
let tracks_count = tracks.len() as i32;
|
||||
|
||||
// Group by album_id, remembering first-seen order.
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
let mut groups: BTreeMap<String, (String, String, Vec<CachedTrackInfo>)> = BTreeMap::new();
|
||||
for t in tracks {
|
||||
let aid = t.album_id.clone().unwrap_or_else(|| "__singles__".to_string());
|
||||
if !groups.contains_key(&aid) {
|
||||
order.push(aid.clone());
|
||||
}
|
||||
let album = t.album.clone().unwrap_or_else(|| "Singles".to_string());
|
||||
groups
|
||||
.entry(aid)
|
||||
.or_insert_with(|| (album, t.artist.clone(), Vec::new()))
|
||||
.2
|
||||
.push(t);
|
||||
}
|
||||
|
||||
let mut rows: Vec<OfflineRow> = Vec::new();
|
||||
for aid in &order {
|
||||
let (album, artist, group) = groups.get(aid).unwrap();
|
||||
let any_failed = group
|
||||
.iter()
|
||||
.any(|t| matches!(t.status, OfflineCacheStatus::Failed));
|
||||
let any_active = group.iter().any(|t| {
|
||||
matches!(
|
||||
t.status,
|
||||
OfflineCacheStatus::Queued | OfflineCacheStatus::Downloading
|
||||
)
|
||||
});
|
||||
let all_ready = group
|
||||
.iter()
|
||||
.all(|t| matches!(t.status, OfflineCacheStatus::Ready));
|
||||
let album_status = if any_failed {
|
||||
4
|
||||
} else if any_active {
|
||||
2
|
||||
} else if all_ready {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let album_size: u64 = group.iter().map(|t| t.file_size_bytes).sum();
|
||||
rows.push(OfflineRow {
|
||||
kind: "album".into(),
|
||||
album_id: aid.clone().into(),
|
||||
track_id: SharedString::new(),
|
||||
title: album.clone().into(),
|
||||
subtitle: artist.clone().into(),
|
||||
meta: format!("{} tracks · {}", group.len(), human_size(album_size)).into(),
|
||||
status: album_status,
|
||||
progress: 0.0,
|
||||
});
|
||||
for t in group {
|
||||
rows.push(OfflineRow {
|
||||
kind: "track".into(),
|
||||
album_id: aid.clone().into(),
|
||||
track_id: t.track_id.to_string().into(),
|
||||
title: t.title.clone().into(),
|
||||
subtitle: t.artist.clone().into(),
|
||||
meta: fmt_duration(t.duration_secs).into(),
|
||||
status: track_status_int(&t.status),
|
||||
progress: t.progress_percent as f32 / 100.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let (limit_text, usage, limit_gb) = match limit {
|
||||
Some(l) if l > 0 => (
|
||||
format!("· of {}", human_size(l)),
|
||||
(total_size as f32 / l as f32).clamp(0.0, 1.0),
|
||||
(l / GB).max(1) as i32,
|
||||
),
|
||||
_ => ("· Unlimited".to_string(), 0.0, 5),
|
||||
};
|
||||
let size_text = human_size(total_size);
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let st = w.global::<OfflineManagerState>();
|
||||
st.set_rows(ModelRc::new(VecModel::from(rows)));
|
||||
st.set_tracks_count(tracks_count);
|
||||
st.set_size_text(SharedString::from(size_text));
|
||||
st.set_limit_text(SharedString::from(limit_text));
|
||||
st.set_usage(usage);
|
||||
st.set_limit_gb(limit_gb);
|
||||
st.set_loading(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Load (or refresh) the manager. Marks loading, then rebuilds.
|
||||
pub fn load(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
w.global::<OfflineManagerState>().set_loading(true);
|
||||
});
|
||||
handle.spawn(rebuild(weak));
|
||||
}
|
||||
|
||||
/// Rebuild after a brief delay — used after a manager mutation (remove /
|
||||
/// 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) {
|
||||
handle.spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
rebuild(weak).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the in-memory cache size limit (GB) and refresh. (Session-scoped for
|
||||
/// now — Slint-side persistence is a follow-up.)
|
||||
pub fn set_limit(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, gb: i32) {
|
||||
handle.spawn(async move {
|
||||
if let Some(off) = crate::offline::get().await {
|
||||
*off.limit_bytes.lock().await = Some((gb.max(1) as u64) * GB);
|
||||
}
|
||||
rebuild(weak).await;
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
|
||||
import { Toast } from "primitives/Toast.slint";
|
||||
|
||||
// Re-export the state globals so the Rust layer can populate them.
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, FolderEditState, FolderEditActions, ToastState } from "state.slint";
|
||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, FolderEditState, FolderEditActions, ToastState } from "state.slint";
|
||||
|
||||
// Which top-level screen is shown. The app starts on `splash` while it
|
||||
// restores a saved session, then resolves to `shell` or `login`.
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// Offline Cache Manager — the standalone manage-downloads surface (Tauri's
|
||||
// OfflineCacheManagerView). Fixed header (Back + title) + a stats bar (totals
|
||||
// / usage bar / edit-limit / open-folder / clear-all) over a scrolling list
|
||||
// of album-header + track rows (per-row remove / re-download).
|
||||
//
|
||||
// Rows are precomputed in Rust (crate::offline_manager) and pushed as a flat
|
||||
// [OfflineRow] model; actions go through OfflineManagerActions.
|
||||
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { OfflineManagerState, OfflineManagerActions, OfflineRow } from "../state.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
|
||||
// A small ghost icon button.
|
||||
component IconBtn inherits Rectangle {
|
||||
in property <image> icon;
|
||||
in property <color> tint: Theme.text-muted;
|
||||
callback clicked;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: Radius.sm;
|
||||
background: ta.has-hover ? Theme.surface-elevated : transparent;
|
||||
QbzIcon {
|
||||
source: root.icon;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: ta.has-hover ? Theme.text-primary : root.tint;
|
||||
}
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cache-status glyph: 2 downloading (spinning loader) / 3 ready (check) /
|
||||
// 4 failed (alert) / else cloud.
|
||||
component StatusIcon inherits Rectangle {
|
||||
in property <int> status;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
if root.status == 2: Image {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
source: @image-url("../assets/icons/loader-circle.svg");
|
||||
colorize: Theme.accent;
|
||||
transform-rotation: ((animation-tick() / 1ms) * 0.36) * 1deg;
|
||||
}
|
||||
if root.status != 2: QbzIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
source: root.status == 3
|
||||
? @image-url("../assets/icons/circle-check-big.svg")
|
||||
: (root.status == 4
|
||||
? @image-url("../assets/icons/triangle-alert.svg")
|
||||
: @image-url("../assets/icons/cloud-download.svg"));
|
||||
tint: root.status == 3 ? Theme.accent : (root.status == 4 ? #e0564f : Theme.text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
export component OfflineManagerView inherits Rectangle {
|
||||
background: Theme.surface-main;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 24px;
|
||||
spacing: 16px;
|
||||
|
||||
// Header — back + title.
|
||||
HorizontalLayout {
|
||||
vertical-stretch: 0;
|
||||
spacing: 12px;
|
||||
NavButtons { }
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: "Offline Cache";
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.title;
|
||||
font-weight: Typography.bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats bar.
|
||||
Rectangle {
|
||||
vertical-stretch: 0;
|
||||
height: 76px;
|
||||
border-radius: Radius.md;
|
||||
background: Theme.surface-elevated;
|
||||
HorizontalLayout {
|
||||
padding: 16px;
|
||||
spacing: 20px;
|
||||
// Totals.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: OfflineManagerState.tracks-count + " tracks";
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.semibold;
|
||||
}
|
||||
Text {
|
||||
text: OfflineManagerState.size-text + " " + OfflineManagerState.limit-text;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
}
|
||||
}
|
||||
// Usage bar.
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: Theme.surface-main;
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
height: parent.height;
|
||||
border-radius: 4px;
|
||||
width: parent.width * Math.clamp(OfflineManagerState.usage, 0.0, 1.0);
|
||||
background: OfflineManagerState.usage >= 1.0 ? #e0564f : Theme.accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Edit limit (GB).
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
Rectangle {
|
||||
width: 58px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
background: Theme.surface-main;
|
||||
border-width: 1px;
|
||||
border-color: limit-in.has-focus ? Theme.accent : Theme.border-subtle;
|
||||
limit-in := TextInput {
|
||||
x: 8px;
|
||||
width: parent.width - 16px;
|
||||
height: 100%;
|
||||
vertical-alignment: center;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
input-type: number;
|
||||
text: OfflineManagerState.limit-gb;
|
||||
accepted => {
|
||||
OfflineManagerActions.set-limit(self.text.to-float());
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: "GB";
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
}
|
||||
}
|
||||
IconBtn {
|
||||
icon: @image-url("../assets/icons/check.svg");
|
||||
clicked => {
|
||||
OfflineManagerActions.set-limit(limit-in.text.to-float());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Open folder + clear all.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
IconBtn {
|
||||
icon: @image-url("../assets/icons/folder.svg");
|
||||
clicked => {
|
||||
OfflineManagerActions.open-folder();
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
IconBtn {
|
||||
icon: @image-url("../assets/icons/trash-2.svg");
|
||||
tint: #e0564f;
|
||||
clicked => {
|
||||
OfflineManagerActions.clear-all();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading / empty / list.
|
||||
if OfflineManagerState.loading: VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { }
|
||||
}
|
||||
}
|
||||
if !OfflineManagerState.loading && OfflineManagerState.rows.length == 0: VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: "No offline tracks yet";
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
if !OfflineManagerState.loading && OfflineManagerState.rows.length > 0: ScrollView {
|
||||
VerticalLayout {
|
||||
spacing: 2px;
|
||||
for row in OfflineManagerState.rows: Rectangle {
|
||||
height: row.kind == "album" ? 58px : 44px;
|
||||
border-radius: 8px;
|
||||
background: row.kind == "album" ? Theme.surface-card : transparent;
|
||||
HorizontalLayout {
|
||||
padding-left: row.kind == "album" ? 12px : 28px;
|
||||
padding-right: 12px;
|
||||
spacing: 12px;
|
||||
// Status glyph.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
StatusIcon { status: row.status; }
|
||||
}
|
||||
// Title + subtitle.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
spacing: 2px;
|
||||
Text {
|
||||
text: row.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: row.kind == "album" ? Typography.body : Typography.legal;
|
||||
font-weight: row.kind == "album" ? Typography.semibold : Typography.regular;
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
text: row.subtitle;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
// Meta.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: row.meta;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: right;
|
||||
}
|
||||
}
|
||||
// Re-download.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
IconBtn {
|
||||
icon: @image-url("../assets/icons/refresh-cw.svg");
|
||||
clicked => {
|
||||
if (row.kind == "album") {
|
||||
OfflineManagerActions.redownload-album(row.album-id);
|
||||
} else {
|
||||
OfflineManagerActions.redownload-track(row.track-id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
IconBtn {
|
||||
icon: @image-url("../assets/icons/trash-2.svg");
|
||||
clicked => {
|
||||
if (row.kind == "album") {
|
||||
OfflineManagerActions.remove-album(row.album-id);
|
||||
} else {
|
||||
OfflineManagerActions.remove-track(row.track-id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Settings > Offline Playback (cache half). Launches the Offline Cache
|
||||
// Manager and offers clear-all / open-folder. The full per-album/track
|
||||
// management lives in the manager; the Local-Library-dependent rows
|
||||
// (show-in-library, repair) arrive with the Local Library port.
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { OfflineManagerActions } from "../state.slint";
|
||||
|
||||
component Btn inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> danger: false;
|
||||
callback clicked();
|
||||
width: 184px;
|
||||
height: 36px;
|
||||
border-radius: Radius.sm;
|
||||
border-width: 1px;
|
||||
border-color: root.danger ? #e0564f : Theme.border-subtle;
|
||||
background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
Text {
|
||||
text: root.label;
|
||||
color: root.danger ? #e0564f : Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component OfflineSettings inherits VerticalLayout {
|
||||
spacing: 16px;
|
||||
padding: 4px;
|
||||
Text {
|
||||
text: @tr("Offline Playback");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.title;
|
||||
font-weight: Typography.bold;
|
||||
}
|
||||
Text {
|
||||
text: @tr("Cache Qobuz tracks for offline listening while your subscription is active. Manage what's downloaded, free up space, or change the cache size limit in the manager.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
alignment: start;
|
||||
Btn {
|
||||
label: @tr("Manage offline cache");
|
||||
clicked => {
|
||||
OfflineManagerActions.open();
|
||||
}
|
||||
}
|
||||
Btn {
|
||||
label: @tr("Open cache folder");
|
||||
clicked => {
|
||||
OfflineManagerActions.open-folder();
|
||||
}
|
||||
}
|
||||
Btn {
|
||||
label: @tr("Clear all");
|
||||
danger: true;
|
||||
clicked => {
|
||||
OfflineManagerActions.clear-all();
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { } // spacer
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { AudioSettings } from "AudioSettings.slint";
|
||||
import { PlaybackSettings } from "PlaybackSettings.slint";
|
||||
import { AppearanceSettings } from "AppearanceSettings.slint";
|
||||
import { OfflineSettings } from "OfflineSettings.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
|
||||
component SubNavItem inherits Rectangle {
|
||||
@@ -130,6 +131,14 @@ export component SettingsView inherits Rectangle {
|
||||
SettingsState.section = 2;
|
||||
}
|
||||
}
|
||||
SubNavItem {
|
||||
icon: @image-url("../assets/icons/cloud-download.svg");
|
||||
label: @tr("Offline");
|
||||
active: SettingsState.section == 3;
|
||||
clicked => {
|
||||
SettingsState.section = 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +185,7 @@ export component SettingsView inherits Rectangle {
|
||||
// AppearanceState and emit its own callbacks, so no
|
||||
// SettingsView-level callback forwarding is needed here.
|
||||
if !SettingsState.loading && SettingsState.section == 2: AppearanceSettings { }
|
||||
if !SettingsState.loading && SettingsState.section == 3: OfflineSettings { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { FavoritesView } from "../favorites/FavoritesView.slint";
|
||||
import { MixView } from "../mix/MixView.slint";
|
||||
import { PlaylistView } from "../playlist/PlaylistView.slint";
|
||||
import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint";
|
||||
import { OfflineManagerView } from "../offline/OfflineManagerView.slint";
|
||||
import { SettingsView } from "../settings/SettingsView.slint";
|
||||
import { SearchResultsView } from "../search/SearchResultsView.slint";
|
||||
|
||||
@@ -169,6 +170,7 @@ export component AppShell inherits Rectangle {
|
||||
}
|
||||
}
|
||||
if NavState.view == ContentView.playlist-manager: PlaylistManagerView { }
|
||||
if NavState.view == ContentView.offline-manager: OfflineManagerView { }
|
||||
if NavState.view == ContentView.mix: MixView {
|
||||
play-track(id) => {
|
||||
root.media-action("mix-track", id, "play");
|
||||
|
||||
@@ -323,7 +323,7 @@ export global DragActions {
|
||||
}
|
||||
|
||||
// Which content view the shell shows.
|
||||
export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager }
|
||||
export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager }
|
||||
|
||||
// In-app toast (transient notification). Mirrors Tauri's toastStore:
|
||||
// a single toast at a time (a new one replaces the current), auto-hidden
|
||||
@@ -657,6 +657,48 @@ export global PlaylistManagerActions {
|
||||
callback move-to-folder(string /* playlist id */, string /* folder id */);
|
||||
}
|
||||
|
||||
// === Offline Cache Manager =============================================
|
||||
// The standalone manage-downloads surface (Tauri's OfflineCacheManagerView).
|
||||
// Rows are precomputed in Rust (crate::offline_manager): an album-header row
|
||||
// followed by its track rows, flattened (mirrors PmTreeRow).
|
||||
export struct OfflineRow {
|
||||
kind: string, // "album" | "track"
|
||||
album-id: string, // album-header: the album id; track: its album id
|
||||
track-id: string, // track rows
|
||||
title: string, // album title (album) | track title (track)
|
||||
subtitle: string, // artist
|
||||
meta: string, // "8 tracks · 320 MB" (album) | "3:45" (track)
|
||||
// 0 none | 2 downloading | 3 ready | 4 failed. Album = worst across tracks.
|
||||
status: int,
|
||||
progress: float, // track downloading 0..1
|
||||
}
|
||||
|
||||
export global OfflineManagerState {
|
||||
in property <[OfflineRow]> rows: [];
|
||||
in property <bool> loading: false;
|
||||
// Header stats.
|
||||
in property <int> tracks-count: 0;
|
||||
in property <string> size-text: ""; // "1.2 GB"
|
||||
in property <string> limit-text: ""; // "of 5 GB" | "Unlimited"
|
||||
in property <float> usage: 0.0; // 0..1 for the bar (0 = unlimited)
|
||||
in-out property <int> limit-gb: 5; // edit-limit input
|
||||
}
|
||||
|
||||
export global OfflineManagerActions {
|
||||
// Navigate to the manager (from the Settings "Offline Playback" entry).
|
||||
callback open();
|
||||
callback refresh();
|
||||
callback remove-track(string /* track id */);
|
||||
callback remove-album(string /* album id */);
|
||||
callback redownload-track(string /* track id */);
|
||||
callback redownload-album(string /* album id */);
|
||||
callback redownload-failed(string /* album id */);
|
||||
callback set-limit(int /* gb */);
|
||||
callback clear-all();
|
||||
callback open-folder();
|
||||
callback play-track(string /* track id */);
|
||||
}
|
||||
|
||||
// Folder editor modal — create + edit (icon preset, solid color, custom
|
||||
// image, hidden toggle, delete). Ports Tauri's FolderEditModal. Gradient
|
||||
// color presets are intentionally omitted (Slint brush limitation).
|
||||
|
||||
Reference in new issue
Block a user