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:
vicrodh committed 2026-05-28 11:23:55 -06:00
1 parent 8c23aad8a8
commit 3e0a718e0c
10 files changed
+922 -2

No files matched your search

+145
View File
@@ -45,6 +45,7 @@ mod folders;
mod library_db; mod library_db;
mod offline; mod offline;
mod offline_cache; mod offline_cache;
mod offline_manager;
mod playlist; mod playlist;
mod playlist_manager; mod playlist_manager;
mod playlist_picker; mod playlist_picker;
@@ -686,6 +687,13 @@ fn apply_entry(
image_cache.clone(), 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 { nav::NavEntry::Location {
mbid, mbid,
area_id, 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 // Scene (location) view actions — open-artist routes to the
// artist page, load-more validates the next page of candidates. // artist page, load-more validates the next page of candidates.
{ {
+3
View File
@@ -44,6 +44,9 @@ pub enum NavEntry {
/// (filter/sort/view/folder-mode) is session-scoped in the /// (filter/sort/view/folder-mode) is session-scoped in the
/// controller, so the entry carries no payload. /// controller, so the entry carries no payload.
PlaylistManager, PlaylistManager,
/// The Offline Cache Manager — the manage-downloads surface (Tauri's
/// OfflineCacheManagerView). Session-scoped; no payload.
OfflineManager,
Album(String), Album(String),
Artist(String), Artist(String),
Settings, Settings,
+171
View File
@@ -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). /// Remove a track's offline copy (DB row + on-disk bundle/file + library row).
pub fn remove_cached( pub fn remove_cached(
_runtime: Runtime, _runtime: Runtime,
+172
View File
@@ -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;
});
}
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint"; import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them. // 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 // Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`. // 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 { AudioSettings } from "AudioSettings.slint";
import { PlaybackSettings } from "PlaybackSettings.slint"; import { PlaybackSettings } from "PlaybackSettings.slint";
import { AppearanceSettings } from "AppearanceSettings.slint"; import { AppearanceSettings } from "AppearanceSettings.slint";
import { OfflineSettings } from "OfflineSettings.slint";
import { ScrollView } from "std-widgets.slint"; import { ScrollView } from "std-widgets.slint";
component SubNavItem inherits Rectangle { component SubNavItem inherits Rectangle {
@@ -130,6 +131,14 @@ export component SettingsView inherits Rectangle {
SettingsState.section = 2; 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 // AppearanceState and emit its own callbacks, so no
// SettingsView-level callback forwarding is needed here. // SettingsView-level callback forwarding is needed here.
if !SettingsState.loading && SettingsState.section == 2: AppearanceSettings { } if !SettingsState.loading && SettingsState.section == 2: AppearanceSettings { }
if !SettingsState.loading && SettingsState.section == 3: OfflineSettings { }
} }
} }
} }
+2
View File
@@ -31,6 +31,7 @@ import { FavoritesView } from "../favorites/FavoritesView.slint";
import { MixView } from "../mix/MixView.slint"; import { MixView } from "../mix/MixView.slint";
import { PlaylistView } from "../playlist/PlaylistView.slint"; import { PlaylistView } from "../playlist/PlaylistView.slint";
import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint"; import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint";
import { OfflineManagerView } from "../offline/OfflineManagerView.slint";
import { SettingsView } from "../settings/SettingsView.slint"; import { SettingsView } from "../settings/SettingsView.slint";
import { SearchResultsView } from "../search/SearchResultsView.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.playlist-manager: PlaylistManagerView { }
if NavState.view == ContentView.offline-manager: OfflineManagerView { }
if NavState.view == ContentView.mix: MixView { if NavState.view == ContentView.mix: MixView {
play-track(id) => { play-track(id) => {
root.media-action("mix-track", id, "play"); root.media-action("mix-track", id, "play");
+43 -1
View File
@@ -323,7 +323,7 @@ export global DragActions {
} }
// Which content view the shell shows. // 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: // In-app toast (transient notification). Mirrors Tauri's toastStore:
// a single toast at a time (a new one replaces the current), auto-hidden // 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 */); 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 // Folder editor modal — create + edit (icon preset, solid color, custom
// image, hidden toggle, delete). Ports Tauri's FolderEditModal. Gradient // image, hidden toggle, delete). Ports Tauri's FolderEditModal. Gradient
// color presets are intentionally omitted (Slint brush limitation). // color presets are intentionally omitted (Slint brush limitation).