Files
qbz/crates/qbz-qt/qml/views/OfflineManagerView.qml
T
vicrodh 41c1b50d61 feat(qt): offline cache manager, and stamp the album identity on cached tracks
The last missing Settings surface: the OFFLINE CACHE group (Open manager /
Open folder / Clear all) and the manager view behind it — stats bar with
the GB limit, sort and failed-only toolbar, A-Z artist rail, album+track
rows with per-row re-download and remove. The engine was already here; what
was missing were four actions (remove_album, redownload_track, open_folder,
clear_all), the rollup and the view.

Two bugs the owner's smoke exposed, both older than this view:

ALBUM IDENTITY WAS BEING LOST ON EVERY ALBUM DOWNLOAD. A Track inside an
/album/get payload carries no nested album object, and `track_cache_info`
read exactly that, so `cache_album` wrote `album = NULL, album_id = NULL`
for every track it queued. Measured on a real index: 34 of 46 rows, three
complete albums. Not cosmetic — `album_id` is the key `remove_album`,
`redownload_album` and the downloaded-album registry look rows up by, so
those albums could not be removed, refreshed or recognised as downloaded.
The album document now stamps id and title onto every track that lacks its
own. The reference has the identical bug.

AND THE ROLLUP MIXED ARTISTS. Every row without an album_id collapsed into
one "__singles__" bucket titled from the first track and credited to its
artist, so three albums by three artists rendered as ONE album header while
two of the artists vanished from the rail. The key is a ladder now: album
id, else (artist, title), else a per-ARTIST singles bucket — two artists
can no longer meet in one group even with the broken data. Rows already on
disk recover their title from library.db, which does know it, so an
existing cache reads correctly without re-downloading.

Deltas from the reference, both because Qt is not Slint: covers are paths
(slint::Image is not Send, which is why it pre-decodes; Qt has no such
constraint) and selection is QML-local, this port's convention, which keeps
the document a pure function of the DB so a finishing download cannot fight
the user's checkboxes.

Clear-all confirms first — the reference fires straight from the glyph, and
undoing this one means re-downloading the whole cache.

cc
2026-08-16 18:54:16 -06:00

642 lines
29 KiB
QML

