feat(slint): offline manager 1:1 — covers, artist rail, sort, failed-only, persisted limit

Closes the v1 gaps in the offline manager toward Tauri parity:

- Album COVERS: rows load the on-disk cover.jpg (per album). Built Send-safely
  — the worker passes the cover PATH (string), the image is decoded on the UI
  thread (slint::Image is not Send).
- Two-pane layout with an A-Z ARTIST RAIL (left, "All artists" + per-artist
  "N albums · M tracks", selectable) + the discography list (right).
- SORT dropdown (A-Z / Recent / Largest / Smallest; Recent uses the DB's
  last_accessed_at DESC order) and a "Failed only" toggle. Filter state lives
  Rust-side (offline_manager) so rebuild can read it off-thread; mirrored back
  onto OfflineManagerState for the UI.
- Limit now PERSISTS to disk (<audio>/offline_limit), restored on activate via
  apply_persisted_limit; the manager's edit-limit writes it.
- Manager mutations (remove/redownload/clear) now rebuild on completion
  (offline_manager::rebuild made pub, called at the end of each cache op) —
  the 500ms timed reload hack is gone.

Deferred (user-accepted): the "track no longer available on Qobuz" case —
offline copy still plays, but warn it can't be re-downloaded / won't work on
Qobuz Connect.

qbz-slint compiles clean.

cc
This commit is contained in:
vicrodh committed 2026-05-28 11:52:55 -06:00
1 parent 3e0a718e0c
commit db3d8491f5
6 files changed
+509 -147

No files matched your search

