feat(slint): offline manager — checkboxes + bulk, status to the end, track numbers

Per user feedback:
- Multi-select: a checkbox on every track row + "Select all"/"Clear" toggle +
  a bulk bar ("N selected" / Re-download / Remove). Selection is an in-place
  model edit (offline_manager::toggle_select / set_all_selected) with a live
  selected-count; bulk reads the checked ids and runs redownload_track /
  remove_cached per track.
- Moved the status glyph to the END of the row (albums and tracks) — it read
  as "missing art" at the leading edge under the album-grouping model.
- Track rows now show their position number in the leading slot (filling what
  looked empty). (Position within the album; the catalog track number isn't in
  the cache index.db — a follow-up if the real number is wanted.)

qbz-slint compiles clean.

cc
This commit is contained in:
vicrodh committed 2026-05-28 12:23:46 -06:00
1 parent db3d8491f5
commit 2b6c71000f
4 files changed
+267 -12

No files matched your search

+68
View File
@@ -3313,6 +3313,74 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
offline_manager::toggle_failed(weak.clone(), handle.clone());
});
}
{
let weak = window.as_weak();
window
.global::<OfflineManagerActions>()
.on_toggle_select(move |id| {
if let Some(w) = weak.upgrade() {
offline_manager::toggle_select(&w, &id);
}
});
}
{
let weak = window.as_weak();
window
.global::<OfflineManagerActions>()
.on_select_all(move || {
if let Some(w) = weak.upgrade() {
offline_manager::set_all_selected(&w, true);
}
});
}
{
let weak = window.as_weak();
window
.global::<OfflineManagerActions>()
.on_clear_selection(move || {
if let Some(w) = weak.upgrade() {
offline_manager::set_all_selected(&w, false);
}
});
}
{
let weak = window.as_weak();
let runtime = runtime.clone();
let handle = handle.clone();
window
.global::<OfflineManagerActions>()
.on_bulk_redownload(move || {
if let Some(w) = weak.upgrade() {
for id in offline_manager::selected_track_ids(&w) {
offline_cache::redownload_track(
runtime.clone(),
weak.clone(),
handle.clone(),
id,
);
}
}
});
}
{
let weak = window.as_weak();
let runtime = runtime.clone();
let handle = handle.clone();
window
.global::<OfflineManagerActions>()
.on_bulk_remove(move || {
if let Some(w) = weak.upgrade() {
for id in offline_manager::selected_track_ids(&w) {
offline_cache::remove_cached(
runtime.clone(),
weak.clone(),
handle.clone(),
id,
);
}
}
});
}
{
let weak = window.as_weak();
let runtime = runtime.clone();
+73 -2
View File
@@ -7,7 +7,7 @@ use std::collections::BTreeMap;
use std::sync::{Mutex as StdMutex, OnceLock};
use qbz_offline_cache::{CachedTrackInfo, OfflineCacheStatus};
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
use crate::{AppWindow, OfflineArtist, OfflineManagerState, OfflineRow};
@@ -112,6 +112,7 @@ struct RowData {
status: i32,
progress: f32,
cover_path: String,
number: String,
}
// --- Data load ----------------------------------------------------------
@@ -227,8 +228,9 @@ pub async fn rebuild(weak: slint::Weak<AppWindow>) {
status: album_status,
progress: 0.0,
cover_path,
number: String::new(),
});
for t in group {
for (i, t) in group.iter().enumerate() {
if f.show_only_failed && !matches!(t.status, OfflineCacheStatus::Failed) {
continue;
}
@@ -242,6 +244,7 @@ pub async fn rebuild(weak: slint::Weak<AppWindow>) {
status: track_status_int(&t.status),
progress: t.progress_percent as f32 / 100.0,
cover_path: String::new(),
number: (i + 1).to_string(),
});
}
}
@@ -272,9 +275,12 @@ pub async fn rebuild(weak: slint::Weak<AppWindow>) {
status: rd.status,
progress: rd.progress,
cover: load_cover(&rd.cover_path),
number: rd.number.into(),
selected: false,
})
.collect();
st.set_rows(ModelRc::new(VecModel::from(offline_rows)));
st.set_selected_count(0);
st.set_artists(ModelRc::new(VecModel::from(artists)));
st.set_tracks_count(tracks_count);
st.set_size_text(SharedString::from(size_text));
@@ -319,6 +325,71 @@ pub fn toggle_failed(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handl
handle.spawn(rebuild(weak));
}
// --- Multi-select (in-place model edits on the UI thread) ---------------
fn recount(st: &OfflineManagerState) {
let model = st.get_rows();
let mut n = 0;
for i in 0..model.row_count() {
if let Some(r) = model.row_data(i) {
if r.kind == "track" && r.selected {
n += 1;
}
}
}
st.set_selected_count(n);
}
/// Flip a single track row's checkbox in place.
pub fn toggle_select(w: &AppWindow, track_id: &str) {
let st = w.global::<OfflineManagerState>();
let model = st.get_rows();
if let Some(vm) = model.as_any().downcast_ref::<VecModel<OfflineRow>>() {
for i in 0..vm.row_count() {
if let Some(mut r) = vm.row_data(i) {
if r.kind == "track" && r.track_id == track_id {
r.selected = !r.selected;
vm.set_row_data(i, r);
}
}
}
}
recount(&st);
}
/// Check (or uncheck) every track row.
pub fn set_all_selected(w: &AppWindow, selected: bool) {
let st = w.global::<OfflineManagerState>();
let model = st.get_rows();
if let Some(vm) = model.as_any().downcast_ref::<VecModel<OfflineRow>>() {
for i in 0..vm.row_count() {
if let Some(mut r) = vm.row_data(i) {
if r.kind == "track" && r.selected != selected {
r.selected = selected;
vm.set_row_data(i, r);
}
}
}
}
recount(&st);
}
/// The Qobuz ids of the checked track rows (for the bulk actions).
pub fn selected_track_ids(w: &AppWindow) -> Vec<u64> {
let model = w.global::<OfflineManagerState>().get_rows();
let mut ids = Vec::new();
for i in 0..model.row_count() {
if let Some(r) = model.row_data(i) {
if r.kind == "track" && r.selected {
if let Ok(id) = r.track_id.parse::<u64>() {
ids.push(id);
}
}
}
}
ids
}
/// 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) {
handle.spawn(async move {
@@ -109,6 +109,33 @@ component ArtistRailRow inherits Rectangle {
}
}
// A small labelled ghost button (toolbar / bulk actions).
component TxtBtn inherits Rectangle {
in property <string> label;
in property <bool> danger: false;
callback clicked;
width: lbl.preferred-width + 24px;
height: 32px;
border-radius: Radius.sm;
border-width: 1px;
border-color: root.danger ? #e0564f : Theme.border-subtle;
background: ta.has-hover ? Theme.surface-elevated : transparent;
lbl := Text {
x: 12px;
text: root.label;
color: root.danger ? #e0564f : Theme.text-secondary;
font-size: Typography.legal;
vertical-alignment: center;
height: 100%;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component OfflineManagerView inherits Rectangle {
background: Theme.surface-main;
@@ -272,7 +299,45 @@ export component OfflineManagerView inherits Rectangle {
}
}
}
Rectangle { } // spacer
Rectangle {
horizontal-stretch: 1;
} // spacer
// Bulk actions on the checked tracks.
if OfflineManagerState.selected-count > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
Text {
text: OfflineManagerState.selected-count + @tr(" selected");
color: Theme.text-muted;
font-size: Typography.legal;
}
}
TxtBtn {
label: @tr("Re-download");
clicked => {
OfflineManagerActions.bulk-redownload();
}
}
TxtBtn {
label: @tr("Remove");
danger: true;
clicked => {
OfflineManagerActions.bulk-remove();
}
}
}
// Select all / clear toggle.
TxtBtn {
label: OfflineManagerState.selected-count > 0 ? @tr("Clear") : @tr("Select all");
clicked => {
if (OfflineManagerState.selected-count > 0) {
OfflineManagerActions.clear-selection();
} else {
OfflineManagerActions.select-all();
}
}
}
}
// Body — artist rail + rows.
@@ -336,13 +401,41 @@ export component OfflineManagerView inherits Rectangle {
border-radius: 8px;
background: row.kind == "album" ? Theme.surface-card : transparent;
HorizontalLayout {
padding-left: row.kind == "album" ? 10px : 28px;
padding-left: row.kind == "album" ? 10px : 16px;
padding-right: 12px;
spacing: 12px;
// Album cover (album rows only).
if row.kind == "album": VerticalLayout {
// Selection checkbox (track rows) / spacer (albums).
VerticalLayout {
alignment: center;
Rectangle {
width: 16px;
height: 16px;
border-radius: 4px;
background: row.selected ? Theme.accent : transparent;
border-width: (row.kind == "track" && !row.selected) ? 1.5px : 0px;
border-color: Theme.text-muted;
visible: row.kind == "track";
QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 10px;
height: 10px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #ffffff;
visible: row.selected;
}
TouchArea {
mouse-cursor: pointer;
clicked => {
OfflineManagerActions.toggle-select(row.track-id);
}
}
}
}
// Leading — album cover OR track number.
VerticalLayout {
alignment: center;
if row.kind == "album": Rectangle {
width: 40px;
height: 40px;
border-radius: 4px;
@@ -355,12 +448,18 @@ export component OfflineManagerView inherits Rectangle {
image-fit: cover;
}
}
}
// Status glyph.
VerticalLayout {
alignment: center;
StatusIcon {
status: row.status;
if row.kind == "track": Rectangle {
width: 40px;
height: 40px;
Text {
width: 100%;
height: 100%;
text: row.number;
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// Title + subtitle.
@@ -420,6 +519,13 @@ export component OfflineManagerView inherits Rectangle {
}
}
}
// Status — at the end of the row (both kinds).
VerticalLayout {
alignment: center;
StatusIcon {
status: row.status;
}
}
}
}
}
+10
View File
@@ -672,6 +672,8 @@ export struct OfflineRow {
status: int,
progress: float, // track downloading 0..1
cover: image, // album-header cover (loaded from the on-disk cover.jpg)
number: string, // track position within its album ("" on album rows)
selected: bool, // multi-select checkbox
}
// One entry in the manager's left A-Z artist rail.
@@ -696,6 +698,8 @@ export global OfflineManagerState {
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;
// Multi-select: how many TRACK rows are checked (drives the bulk bar).
in property <int> selected-count: 0;
}
export global OfflineManagerActions {
@@ -706,6 +710,12 @@ export global OfflineManagerActions {
callback select-artist(string /* name, "" = all */);
callback set-sort(int /* index */);
callback toggle-failed();
// Multi-select.
callback toggle-select(string /* track id */);
callback select-all();
callback clear-selection();
callback bulk-redownload();
callback bulk-remove();
callback remove-track(string /* track id */);
callback remove-album(string /* album id */);
callback redownload-track(string /* track id */);