// Offline Cache Manager — QML port of crates/qbz-ui/ui/offline/
// OfflineManagerView.slint. A FULL-PAGE content view (route "offlinemanager"),
// reached only from Settings > Offline > "Open manager", exactly like the
// Blacklist manager next door.
//
// Layout, 1:1 with the reference: a stats bar (totals · usage bar · GB limit
// field · open-folder · clear-all) over a toolbar (sort · failed-only) over a
// two-pane body — a 210px A-Z artist rail on the left, the album-header +
// track rows on the right.
//
// Everything comes out of ONE document, QbzOffline.managerJson. The rollup,
// the ordering, the sizes and the status integers are all Rust-side
// (src/offline_manager_qt.rs); QML never filters and never sorts.
//
// Deltas vs the Slint, each deliberate:
//
// - NO per-view Back chrome (the Slint's NavButtons row, :171). Nav history is
// the global HeaderBar in this port — the same treatment
// views/BlacklistManagerView.qml already documents for the identical case.
// ADR-004 is satisfied by the HeaderBar.
// - The bulk actions are the SHARED controls/QbzMultiSelectBar.qml, not the
// reference's inline `BulkIconBtn` row plus a separate select-all toggle.
// The Slint's own comment (:115-118) says that component was "homologated
// with MultiSelectBar's BulkButton ... so the offline-cache track rows share
// the app-wide bulk-bar style" — this port HAS that bar, it already carries
// both "Select all" and "Clear" as actions, and every other multi-select
// surface here uses it.
// - Selection is QML-local (`selected` map below), the port's convention
// (LocalLibraryView.qml:100) rather than the reference's in-place edits on
// the Slint model. The rebuild is then a pure function of the DB, so a
// download finishing mid-selection cannot fight the user's checkboxes.
// - Covers are file paths in the document, loaded by RoundedImage with
// sourceSize; the reference pre-decodes pixels on its worker because
// slint::Image is not Send. Qt has no such constraint.
//
// Live progress does NOT wait for a rebuild: QbzShell.trackCacheStatusChanged
// is the app-wide row-status channel and this view patches its own rows from
// it, the same way the album page and the track rows do. A rebuild only
// happens on a terminal mutation (offline_manager_qt::refresh_if_open).
//
// Nav re-entry: nav_qt::back()/forward() republish `currentView` and run NO
// per-view load, so this view calls QbzOffline.reload() in
// Component.onCompleted.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
// Transparent while the ambient background is active — the frosted content
// panel shows through (HomeView.qml:53 and its twelve siblings).
readonly property bool ambientOn: theme.ambientOn
color: root.ambientOn ? "transparent" : theme.surfaceMain
// Round to the AppShell content-frame bezel (Radius.md): QML clips are
// rectangular, so the frame's rounding never reaches the view.
radius: 12
QbzTheme { id: theme }
// ============================== data ==================================
readonly property var doc: {
try {
return JSON.parse(QbzOffline.managerJson)
} catch (e) {
return ({})
}
}
readonly property var rows: root.doc.rows || []
readonly property var artists: root.doc.artists || []
readonly property int tracksCount: root.doc.tracksCount || 0
/// Checked TRACK rows, keyed by track id. Cleared on every publish: the
/// rows may have been re-filtered or removed underneath, and a checkbox
/// pointing at a row that is gone is a bulk action on nothing.
property var selected: ({})
readonly property int selectedCount: Object.keys(root.selected).length
onDocChanged: root.selected = ({})
/// Live per-row STATUS patches (id -> {status}) merged over the document.
/// Kept separate so the next publish drops them wholesale rather than
/// leaving a stale spinner behind.
///
/// The signal also carries a progress fraction and the document carries
/// `progress` per row, but neither is drawn — the reference does not draw
/// it either: its StatusIcon spins, it does not fill. Both are kept in the
/// payload because the row-status channel is app-wide and its shape is not
/// this view's to trim.
property var livePatch: ({})
Component.onCompleted: QbzOffline.reload()
Connections {
target: QbzShell
function onTrackCacheStatusChanged(trackId, status, progress) {
var p = Object.assign({}, root.livePatch)
p[trackId] = { "status": status, "progress": progress }
root.livePatch = p
}
}
function rowStatus(row) {
var p = root.livePatch[row.trackId]
return (row.kind === "track" && p !== undefined) ? p.status : row.status
}
function toggleSelect(trackId) {
var s = Object.assign({}, root.selected)
if (s[trackId] === true)
delete s[trackId]
else
s[trackId] = true
root.selected = s
}
function bulkAction(id) {
if (id === "clear") {
root.selected = ({})
return
}
if (id === "select-all") {
var s = ({})
for (var i = 0; i < root.rows.length; i++)
if (root.rows[i].kind === "track") s[root.rows[i].trackId] = true
root.selected = s
return
}
var ids = Object.keys(root.selected)
if (ids.length === 0)
return
if (id === "redownload") QbzOffline.bulkRedownload(JSON.stringify(ids))
else if (id === "remove") QbzOffline.bulkRemove(JSON.stringify(ids))
root.selected = ({})
}
// ============================== chrome ================================
// The cache-status glyph. Status 2 (downloading) is the only animated one
// and it rides the SHARED shell pulse — never a local Timer or
// NumberAnimation (the repaint-pulse law: every continuous animator ticks
// on QbzShell.pulseMs or the whole window presents at display rate).
component StatusGlyph: Item {
id: glyph
property int status: 0
width: 18
height: 18
property real spin: 0
Connections {
target: QbzShell
enabled: glyph.status === 2 && glyph.visible
function onPulseMsChanged() { glyph.spin = (glyph.spin + 12) % 360 }
}
QbzIcon {
anchors.centerIn: parent
width: 16
height: 16
rotation: glyph.status === 2 ? glyph.spin : 0
name: glyph.status === 2 ? "loader-circle"
: glyph.status === 3 ? "circle-check-big"
: glyph.status === 4 ? "triangle-alert" : "cloud-download"
tintName: glyph.status === 2 ? "accent"
: glyph.status === 3 ? "accent"
: glyph.status === 4 ? "warning" : "muted"
}
}
// A 30px ghost icon button (the reference's IconBtn).
component GhostBtn: Rectangle {
id: gb
property string name: ""
property string tint: "muted"
signal clicked()
width: 30
height: 30
radius: theme.radiusSm
color: gbArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
anchors.centerIn: parent
width: 16
height: 16
name: gb.name
tintName: gbArea.containsMouse ? "textPrimary" : gb.tint
}
MouseArea {
id: gbArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: gb.clicked()
}
}
// ============================== layout ================================
Column {
anchors.fill: parent
anchors.margins: 24
spacing: 14
// ---- Header ------------------------------------------------------
Text {
text: QbzSession.tr("Offline Cache", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
}
// ---- Stats bar ---------------------------------------------------
Rectangle {
width: parent.width
height: 76
radius: theme.radiusMd
color: theme.surfaceElevated
// Anchored, NOT a Row: the usage bar has to take exactly the
// slack between the two fixed clusters, and expressing that as a
// width subtraction inside a Row needs a named constant for the
// left cluster — which is how a phantom spacer item ends up in the
// layout adding its own width.
Column {
id: statsLeft
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: root.doc.tracksText || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
Text {
text: (root.doc.sizeText || "") + " " + (root.doc.limitText || "")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
Row {
id: statsRight
anchors.right: parent.right
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 6
QbzLineEdit {
id: limitField
anchors.verticalCenter: parent.verticalCenter
width: 58
// Seeded from the document, then owned locally while the
// user types — binding it to `limitGb` would put the
// persisted number back under the cursor on every
// republish (the re-seed rule in QbzLineEdit.qml:67).
text: (root.doc.limitGb || 5).toString()
onAccepted: function (v) { QbzOffline.setLimit(parseInt(v) || 0) }
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: "GB"
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
GhostBtn {
anchors.verticalCenter: parent.verticalCenter
name: "check"
onClicked: QbzOffline.setLimit(parseInt(limitField.text) || 0)
}
GhostBtn {
anchors.verticalCenter: parent.verticalCenter
name: "folder"
onClicked: QbzOffline.openFolder()
}
GhostBtn {
anchors.verticalCenter: parent.verticalCenter
name: "trash-2"
tint: "warning"
onClicked: clearConfirm.open()
}
}
Rectangle {
anchors.left: statsLeft.right
anchors.right: statsRight.left
anchors.leftMargin: 20
anchors.rightMargin: 20
anchors.verticalCenter: parent.verticalCenter
height: 8
radius: 4
color: theme.surfaceMain
Rectangle {
width: parent.width * Math.max(0, Math.min(1, root.doc.usage || 0))
height: parent.height
radius: 4
color: (root.doc.usage || 0) >= 1.0 ? theme.danger : theme.accent
}
}
}
// ---- Toolbar -----------------------------------------------------
Row {
width: parent.width
height: 32
spacing: 12
QbzSelect {
anchors.verticalCenter: parent.verticalCenter
menuWidth: 160
sm: true
options: [
QbzSession.tr("Sort: A-Z", QbzSession.trRev),
QbzSession.tr("Sort: Recent", QbzSession.trRev),
QbzSession.tr("Sort: Largest", QbzSession.trRev),
QbzSession.tr("Sort: Smallest", QbzSession.trRev)
]
currentIndex: root.doc.sortIndex || 0
onSelected: function (i) { QbzOffline.setSort(i) }
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: failedLabel.implicitWidth + 28
height: 32
radius: theme.radiusSm
border.width: 1
border.color: root.doc.showOnlyFailed === true ? theme.accent : theme.borderSubtle
color: root.doc.showOnlyFailed === true
? theme.dangerBg
: (failedArea.containsMouse ? theme.surfaceElevated : "transparent")
Text {
id: failedLabel
x: 14
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Failed only", QbzSession.trRev)
color: root.doc.showOnlyFailed === true ? theme.danger : theme.textSecondary
font.pixelSize: theme.fontLegal
}
MouseArea {
id: failedArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzOffline.toggleFailed()
}
}
}
// ---- Body --------------------------------------------------------
Item {
width: parent.width
height: parent.height - y
Row {
anchors.fill: parent
anchors.bottomMargin: root.selectedCount > 0 ? 58 : 0
spacing: 14
// Left A-Z artist rail.
Rectangle {
width: 210
height: parent.height
radius: theme.radiusMd
color: theme.surfaceCard
clip: true
ListView {
id: railList
anchors.fill: parent
anchors.margins: 6
spacing: 2
// +1 for the "All artists" row, which is not in the
// document (it is the absence of a filter).
model: root.artists.length + 1
boundsBehavior: Flickable.StopAtBounds
// Row 0 is "All artists" — the ABSENCE of a filter, so
// it is not in the document and the model is one longer
// than the artist list. Every child reads the delegate
// through its own id: a `parent.parent` chain here
// silently resolves to the Column once anything is
// nested one level deeper.
delegate: Rectangle {
id: railRow
required property int index
readonly property bool isAll: index === 0
readonly property var a: isAll ? null : root.artists[index - 1]
readonly property bool active: isAll
? (root.doc.selectedArtist || "") === ""
: (a !== null && a.selected === true)
width: railList.width
height: 48
radius: 8
color: railRow.active ? theme.surfaceElevated
: (railArea.containsMouse ? theme.surfaceHover : "transparent")
Column {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 8
spacing: 2
Text {
width: parent.width
text: railRow.isAll
? QbzSession.tr("All artists", QbzSession.trRev)
: railRow.a.name
color: railRow.active ? theme.accent : theme.textPrimary
font.pixelSize: theme.fontLegal
font.weight: railRow.active
? theme.weightSemibold : theme.weightRegular
elide: Text.ElideRight
}
Text {
width: parent.width
text: railRow.isAll
? (root.doc.tracksText || "")
: railRow.a.meta
color: theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
}
MouseArea {
id: railArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzOffline.selectArtist(
railRow.isAll ? "" : railRow.a.name)
}
}
QbzScrollBar {
target: railList
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
// Right list — the three mutually-exclusive body branches, in
// the reference's order: loading · empty · the list.
Item {
width: parent.width - 210 - 14
height: parent.height
QbzSpinner {
anchors.centerIn: parent
visible: QbzOffline.managerLoading
}
Text {
anchors.centerIn: parent
visible: !QbzOffline.managerLoading && root.rows.length === 0
text: QbzSession.tr("No offline tracks yet", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
ListView {
id: rowList
anchors.fill: parent
visible: !QbzOffline.managerLoading && root.rows.length > 0
spacing: 2
clip: true
model: root.rows
boundsBehavior: Flickable.StopAtBounds
// A cache that can hold thousands of tracks is born
// windowed (TRACK-RULES §6) — never a Flickable over
// every row.
cacheBuffer: 400
delegate: Rectangle {
id: rowItem
required property var modelData
readonly property bool isAlbum: modelData.kind === "album"
readonly property bool checked:
root.selected[modelData.trackId] === true
width: rowList.width
height: isAlbum ? 60 : 44
radius: 8
color: isAlbum ? theme.surfaceCard
: (rowArea.containsMouse ? theme.surfaceHover : "transparent")
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: rowItem.isAlbum
? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: if (!rowItem.isAlbum)
QbzOffline.playTrack(rowItem.modelData.trackId)
}
Row {
anchors.fill: parent
anchors.leftMargin: rowItem.isAlbum ? 10 : 16
anchors.rightMargin: 12
spacing: 12
// Checkbox (track rows) / spacer (albums).
Item {
width: 16
height: parent.height
QbzCheckbox {
anchors.centerIn: parent
visible: !rowItem.isAlbum
checked: rowItem.checked
onToggled: root.toggleSelect(rowItem.modelData.trackId)
}
}
// Leading — album cover or track number.
Item {
width: 40
height: parent.height
Rectangle {
anchors.centerIn: parent
visible: rowItem.isAlbum
width: 40
height: 40
radius: 4
color: theme.surfaceMain
RoundedImage {
anchors.fill: parent
radius: 4
source: (rowItem.modelData.cover || "") !== ""
? "file://" + rowItem.modelData.cover : ""
}
}
Text {
anchors.centerIn: parent
visible: !rowItem.isAlbum
text: rowItem.modelData.number || ""
color: theme.textMuted
font.pixelSize: theme.fontBody
}
}
// Title + subtitle.
Column {
width: parent.width - 16 - 40 - metaText.width
- 30 * 2 - 18 - 12 * 6
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: rowItem.modelData.title || ""
color: theme.textPrimary
font.pixelSize: rowItem.isAlbum
? theme.fontBody : theme.fontLegal
font.weight: rowItem.isAlbum
? theme.weightSemibold : theme.weightRegular
elide: Text.ElideRight
}
Text {
width: parent.width
text: rowItem.modelData.subtitle || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Text {
id: metaText
anchors.verticalCenter: parent.verticalCenter
text: rowItem.modelData.meta || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignRight
}
GhostBtn {
anchors.verticalCenter: parent.verticalCenter
name: "refresh-cw"
onClicked: rowItem.isAlbum
? QbzOffline.redownloadAlbum(rowItem.modelData.albumId)
: QbzOffline.redownloadTrack(rowItem.modelData.trackId)
}
GhostBtn {
anchors.verticalCenter: parent.verticalCenter
name: "trash-2"
onClicked: rowItem.isAlbum
? QbzOffline.removeAlbum(rowItem.modelData.albumId)
: QbzOffline.removeTrack(rowItem.modelData.trackId)
}
StatusGlyph {
anchors.verticalCenter: parent.verticalCenter
status: root.rowStatus(rowItem.modelData)
}
}
}
QbzScrollBar {
target: rowList
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
// Bulk bar — the shared component, mounted the way every other
// multi-select surface in this port mounts it.
QbzMultiSelectBar {
visible: root.selectedCount > 0
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
selectedCount: root.selectedCount
actions: [
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
{ "id": "redownload", "label": QbzSession.tr("Re-download", QbzSession.trRev), "icon": "refresh-cw", "danger": false, "needsSelection": true },
{ "id": "remove", "label": QbzSession.tr("Remove", QbzSession.trRev), "icon": "trash-2", "danger": true, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
]
onAction: function (id) { root.bulkAction(id) }
}
}
}
// Clear-all confirmation. The Slint fires it straight from the trash glyph;
// this port asks first, the way every other destructive row in Settings
// does (SettingsConfirmHost). Purging the cache can be an hours-long
// re-download and there is no undo.
QbzConfirmModal {
id: clearConfirm
// Fills the VIEW, like BlacklistManagerView.qml:523 — without it the
// modal has no size and its scrim covers nothing.
anchors.fill: parent
title: QbzSession.tr("Clear cache", QbzSession.trRev)
body: QbzSession.tr("Frees up cached data. Your downloaded albums are kept — remove those from the offline manager above.", QbzSession.trRev)
confirmLabel: QbzSession.tr("Clear all", QbzSession.trRev)
danger: true
onConfirmed: QbzOffline.clearAll()
}
}