The AppShell hotkey seam duck-types selectAll, exitMultiSelectMode and multiSelectOn on the mounted view. The offline manager exposed none of them, so Ctrl+A did nothing there although the Slint ticked every track, and MixView lacked multiSelectOn, so Escape never left its select mode. The seam's comment, which still listed Artist, Playlist, Mix and Label as views without multi-select, now names the views that implement it. cc
717 lines
34 KiB
QML
717 lines
34 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)
|
|
}
|
|
|
|
// Ctrl+A / Escape hotkey seam (AppShell duck-types these). The selection
|
|
// here is always on (no select mode), so Ctrl+A ticks every track — as
|
|
// the Slint's select_all_active_surface did for this view — and Escape
|
|
// drops a non-empty selection.
|
|
readonly property bool multiSelectOn: Object.keys(root.selected).length > 0
|
|
function selectAll() {
|
|
root.bulkAction("select-all")
|
|
}
|
|
function exitMultiSelectMode() {
|
|
root.bulkAction("clear")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|