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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */);
|
||||
|
||||
Reference in new issue
Block a user