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:
4 files changed
+267
-12
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */);
|
||||
|
||||
Reference in new issue
Block a user