The manager's checkboxes toggled one track at a time. They now follow the shared rule over the track rows as drawn, album headers excluded, and a Shift or Ctrl click on a track row selects instead of starting playback; a plain click still plays. The Slint had this surface on its range table (SURFACE_OFFLINE). cc
705 lines
33 KiB
QML
705 lines
33 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
|
|
property bool kioskHost: false
|
|
|
|
// 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
|
|
}
|
|
/// The rule every multi-select list shares (controls/SelectionModel.qml):
|
|
/// a plain or Ctrl click toggles one track, Shift adds the range from the
|
|
/// last one — over the TRACK rows as drawn, never the album headers
|
|
/// between them. The Slint had this surface on its Shift-range table
|
|
/// (selection.rs SURFACE_OFFLINE); the port left it toggle-only.
|
|
SelectionModel { id: offlineSel; idKey: "trackId" }
|
|
function toggleSelect(trackId, mods) {
|
|
var tracks = root.rows.filter(function (row) { return row.kind === "track" })
|
|
root.selected = offlineSel.next(root.selected, trackId, tracks,
|
|
mods === undefined ? Qt.NoModifier : mods)
|
|
}
|
|
|
|
function bulkAction(id) {
|
|
if (id === "clear") {
|
|
root.selected = ({})
|
|
offlineSel.anchorId = ""
|
|
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"
|
|
/// Hover tooltip text, shown by the view's QbzTooltip overlay ("" =
|
|
/// none). Icon-only buttons get one; the row glyphs have their labels.
|
|
property string tip: ""
|
|
signal clicked()
|
|
width: root.kioskHost ? 44 : 30
|
|
height: root.kioskHost ? 44 : 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: {
|
|
tips.hide("ghost-" + gb.name)
|
|
gb.clicked()
|
|
}
|
|
onContainsMouseChanged: tips.hover(containsMouse, gb, "ghost-" + gb.name, gb.tip)
|
|
}
|
|
}
|
|
|
|
// ============================== layout ================================
|
|
|
|
Column {
|
|
anchors.fill: parent
|
|
anchors.margins: root.kioskHost ? 8 : 24
|
|
spacing: root.kioskHost ? 6 : 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
|
|
visible: !root.kioskHost || root.selectedCount === 0
|
|
height: root.kioskHost ? 44 : 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: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
|
}
|
|
}
|
|
|
|
Row {
|
|
id: statsRight
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 16
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 6
|
|
|
|
QbzLineEdit {
|
|
kioskHost: root.kioskHost
|
|
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: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
|
}
|
|
GhostBtn {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: "check"
|
|
tip: QbzSession.tr("Apply the size limit", QbzSession.trRev)
|
|
onClicked: QbzOffline.setLimit(parseInt(limitField.text) || 0)
|
|
}
|
|
GhostBtn {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: "folder"
|
|
tip: QbzSession.tr("Open the cache folder", QbzSession.trRev)
|
|
onClicked: QbzOffline.openFolder()
|
|
}
|
|
// The ONE place the whole offline cache can be purged (the
|
|
// Settings row that mirrored it was dropped 2026-09-13): a
|
|
// tooltip says so before the click, the modal says what goes.
|
|
GhostBtn {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: "trash-2"
|
|
tint: "warning"
|
|
tip: QbzSession.tr("Clear the whole offline cache…", QbzSession.trRev)
|
|
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: root.kioskHost ? 44 : 32
|
|
spacing: 12
|
|
|
|
QbzSelect {
|
|
kioskHost: root.kioskHost
|
|
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: root.kioskHost ? 44 : 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: root.kioskHost ? theme.fontLegal * 1.2 : 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: root.kioskHost ? 6 : 14
|
|
|
|
// Left A-Z artist rail.
|
|
Rectangle {
|
|
width: root.kioskHost ? 160 : 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: root.kioskHost ? 64 : 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: root.kioskHost ? theme.fontLegal * 1.2 : 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: root.kioskHost ? 13 : 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 - (root.kioskHost ? 160 + 6 : 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: root.kioskHost ? 64 : (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
|
|
// A plain click plays; a Shift or Ctrl click is
|
|
// a selection gesture, as in any file list, so
|
|
// it ticks (or ranges) instead of starting
|
|
// playback.
|
|
onClicked: function (mouse) {
|
|
if (rowItem.isAlbum)
|
|
return
|
|
if ((mouse.modifiers & (Qt.ShiftModifier | Qt.ControlModifier
|
|
| Qt.MetaModifier)) !== 0)
|
|
root.toggleSelect(rowItem.modelData.trackId, mouse.modifiers)
|
|
else
|
|
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: root.kioskHost ? 44 : 16
|
|
height: parent.height
|
|
QbzCheckbox {
|
|
kioskHost: root.kioskHost
|
|
anchors.centerIn: parent
|
|
visible: !rowItem.isAlbum
|
|
checked: rowItem.checked
|
|
onToggled: function (mods) {
|
|
root.toggleSelect(rowItem.modelData.trackId, mods)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
Loader {
|
|
anchors.fill: parent
|
|
active: root.kioskHost && rowItem.isAlbum
|
|
source: active ? "../kiosk/KioskArtwork.qml" : ""
|
|
onLoaded: item.source = Qt.binding(function() {
|
|
return rowItem.modelData.cover ? "file://" + rowItem.modelData.cover : ""
|
|
})
|
|
}
|
|
RoundedImage {
|
|
visible: !root.kioskHost
|
|
anchors.fill: parent
|
|
radius: 4
|
|
source: !root.kioskHost && (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 - (root.kioskHost ? 44 : 16) - 40 - metaText.width
|
|
- (root.kioskHost ? 44 : 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: root.kioskHost ? theme.fontLegal * 1.2 : theme.fontLegal
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: metaText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: rowItem.modelData.meta || ""
|
|
color: theme.textMuted
|
|
font.pixelSize: root.kioskHost ? theme.fontLegal * 1.2 : 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
|
// this container's offset while it is the live page, and restores it
|
|
// when a back/forward step arms this route.
|
|
ScrollMemory { target: rowList; scope: "offlinemanager" }
|
|
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 {
|
|
kioskHost: root.kioskHost
|
|
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. Purging the cache can be an hours-long re-download
|
|
// and there is no undo, so the body says exactly what goes (the live
|
|
// count and size from the stats bar) and what stays (purchased downloads
|
|
// in the music folder — a different tree, never touched by clear_all).
|
|
QbzConfirmModal {
|
|
kioskHost: root.kioskHost
|
|
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 the offline cache?", QbzSession.trRev)
|
|
body: QbzSession.tr("Removes everything saved for offline listening: {} using {}. Purchased downloads in your music folder are kept; anything cleared has to be downloaded again to play offline.", QbzSession.trRev)
|
|
.replace("{}", root.doc.tracksText || "")
|
|
.replace("{}", root.doc.sizeText || "")
|
|
confirmLabel: QbzSession.tr("Clear all", QbzSession.trRev)
|
|
danger: true
|
|
onConfirmed: QbzOffline.clearAll()
|
|
}
|
|
|
|
// Hover tooltips for the icon-only stats-bar buttons. QbzTooltip is the
|
|
// shared overlay; it takes no pointer and owns no animator, so an idle
|
|
// one costs nothing (ArtistSceneView.qml mounts it the same way). Below
|
|
// the modal's z (ADR-009), so an open confirmation always covers it.
|
|
QbzTooltip {
|
|
id: tips
|
|
anchors.fill: parent
|
|
z: 900
|
|
}
|
|
}
|