Files
qbz/crates/qbz-qt/qml/views/myqbz/DiscoBuilderView.qml
T
vicrodh e291eb3af4 fix: stabilize playback memory, local library sync and Qt settings
Add shared playback memory profiles, bounded disk-backed playback and storage
selection for desktop and daemon. Preserve gapless during streaming-only playback
and provide explicit cache eviction with playback confirmation.

Stop watcher read events and unchanged scans from rebuilding the local catalog.
Restore playlist/sidebar updates, folder availability feedback and player layout.
Add appearance presets and a radio-based cache settings panel with translated
trade-offs, plus consistent clipboard menus across visible Qt text editors.

Validate with Rust regressions, QML interaction tests, isolated release startup
and idle-library checks; extend the shared local and CI gates.

cx
2026-09-13 02:57:21 -06:00

519 lines
22 KiB
QML

// Discography Builder — the full-page flow that assembles an artist's complete
// discography into a saved `kind='artist_collection'` collection. QML port of
// myqbz/DiscographyBuilderView.slint:418-787 (spec 01 §8).
//
// Route "discobuilder". Entry: the artist page's overflow -> "Create Artist
// Collection". Exit: the footer Cancel — there is NO per-view back button
// (:438: the shell owns the global < > nav).
//
// TWO-PART SHELL (:8-11, :422-428, :727-736): everything scrolls except a
// FIXED 60px footer pinned at the bottom as a sibling of the scroller (NOT
// sticky-inside-scroll), so the last rows never peek past it.
//
// WINDOWING (spec 01 §8.9): a discography is tens-to-hundreds of groups and
// every row mounts a QualityBadgeFull, so the table is ONE recycled ListView
// over the FLATTENED row descriptors Rust publishes as `doc.rows`
// (`{kind, groupKey, groupIsCompilation, restOpacity, candidate}`), with
// `cacheBuffer: 44*10`. NOT a ListView of groups each holding a Repeater of
// alternates: that shape leaves `rows` unread and makes every delegate a
// variable-height Column. Everything above the table — the artist header,
// the NAME field, the ORDER BY row, the 20px spacer, the loading/error/empty
// band and the table's own rule + column header — is the ListView.header, so
// ONE scroller drives the page; the content's 24px bottom padding is the
// ListView.footer. In the three non-populated states the model is empty and
// the header carries the whole page: the ListView is never branched out of the
// tree, because a new `model` assignment resets contentY (recipe §8).
//
// The root is `color: "transparent"` VERBATIM (:419) — not the ambient ternary
// the other views use, and therefore no `radius: 12` either: there is no fill
// of its own to round, the content frame's surfaceMain shows through, and the
// footer's surfaceMain matches it (AppShell's bezel nubs own the corners).
import QtQuick
import "../../kiosk"
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
import "../local"
Rectangle {
id: root
property bool kioskHost: false
color: "transparent"
QbzTheme { id: theme }
property var doc: ({})
function parseDoc() {
try {
return JSON.parse(QbzDisco.discoJson)
} catch (e) {
return ({})
}
}
// `rows` is a plain JS array, so every Rust publish assigns a brand-new
// ListView model. Capture the viewport BEFORE assigning `doc`; trying to
// recover it from onRowsChanged is already too late because Qt may have
// reset contentY during the binding update.
property int documentEpoch: 0
function applyDocument() {
var next = root.parseDoc()
var currentArtist = root.doc.artistId || ""
var nextArtist = next.artistId || ""
var preserve = nextArtist !== "" && nextArtist === currentArtist
&& next.loading !== true
var savedY = preserve ? rowList.contentY : 0
root.documentEpoch += 1
var epoch = root.documentEpoch
root.doc = next
Qt.callLater(function () {
if (epoch !== root.documentEpoch)
return
rowList.forceLayout()
var minY = rowList.originY
var maxY = minY + Math.max(0,
rowList.contentHeight - rowList.height)
rowList.contentY = preserve
? Math.max(minY, Math.min(savedY, maxY)) : minY
})
}
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// --- Document slices (spec 02 §5.8 DiscoDoc) --------------------------
readonly property string artistId: doc.artistId || ""
readonly property bool loading: doc.loading === true
readonly property string loadError: doc.loadError || ""
readonly property var groups: doc.groups || []
// The flattened table (spec 01 §8.9) — one entry per RENDERED row, in the
// same order as `groups`, each carrying its precomputed `restOpacity`.
readonly property var rows: doc.rows || []
readonly property string orderBy: doc.orderBy || "release_date"
readonly property int selectedCount: doc.selectedCount || 0
readonly property int groupCount: doc.groupCount || 0
readonly property bool creating: doc.creating === true
readonly property string defaultName: doc.defaultName || ""
// Precedence exactly as the Slint writes it (:599,610,621-623,634-636):
// loading -> loadError -> empty -> the table.
readonly property bool populated: !root.loading && root.loadError === ""
&& root.groups.length > 0
// --- Collection name (QML-local) --------------------------------------
// The bridge (spec 02 §4.3) has no `setName`: `create(name)` takes it, so
// the field's truth lives here and Rust seeds it once through
// `defaultName` ("{artist} — Complete Discography").
// DIVERGENCE from spec 01 §8.7, forced by that surface: Slint cannot trim
// and exposes `name-trimmed-empty` from Rust; with one implementation
// there is nothing to diverge from, so the trim is done here.
property string collectionName: ""
property bool nameEdited: false
readonly property bool nameTrimmedEmpty: root.collectionName.trim() === ""
// A new artist re-seeds; a late-arriving default (the name resolves after
// the fetch) must not clobber what the user typed.
onArtistIdChanged: {
root.nameEdited = false
root.collectionName = root.defaultName
}
onDefaultNameChanged: if (!root.nameEdited) root.collectionName = root.defaultName
// Slint's `reset` blanks `collection-name` before EVERY fetch
// (myqbz_builder.rs:690) and `install` re-seeds it, so a name the user typed
// during a previous open never survives into the next one. Without this the
// `nameEdited` latch keeps a stale edit forever when the builder is
// re-opened for the SAME artist (`artistId` and `defaultName` both unchanged,
// so neither handler above fires).
onLoadingChanged: if (root.loading) {
root.nameEdited = false
root.collectionName = ""
}
Component.onCompleted: {
root.applyDocument()
if (!root.nameEdited && root.collectionName === "")
root.collectionName = root.defaultName
}
// Footer count. Rust pre-renders the ONE true plural in this domain with
// `qbz_i18n::tf` and publishes it as `footerCount` (spec 01 §4/§8.7) —
// QML has no plural entry point. The fallback below only runs if that key
// is absent, and then falls through to the English source (an untranslated
// msgid renders, it does not break — recipe §7).
readonly property string footerText: {
var pre = root.doc.footerCount || ""
if (pre !== "")
return pre
var src = root.groupCount === 1
? "{} of {} album selected" : "{} of {} albums selected"
return root.t(src).replace("{}", String(root.selectedCount))
.replace("{}", String(root.groupCount))
}
/// The six column labels (:677-700) — uppercase literals for the same
/// reason as the eyebrow, `letterSpacing 1.2`, and the per-cell alignment
/// the Slint gives each one. `titleW` is the 1fr ALBUM cell.
function headCells(rev, titleW) {
return [
{ "w": 56, "label": QbzSession.tr("YEAR", rev), "center": false },
{ "w": 72, "label": QbzSession.tr("TYPE", rev), "center": false },
{ "w": titleW, "label": QbzSession.tr("ALBUM", rev), "center": false },
{ "w": 72, "label": QbzSession.tr("TRACKS", rev), "center": true },
{ "w": 60, "label": QbzSession.tr("SOURCE", rev), "center": true },
{ "w": 156, "label": QbzSession.tr("QUALITY", rev), "center": true }
]
}
/// ORDER BY segments (:552-555). Built by a function that TAKES `rev` so
/// the array rebuilds on a live language switch.
function orderSegments(rev) {
return [
{ "id": "release_date", "label": QbzSession.tr("Release date", rev) },
{ "id": "title", "label": QbzSession.tr("Title", rev) },
{ "id": "manual", "label": QbzSession.tr("Manual", rev) }
]
}
// --- Everything above the table (the ListView header) ------------------
Component {
id: pageHead
Column {
width: rowList.width
spacing: 0
// content padding-top (:433).
Item { width: 1; height: 24 }
// ── header.builder-header (:442-486) ──
Row {
width: parent.width
spacing: 20
// 72px circle. The Slint draws NO placeholder glyph — an
// artist without a portrait is the bare surface (:446-458).
Rectangle {
width: 72
height: 72
radius: 36
color: theme.surfaceElevated
clip: true
Loader { anchors.fill: parent; sourceComponent: root.kioskHost ? kioskImage9049 : desktopImage9049
Component { id: kioskImage9049; KioskArtwork {
anchors.fill: parent
visible: (root.doc.artistAvatarUrl || "") !== ""
source: root.doc.artistAvatarPath || ""
radius: 36
} }
Component { id: desktopImage9049; RoundedImage {
anchors.fill: parent
visible: (root.doc.artistAvatarUrl || "") !== ""
source: root.doc.artistAvatarPath || ""
radius: 36
} }
}
}
Column {
width: Math.max(0, parent.width - 72 - 20)
anchors.verticalCenter: parent.verticalCenter
spacing: 4
// Uppercase in the msgid: Slint has no text-transform and
// writes the literal in caps (:465-471), and the eight
// catalogues carry it that way.
Text {
text: root.t("BUILD ARTIST COLLECTION")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Text {
width: parent.width
text: (root.doc.artistName || "") === ""
? "—" : root.doc.artistName
color: theme.textPrimary
font.pixelSize: 28
font.weight: theme.weightBold
elide: Text.ElideRight
}
}
}
Item { width: 1; height: 24 }
// ── label.field — the collection-name input (:489-524) ──
Column {
width: parent.width
spacing: 8
Text {
text: root.t("NAME")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
// Inlined rather than QbzLineEdit's plain arm: that arm is
// 240x34 / borderSubtle and this field is a one-off 540x40
// bordered `surfaceElevated` (spec 01 §8.3). Do not fork the
// shared control for it.
Rectangle {
width: 540
height: root.kioskHost ? 44 : 40
color: theme.surfaceCard
radius: 8
border.width: 1
border.color: nameField.activeFocus
? theme.accent : theme.surfaceElevated
TextInput {
QbzTextEditMenu { }
id: nameField
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: 14
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
text: root.collectionName
onTextEdited: {
root.nameEdited = true
root.collectionName = text
}
// Re-seed from Rust while the field is NOT being
// edited (the QbzLineEdit.qml:174-179 idiom).
Binding {
target: nameField
property: "text"
value: root.collectionName
when: !nameField.activeFocus
}
}
}
}
Item { width: 1; height: 16 }
// ── .order-row — the segmented control (:527-594) ──
Row {
width: parent.width
spacing: 12
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.t("ORDER BY")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
// Use the shared labelled segmented control. The former
// one-off Rectangle did not set a fill, so Qt's default white
// leaked through instead of the theme well.
QbzTabBar {
kioskHost: root.kioskHost
anchors.verticalCenter: parent.verticalCenter
tabs: root.orderSegments(QbzSession.trRev)
activeId: root.orderBy
counts: false
underline: false
compact: false
onSelected: function (id) { QbzDisco.setOrder(id) }
}
}
Item { width: 1; height: 20 }
// ── loading / error / empty — one 96px band (:597-632) ──
Item {
width: parent.width
visible: !root.populated
height: visible ? 96 : 0
Text {
anchors.fill: parent
text: root.loading
? root.t("Loading...")
: (root.loadError !== ""
? root.loadError : root.t("No results found"))
// The Slint uses Theme.favorite here, not Theme.danger
// (:614) — keep it.
color: (!root.loading && root.loadError !== "")
? theme.favorite : theme.textMuted
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
// ── .groups table preamble: border-top + 4px pad (:639-640) ──
Rectangle {
width: parent.width
visible: root.populated
height: visible ? 1 : 0
color: theme.surfaceElevated
}
Item { width: 1; visible: root.populated; height: visible ? 4 : 0 }
// ── header row (.row.header-row, :643-702) ──
// The 7-column template is inlined here and in DiscoCandidateRow —
// a MATCHED PAIR. 28/56/72/1fr/72/60/156, padding 8, spacing 10.
Item {
id: colHead
width: parent.width
visible: root.populated
height: visible ? (root.kioskHost ? 44 : 36) : 0
readonly property real titleW: Math.max(
0, colHead.width - 16 - 444 - 60)
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.surfaceElevated
}
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 10
// [0] 28px — tri-state select-all. The indeterminate state
// renders minus.svg inside the same accent disc (the
// SelectCheck/TreeRow idiom), never a hand-drawn dash.
Item {
width: root.kioskHost ? 60 : 28
height: parent.height
SelectCheck {
diameter: root.kioskHost ? 44 : 13
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
on: root.doc.allChecked === true
partial: root.doc.allChecked !== true
&& root.doc.someChecked === true
onToggled: QbzDisco.toggleAll()
}
}
// [1..6] — the column labels, in order.
Repeater {
model: root.headCells(QbzSession.trRev, colHead.titleW)
delegate: Text {
required property var modelData
width: modelData.w
height: parent.height
text: modelData.label
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.2
horizontalAlignment: modelData.center
? Text.AlignHCenter : Text.AlignLeft
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
}
}
// content padding-bottom (:436).
Component {
id: bottomPad
Item { width: 1; height: 24 }
}
// --- The scroller (:424-428) -----------------------------------------
ListView {
id: rowList
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: footerBar.top
// content padding-l/r 32 (:431-432); the scrollbar gutter sits inside
// the right padding, so it never overlaps a row.
anchors.leftMargin: 32
anchors.rightMargin: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
// spec 01 §8.9: 44 * 10.
cacheBuffer: root.kioskHost ? 128 : 440
model: root.populated ? root.rows : []
header: pageHead
footer: bottomPad
// ONE row — a group's primary, or one of its alternates (:705-722).
// Every field comes off the Rust row descriptor: `restOpacity` is
// precomputed there (primary = isCompilation ? 0.65 : 1.0, alternate =
// 0.72 unconditionally), so it is never re-derived here.
delegate: DiscoCandidateRow {
kioskHost: root.kioskHost
required property var modelData
width: rowList.width
candidate: modelData.candidate || ({})
groupKey: modelData.groupKey || ""
alt: modelData.kind === "alt"
groupIsCompilation: modelData.groupIsCompilation === true
restOpacity: modelData.restOpacity !== undefined
? modelData.restOpacity : 1.0
}
}
Connections {
target: QbzDisco
function onDiscoJsonChanged() { root.applyDocument() }
}
// 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: "discobuilder" }
QbzScrollBar {
target: rowList
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: rowList.top
anchors.bottom: rowList.bottom
}
// --- footer.builder-footer (:728-785) --------------------------------
Rectangle {
id: footerBar
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: root.kioskHost ? 72 : 60
color: theme.surfaceMain
Rectangle {
y: 0
width: parent.width
height: 1
color: theme.surfaceElevated
}
Text {
anchors.left: parent.left
anchors.leftMargin: 32
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 32 - 88 - actionRow.width - 16)
text: root.footerText
color: theme.textSecondary
font.pixelSize: 13
elide: Text.ElideRight
}
Row {
id: actionRow
// Asymmetric right padding (:739) — 88 clears the back-to-top FAB.
anchors.right: parent.right
anchors.rightMargin: root.kioskHost ? 16 : 88
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.t("Cancel")
btnHeight: root.kioskHost ? 64 : 36
minWidth: 0
onClicked: QbzDisco.back()
}
QbzPrimaryButton {
label: root.creating
? root.t("Loading...") : root.t("Create Collection")
btnHeight: root.kioskHost ? 64 : 36
btnEnabled: !(root.creating || root.selectedCount === 0
|| root.nameTrimmedEmpty)
onClicked: QbzDisco.create(root.collectionName)
}
}
}
}