@@ -1,17 +1,20 @@
// 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).
// OfflineCacheManagerView). Header (Back + title) + a stats bar (totals /
// usage bar / edit-limit / open-folder / clear-all) + a toolbar (sort
// dropdown / show-only-failed) over a two-pane body: a left A-Z artist rail
// and a right scrolling list of album-header (with cover) + track rows
// (per-row remove / re-download).
//
// Rows are precomputed in Rust (crate::offline_manager) and pushed as a flat
// [OfflineRow] model; actions go through OfflineManagerActions.
// Rows + artists are precomputed in Rust (crate::offline_manager) and pushed
// as models; 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 { OfflineManagerState, OfflineManagerActions, OfflineRow, OfflineArtist } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { NavButtons } from "../shell/NavButtons.slint";
@@ -40,8 +43,7 @@ component IconBtn inherits Rectangle {
}
}
// The cache-status glyph: 2 downloading (spinning loader) / 3 ready (check) /
// 4 failed (alert) / else cloud.
// The cache-status glyph (downloading spins).
component StatusIcon inherits Rectangle {
in property <int> status;
width: 18px;
@@ -69,14 +71,52 @@ component StatusIcon inherits Rectangle {
}
}
// One artist-rail row.
component ArtistRailRow inherits Rectangle {
in property <string> name;
in property <string> meta;
in property <bool> active;
callback clicked;
height: 48px;
border-radius: 8px;
background: root.active ? Theme.surface-elevated : (ta.has-hover ? #ffffff10 : transparent);
HorizontalLayout {
padding-left: 12px;
padding-right: 8px;
VerticalLayout {
alignment: center;
spacing: 2px;
Text {
text: root.name;
color: root.active ? Theme.accent : Theme.text-primary;
font-size: Typography.legal;
font-weight: root.active ? Typography.semibold : Typography.regular;
overflow: elide;
}
Text {
text: root.meta;
color: Theme.text-muted;
font-size: 11px;
overflow: elide;
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component OfflineManagerView inherits Rectangle {
background: Theme.surface-main;
VerticalLayout {
padding: 24px;
spacing: 16px;
spacing: 14px;
// Header — back + title.
// Header.
HorizontalLayout {
vertical-stretch: 0;
spacing: 12px;
@@ -101,7 +141,6 @@ export component OfflineManagerView inherits Rectangle {
HorizontalLayout {
padding: 16px;
spacing: 20px;
// Totals.
VerticalLayout {
alignment: center;
Text {
@@ -116,7 +155,6 @@ export component OfflineManagerView inherits Rectangle {
font-size: Typography.legal;
}
}
// Usage bar.
Rectangle {
horizontal-stretch: 1;
VerticalLayout {
@@ -136,7 +174,6 @@ export component OfflineManagerView inherits Rectangle {
}
}
}
// Edit limit (GB).
VerticalLayout {
alignment: center;
HorizontalLayout {
@@ -178,7 +215,6 @@ export component OfflineManagerView inherits Rectangle {
}
}
}
// Open folder + clear all.
VerticalLayout {
alignment: center;
IconBtn {
@@ -201,92 +237,187 @@ export component OfflineManagerView inherits Rectangle {
}
}
// Loading / empty / list.
if OfflineManagerState.loading: VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: center;
LoadingSpinner { }
// Toolbar — sort + show-only-failed.
HorizontalLayout {
vertical-stretch: 0;
spacing: 12px;
QbzSelect {
menu-width: 160px;
sm: true;
options: [@tr("Sort: A-Z"), @tr("Sort: Recent"), @tr("Sort: Largest"), @tr("Sort: Smallest")];
current-index: OfflineManagerState.sort-index;
selected(i) => {
OfflineManagerActions.set-sort(i);
}
}
}
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;
Rectangle {
width: failed-label.preferred-width + 28px;
height: 32px;
border-radius: Radius.sm;
border-width: 1px;
border-color: OfflineManagerState.show-only-failed ? Theme.accent : Theme.border-subtle;
background: OfflineManagerState.show-only-failed ? #e0564f22 : (failed-ta.has-hover ? Theme.surface-elevated : transparent);
failed-label := Text {
x: 14px;
text: @tr("Failed only");
color: OfflineManagerState.show-only-failed ? #e0564f : Theme.text-secondary;
font-size: Typography.legal;
vertical-alignment: center;
height: 100%;
}
failed-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
OfflineManagerActions.toggle-failed();
}
}
}
Rectangle { } // spacer
}
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;
// Body — artist rail + rows.
HorizontalLayout {
vertical-stretch: 1;
spacing: 14px;
// Left A-Z artist rail.
Rectangle {
width: 210px;
border-radius: Radius.md;
background: Theme.surface-card;
ScrollView {
VerticalLayout {
padding: 6px;
spacing: 2px;
ArtistRailRow {
name: @tr("All artists");
meta: OfflineManagerState.tracks-count + " tracks";
active: OfflineManagerState.selected-artist == "";
clicked => {
OfflineManagerActions.select-artist("");
}
}
for a in OfflineManagerState.artists: ArtistRailRow {
name: a.name;
meta: a.meta;
active: a.selected;
clicked => {
OfflineManagerActions.select-artist(a.name);
}
}
}
}
}
// Right list.
Rectangle {
horizontal-stretch: 1;
if OfflineManagerState.loading: VerticalLayout {
alignment: center;
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);
alignment: center;
LoadingSpinner { }
}
}
if !OfflineManagerState.loading && OfflineManagerState.rows.length == 0: VerticalLayout {
alignment: center;
Text {
text: @tr("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" ? 60px : 44px;
border-radius: 8px;
background: row.kind == "album" ? Theme.surface-card : transparent;
HorizontalLayout {
padding-left: row.kind == "album" ? 10px : 28px;
padding-right: 12px;
spacing: 12px;
// Album cover (album rows only).
if row.kind == "album": VerticalLayout {
alignment: center;
Rectangle {
width: 40px;
height: 40px;
border-radius: 4px;
clip: true;
background: Theme.surface-main;
Image {
width: 100%;
height: 100%;
source: row.cover;
image-fit: cover;
}
}
}
}
}
// 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);
// 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);
}
}
}
}
}
+18
View File
@@ -671,10 +671,19 @@ export struct OfflineRow {
// 0 none | 2 downloading | 3 ready | 4 failed. Album = worst across tracks.
status: int,
progress: float, // track downloading 0..1
cover: image, // album-header cover (loaded from the on-disk cover.jpg)
}
// One entry in the manager's left A-Z artist rail.
export struct OfflineArtist {
name: string,
meta: string, // "3 albums · 21 tracks"
selected: bool,
}
export global OfflineManagerState {
in property <[OfflineRow]> rows: [];
in property <[OfflineArtist]> artists: [];
in property <bool> loading: false;
// Header stats.
in property <int> tracks-count: 0;
@@ -682,12 +691,21 @@ export global OfflineManagerState {
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
// Toolbar — set by the controller from the Rust-side filter state so the
// dropdown selection / toggle / rail highlight reflect the current view.
in property <string> selected-artist: ""; // "" = all artists
in property <int> sort-index: 0; // 0 alpha / 1 recent / 2 largest / 3 smallest
in property <bool> show-only-failed: false;
}
export global OfflineManagerActions {
// Navigate to the manager (from the Settings "Offline Playback" entry).
callback open();
callback refresh();
// Toolbar.
callback select-artist(string /* name, "" = all */);
callback set-sort(int /* index */);
callback toggle-failed();
callback remove-track(string /* track id */);
callback remove-album(string /* album id */);
callback redownload-track(string /* track id */);