Opt-in collapsible My QBZ tree in the sidebar (Settings > Navigation): a branding-titled parent over Mixtapes and Collections sections, each header a link to its grid with a chevron that only collapses. Header text and icon on the left, a Playlists-parity toolbar floating right (search, new, sort A-Z/Z-A, refresh, collapse) with the chevron aligned to the playlist carets. The tree feed rides QbzShell (QbzMyQbz is not reliably registered in the shell context) and is published through publish_sidebar, so it lands reliably at startup. Per-element visibility reuses the mixtape hidden flag (repo::set_hidden): a row's right-click hides it from the sidebar, and the detail hero menu toggles "Hide from sidebar" / "Show in sidebar". Collapse state for the playlist tree and all three My QBZ sections is persisted. The collapsible tree and the flat My QBZ entry are mutually exclusive — the classic row is suppressed whenever the tree is on. Settings gains a Navigation section (Navigation + Search moved out of Appearance). Detail view also gains a discreet, column-aligned sub-header over the inline track list. Eight locales updated. cc
1407 lines
74 KiB
QML
1407 lines
74 KiB
QML
// MyQbzDetailView — the My QBZ Mixtape / Collection DETAIL route
|
|
// ("mixtapedetail"), the port of myqbz/MixtapeDetailView.slint:596-1272.
|
|
// Hero + sticky toolbar + body (list / grid / expanded) + the bulk bar.
|
|
// ONE document: `QbzMyQbz.detailJson` (spec 02 §5.2 `DetailDoc`).
|
|
//
|
|
// No per-view back button — the shell owns the global < > nav (.slint :613).
|
|
//
|
|
// --- PAGE FRAME, and the ONE structural deviation ------------------------
|
|
// The .slint puts hero + toolbar + body inside a single Flickable
|
|
// (:631-660: padding 16 / 32 / 100 / 32, spacing 0, top-aligned), so its hero
|
|
// scrolls away. Here the hero + toolbar are a FIXED header and only the body
|
|
// scrolls, which is the port's established shape for a WINDOWED body
|
|
// (`views/LibraryView.qml:1269`, `views/local/LocalTracksTab.qml:135`).
|
|
//
|
|
// Why, since 1:1 is the bar: an artist_collection holds 200+ items and every
|
|
// row carries a QualityBadgeFull, so the body MUST be a recycling
|
|
// ListView/GridView (spec 01 §15.1, non-negotiable). A recycling view cannot
|
|
// be content-sized inside an outer Flickable, and `ListView.header` is not
|
|
// available to the GRID arm either — the grid is hosted in a clipped Item and
|
|
// given a corrected width to reproduce the app album grid's column count
|
|
// (see the grid arm), which would offset and over-widen a header inside it. The
|
|
// paddings, the 32px hero→body gap and the 10px toolbar tail are all kept;
|
|
// the toolbar simply became literally sticky, which is what §7.2 calls it.
|
|
//
|
|
// --- PER-ROW COLLAPSIBLE ROWS (owner-authorised divergence) -------------
|
|
// In the DETAILS arm every album / playlist row carries a chevron that opens
|
|
// ITS OWN inline track list (`MyQbzDetailRow.qml`, col 0). NEITHER the .slint
|
|
// NOR the Tauri build had this — both render inline tracks only as a whole-list
|
|
// view MODE, explicitly "no chevron". The owner asked for the accordion anyway
|
|
// (2026-07-30); do not "restore" it toward either reference.
|
|
// It is ADDITIVE on machinery that already existed: Rust already published
|
|
// per-row `canExpand` / `inlineTracks` / `expandLoading` / `tracksLoaded`, so
|
|
// the accordion added exactly one thing — `rowOpen`, THE single notion of open.
|
|
// There is deliberately no second, QML-side definition of "is this row open".
|
|
// DEFAULT + PERSISTENCE, owner 2026-08-01: the details arm opens with every row
|
|
// CLOSED (the segment no longer force-opens anything) and the rows the user
|
|
// opens are remembered per collection across view switches and restarts
|
|
// (`collection_open_rows.json`). Rust gates the published `rowOpen` on the
|
|
// details arm itself, so the other arms still render no inline block.
|
|
// SCOPE, owner 2026-07-31: the chevron belongs to the DETAILS arm only — the
|
|
// plain LIST arm carries no affordance and is the reference's 8-column row
|
|
// again (`rowExpander` below). The accordion itself stays; only its reach
|
|
// changed, and `rowOpen` is still the one notion of open.
|
|
//
|
|
// --- THE MODEL IS PATCHED, NEVER REPLACED, AND PARSED ONCE --------------
|
|
// `QbzMyQbz` carries no signals: every Rust-side change arrives as a whole new
|
|
// `detailJson`. `refresh()` is the single entry point: ONE `JSON.parse` per
|
|
// republish (spec 01 §15.2), feeding both `doc` and `syncRows` from the same
|
|
// object so the two can never be a republish out of step.
|
|
// A naive `model: JSON.parse(...).items` would push a NEW
|
|
// array into the view on every `resolve_items` badge, every `ensure_expanded`
|
|
// batch and every checkbox — and `QQuickItemView::setModel()` resets contentY
|
|
// to 0 (and can SIGSEGV from inside its own teardown; the analysis is at
|
|
// `views/LibraryView.qml:174-221`). So `syncRows()` keeps the array identity
|
|
// whenever the DERIVATION did not change (same collection, same positions in
|
|
// the same order) and patches the row objects in place, bumping `patchRev` —
|
|
// the notifier a plain JS object mutation does not have. The delegates read
|
|
// `patchRev` (see MyQbzDetailRow.qml's header). The array is replaced only on
|
|
// a real re-derive (sort / filter / search / another collection), which is
|
|
// exactly when Slint also rebuilds and the scroll reset is correct.
|
|
//
|
|
// --- THE GRID ARM MOUNTS THE STANDARD CARD ------------------------------
|
|
// `cards/AlbumCard.qml`, not a My QBZ card. The port used to carry
|
|
// `MyQbzDetailCard.qml`, a 1:1 of the reference's local `DetailCard`
|
|
// (MixtapeDetailView.slint:495-594); the owner rejected it (2026-07-31,
|
|
// "quiero el de siempre con su overlay") and it is DELETED. Select mode is the
|
|
// CARD's (`selectMode`/`selected`/`selectToggled` — the port of the reference
|
|
// card's own `select-mode`, discover/AlbumCard.slint:83), because it has to
|
|
// hide the pin and the hover action row as well as draw the tick; only "Remove
|
|
// from collection" is host knowledge and it rides AlbumCard's
|
|
// `extraMenuEntries` tail.
|
|
//
|
|
// Reused, not rebuilt: rows/TrackRow.qml (inline tracks), cards/AlbumCard,
|
|
// controls/QualityBadgeFull, controls/CardMenu, controls/QbzContextMenu,
|
|
// controls/QbzSegToggle, controls/QbzMultiSelectBar, controls/QbzToolButton,
|
|
// controls/QbzLoadingDots, controls/QbzLineEdit, controls/QbzSelect,
|
|
// controls/QbzCircleAction, cards/CollectionMosaic, views/local/SelectCheck,
|
|
// theme/QbzScrollBar, theme/QbzSpinner.
|
|
//
|
|
// `controls/QbzLineEdit.qml` is NOT 1:1 with `primitives/ExpandableSearch.slint`
|
|
// — five pre-existing, app-wide divergences (leading glyph 13 vs 14 at `sm`,
|
|
// input + placeholder 12px vs 15, row spacing 7 vs 8, focus border `accent`
|
|
// vs `focus-ring`, X hover `surfaceHover` vs `surface-elevated`). Shipped as
|
|
// they are: fixing them changes the Local Library toolbars too (spec 01 §3.2).
|
|
|
|
import QtQuick
|
|
import QtQuick.Window
|
|
import com.blitzfc.qbz
|
|
import "../../cards"
|
|
import "../../controls"
|
|
import "../../rows"
|
|
import "../../theme"
|
|
import "../local"
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
color: ambientOn ? "transparent" : theme.surfaceMain
|
|
readonly property bool ambientOn: theme.ambientOn
|
|
radius: 12
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
function trs(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
|
|
|
// ---------------------- file-private components ----------------------
|
|
|
|
/// One 10px column label of the 8-column header (.slint :1123-1129).
|
|
component ColHead: Text {
|
|
color: theme.textMuted
|
|
font.pixelSize: 10
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 1.2
|
|
height: 34
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
/// A filter-popup section header (.slint :968-978): 20px, 10px semibold,
|
|
/// letter-spacing 0.8, left-aligned.
|
|
component SectionHead: Text {
|
|
width: parent ? parent.width : 0
|
|
height: 20
|
|
color: theme.textMuted
|
|
font.pixelSize: 10
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 0.8
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
|
|
/// One filter row (.slint `MenuOption`, :47-77): 30px, r4, hover
|
|
/// `surface-hover`, padding 8, spacing 8, a leading `SelectCheck` and a
|
|
/// 13px label that goes accent when selected. The WHOLE row is clickable,
|
|
/// not just the box.
|
|
component MenuOption: Rectangle {
|
|
id: mo
|
|
property string label: ""
|
|
property bool selected: false
|
|
signal chosen()
|
|
|
|
width: parent ? parent.width : 0
|
|
height: 30
|
|
radius: 4
|
|
color: moArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 8
|
|
anchors.rightMargin: 8
|
|
spacing: 8
|
|
SelectCheck {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
on: mo.selected
|
|
onToggled: mo.chosen()
|
|
}
|
|
Text {
|
|
width: Math.max(0, parent.width - 21)
|
|
height: parent.height
|
|
text: mo.label
|
|
color: mo.selected ? theme.accent : theme.textPrimary
|
|
font.pixelSize: theme.fontLegal
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: moArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: mo.chosen()
|
|
}
|
|
}
|
|
|
|
// ------------------------------ document ------------------------------
|
|
function parseDoc() {
|
|
try {
|
|
return JSON.parse(QbzMyQbz.detailJson)
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
/// ONE parse per republish (spec 01 §15.2). `refresh()` is the only writer
|
|
/// and the only caller of `syncRows`, so `doc` and `rows` can never be a
|
|
/// republish out of step — the earlier shape had a `readonly` binding here
|
|
/// AND a second `parseDoc()` inside the change handler, i.e. two parses of
|
|
/// a document that in expanded mode carries every item's inline track list.
|
|
/// The initial value is still a BINDING so the very first frame is already
|
|
/// correct (a plain `({})` would flash the not-found state for a frame);
|
|
/// the first `refresh()` assignment replaces it.
|
|
property var doc: root.parseDoc()
|
|
|
|
readonly property bool loading: doc.loading === true
|
|
readonly property bool found: doc.found === true
|
|
// The collapsible My QBZ opt-in gates the sidebar hide/show toggle in the
|
|
// hero overflow — the concept is meaningless while the flat entry is used.
|
|
// GUARDED parse, the NavFlyout.qml:65-70 precedent.
|
|
readonly property bool collapsibleMyqbzOn: {
|
|
try { return JSON.parse(QbzBridge.settingsJson).collapsibleMyqbz === true }
|
|
catch (e) { return false }
|
|
}
|
|
readonly property string kind: doc.kind || ""
|
|
readonly property int itemCount: doc.itemCount || 0
|
|
readonly property string viewMode: doc.viewMode || "list"
|
|
readonly property bool selectMode: doc.selectMode === true
|
|
readonly property int selectedCount: doc.selectedCount || 0
|
|
|
|
// --- Escape hotkeys interface (2026-08-03 hotkeys-port §4.6), EXIT-ONLY.
|
|
// The selection lives in RUST (myqbz_detail_qt.rs positions) and its bulk
|
|
// bar carries no select-all arm, so this view deliberately exposes NO
|
|
// selectAll() — the AppShell capability reporter reads that as
|
|
// "Ctrl+A does not apply here". Escape-exit is available:
|
|
// detailToggleSelectMode off ALSO clears the selection
|
|
// (myqbz_detail_qt.rs:1902-1917).
|
|
readonly property bool multiSelectOn: root.selectMode
|
|
function exitMultiSelectMode() {
|
|
if (root.selectMode) QbzMyQbz.detailToggleSelectMode()
|
|
}
|
|
|
|
readonly property int filterCount: doc.filterCount || 0
|
|
readonly property bool hasAnyFilter: doc.hasAnyFilter === true
|
|
|
|
/// Does the body carry the per-row accordion chevron?
|
|
///
|
|
/// ONLY the DETAILS arm — the `rows-3` segment, the one whose whole point
|
|
/// is showing each item's tracks (the segment id stays `expanded`, which is
|
|
/// now only the arm's name: it no longer means "open all"). The owner asked
|
|
/// for the chevron out of the plain LIST arm (2026-07-31); it is not a
|
|
/// removal of the accordion, which stays his feature, and `rowOpen` stays
|
|
/// the ONE notion of open with Rust as its only writer. Rust AND-s the
|
|
/// published `rowOpen` with this same arm, so list mode cannot render an
|
|
/// open row and no state is orphaned by hiding the affordance — the user's
|
|
/// open set survives the switch instead of being cleared by it.
|
|
readonly property bool rowExpander: root.viewMode === "expanded"
|
|
|
|
// --------------------------- the row model -----------------------------
|
|
property var rows: []
|
|
property string rowsKey: ""
|
|
property int patchRev: 0
|
|
|
|
/// Identity of the current derivation: the collection plus the visible
|
|
/// positions in order. `position` is the stable persisted key (spec 02
|
|
/// §5.2), so this changes exactly when Slint would rebuild its model.
|
|
function rowsSignature(d) {
|
|
var it = d.items || []
|
|
var s = (d.id || "") + "#" + it.length
|
|
for (var i = 0; i < it.length; i++) s += "," + it[i].position
|
|
return s
|
|
}
|
|
function syncRows(d) {
|
|
var fresh = d.items || []
|
|
var sig = root.rowsSignature(d)
|
|
if (sig === root.rowsKey && root.rows.length === fresh.length) {
|
|
for (var i = 0; i < fresh.length; i++) Object.assign(root.rows[i], fresh[i])
|
|
root.patchRev = root.patchRev + 1
|
|
return
|
|
}
|
|
root.rows = fresh
|
|
root.rowsKey = sig
|
|
root.patchRev = root.patchRev + 1
|
|
// `ensureExpanded` is idempotent (cached rows are skipped). Rust fires
|
|
// it from `detailSetViewMode("expanded")`; the OTHER entry is an
|
|
// INITIAL OPEN whose restored view-pref is already `expanded`
|
|
// (spec 01 §7.5), which no invokable covers. Read off `d`, not the
|
|
// `doc` binding, which may not have re-evaluated yet.
|
|
if (d.loading !== true && d.found === true && d.viewMode === "expanded"
|
|
&& root.expandedFor !== (d.id || "")) {
|
|
root.expandedFor = d.id || ""
|
|
QbzMyQbz.ensureExpanded()
|
|
}
|
|
}
|
|
property string expandedFor: ""
|
|
/// The single republish entry point: parse once, publish `doc`, then patch
|
|
/// or replace `rows` off THAT same object. Never read `root.doc` here — the
|
|
/// order between a binding on `doc` and this handler is not defined, which
|
|
/// is why the parse happens locally and is handed to both.
|
|
function refresh() {
|
|
var d = root.parseDoc()
|
|
root.doc = d
|
|
root.syncRows(d)
|
|
}
|
|
/// Merge a PARTIAL row batch from `QbzMyQbz.detailRowsPatched`.
|
|
///
|
|
/// Rust no longer republishes the whole document for a one-row change
|
|
/// (resolved quality, an inline track list landing, a chevron toggle) — it
|
|
/// sends `{ id, rows: [ { position, sourceItemId, ...changed } ] }`. The
|
|
/// document in Rust stays authoritative; this is purely the cheap path to
|
|
/// the SAME live row objects `syncRows` patches, so `patchRev` is bumped
|
|
/// ONCE for the batch and every delegate binding re-evaluates exactly like
|
|
/// after a republish.
|
|
///
|
|
/// `position` is the join column because it is the stable persisted key
|
|
/// (spec 02 §5.2) and it survives a re-derive; the array INDEX does not.
|
|
/// A batch addressed at another collection, or at rows the current
|
|
/// derivation filtered out, is silently dropped — the same guard Rust
|
|
/// makes before building it.
|
|
///
|
|
/// Being a signal, it is a DELTA: an instance mounted after it was emitted
|
|
/// never saw it. That is what `QbzMyQbz.detailResync()` below is for.
|
|
function applyRowPatch(patchJson) {
|
|
var p
|
|
try {
|
|
p = JSON.parse(patchJson)
|
|
} catch (e) {
|
|
return
|
|
}
|
|
if (!p || !p.rows || p.rows.length === 0) return
|
|
if ((p.id || "") !== (root.doc.id || "")) return
|
|
var byPosition = ({})
|
|
for (var i = 0; i < root.rows.length; i++)
|
|
byPosition[root.rows[i].position] = root.rows[i]
|
|
var touched = false
|
|
for (var j = 0; j < p.rows.length; j++) {
|
|
var patch = p.rows[j]
|
|
var target = byPosition[patch.position]
|
|
if (target === undefined) continue
|
|
Object.assign(target, patch)
|
|
touched = true
|
|
}
|
|
if (touched) root.patchRev = root.patchRev + 1
|
|
}
|
|
|
|
Connections {
|
|
target: QbzMyQbz
|
|
function onDetailJsonChanged() { root.refresh() }
|
|
function onDetailRowsPatched(patchJson) { root.applyRowPatch(patchJson) }
|
|
}
|
|
// `doc`'s initial binding has already parsed at this point — reuse it
|
|
// instead of parsing a third time.
|
|
//
|
|
// Then ask Rust to re-hand the document. This view is a `Loader` child
|
|
// (`shell/AppShell.qml:192`), so nav-away DESTROYS it and nav-back builds a
|
|
// NEW instance whose `detailJson` is whatever was last fully published —
|
|
// every row patch since (resolved quality badges, loaded inline tracks,
|
|
// which rows are open) went to the dead instance. One full publish per
|
|
// mount closes that, and it is the only full publish the patch channel
|
|
// still costs.
|
|
Component.onCompleted: {
|
|
root.syncRows(root.doc)
|
|
QbzMyQbz.detailResync()
|
|
}
|
|
|
|
// --- ONE loading-dots clock for the whole view (spec 01 §7.5 / §15.5) --
|
|
// Slint animates each row's LoadingDots off `animation-tick`; a windowed
|
|
// list of hundreds of rows must not. Gating rule, copied from
|
|
// `views/PlaylistView.qml:156-179`: freeze when not visible or the window
|
|
// is minimized / hidden, NEVER on lost focus (a tiling desktop keeps
|
|
// windows visible and unfocused).
|
|
property int dotPhase: 0
|
|
readonly property bool windowShowing: root.Window.window
|
|
? (root.Window.window.visibility !== Window.Minimized
|
|
&& root.Window.window.visibility !== Window.Hidden)
|
|
: true
|
|
readonly property bool anyResolving: {
|
|
if (root.patchRev < 0) return false // `patchRev` dependency
|
|
var r = root.rows
|
|
for (var i = 0; i < r.length; i++) {
|
|
if (r[i].qualityResolving === true && (r[i].qualityTier || "") === "") return true
|
|
}
|
|
return false
|
|
}
|
|
Timer {
|
|
interval: 300
|
|
repeat: true
|
|
running: root.anyResolving && root.visible && root.windowShowing
|
|
onTriggered: root.dotPhase = (root.dotPhase + 1) % 3
|
|
}
|
|
|
|
// ============================== LOADING ==============================
|
|
QbzSpinner {
|
|
visible: root.loading
|
|
anchors.centerIn: parent
|
|
size: 36
|
|
}
|
|
|
|
// ============================= NOT FOUND =============================
|
|
Text {
|
|
visible: !root.loading && !root.found
|
|
anchors.centerIn: parent
|
|
text: root.trs("Not found")
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLink
|
|
}
|
|
|
|
// =============================== LOADED ==============================
|
|
Item {
|
|
id: page
|
|
visible: !root.loading && root.found
|
|
anchors.fill: parent
|
|
|
|
// --------------------------- fixed header -------------------------
|
|
Column {
|
|
id: headerCol
|
|
x: 32
|
|
y: 16
|
|
width: parent.width - 64
|
|
spacing: 0
|
|
|
|
// --- Hero (.slint :663-861) -------------------------------
|
|
Row {
|
|
width: parent.width
|
|
spacing: 32
|
|
|
|
// Rust pre-downscales the hero cells (3 cols -> _50, else
|
|
// _150 — `myqbz_detail.rs:375`), so the mosaic only lays out.
|
|
CollectionMosaic {
|
|
anchors.bottom: parent.bottom
|
|
size: 186
|
|
kind: root.kind
|
|
itemCount: root.itemCount
|
|
hasCustomCover: root.doc.hasCustomCover === true
|
|
customCoverPath: root.doc.customCoverPath || ""
|
|
coverCount: root.doc.coverCount || 0
|
|
urls: root.doc.heroCellUrls || []
|
|
paths: root.doc.heroCellPaths || []
|
|
}
|
|
|
|
Column {
|
|
width: Math.max(0, parent.width - 186 - 32)
|
|
anchors.bottom: parent.bottom
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: root.doc.kindLabel || ""
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightSemibold
|
|
font.letterSpacing: 1.2
|
|
}
|
|
// 18px, NOT 25 — .slint :709-713 calls the old 25px a
|
|
// homologation bug against the other detail heroes.
|
|
Text {
|
|
width: parent.width
|
|
text: root.doc.name || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontSection
|
|
font.weight: theme.weightBold
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
Text {
|
|
visible: (root.doc.description || "") !== ""
|
|
width: Math.min(root.width - 280, 720)
|
|
text: root.doc.description || ""
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLink
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
Text {
|
|
text: root.doc.meta || ""
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLegal
|
|
}
|
|
Item { width: 1; height: 4 }
|
|
|
|
// EXACTLY four controls (.slint :735-859).
|
|
Row {
|
|
spacing: 8
|
|
QbzCircleAction {
|
|
name: "play-fill"
|
|
primary: true
|
|
btnEnabled: root.itemCount > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzMyQbz.playAll()
|
|
}
|
|
QbzCircleAction {
|
|
name: "shuffle"
|
|
btnEnabled: root.itemCount > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzMyQbz.shuffle()
|
|
}
|
|
// ASSET GAP: `turntable` is not baked yet (spec 01 §14).
|
|
QbzCircleAction {
|
|
name: "turntable"
|
|
btnEnabled: root.itemCount > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzMyQbz.djMix()
|
|
}
|
|
Item {
|
|
id: overflowAnchor
|
|
width: 32
|
|
height: 32
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
// Self-sizing (32px, the non-primary arm), so the
|
|
// anchor Item is exactly its box.
|
|
QbzCircleAction {
|
|
name: "ellipsis"
|
|
// Slint anchors this 220px panel `x: 0,
|
|
// y: 36` on the 32px disc (:787-788), so it
|
|
// hugs the trigger's LEFT edge —
|
|
// `openBelowRight` would throw it 188px left.
|
|
onClicked: heroMenu.openBelowLeft(overflowAnchor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item { width: 1; height: 32 }
|
|
|
|
// --- Empty collection (.slint :866-877) — the hero stays, the
|
|
// toolbar and the body do not (:880) ----------------------------
|
|
Item {
|
|
visible: root.itemCount === 0
|
|
width: parent.width
|
|
height: visible ? 80 : 0
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: root.trs("No items yet. Add albums, tracks, or playlists from their detail pages.")
|
|
color: theme.textMuted
|
|
font.pixelSize: 13
|
|
}
|
|
}
|
|
|
|
// --- Sticky toolbar (.slint :885-1086) ------------------------
|
|
Item {
|
|
visible: root.itemCount > 0
|
|
width: parent.width
|
|
height: visible ? 30 : 0
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
height: parent.height
|
|
spacing: 8
|
|
|
|
QbzLineEdit {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
searchMode: true
|
|
expandable: true
|
|
sm: true
|
|
elevated: false
|
|
openWidth: 196
|
|
placeholder: root.trs("Search")
|
|
text: root.doc.search || ""
|
|
onEdited: function (v) { QbzMyQbz.detailSearch(v) }
|
|
}
|
|
|
|
QbzSelect {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
sm: true
|
|
menuWidth: 170
|
|
options: [root.trs("Position"), root.trs("Name"),
|
|
root.trs("Year"), root.trs("Tracks")]
|
|
currentIndex: (root.doc.sort || "position") === "name" ? 1
|
|
: (root.doc.sort || "position") === "year" ? 2
|
|
: (root.doc.sort || "position") === "tracks" ? 3 : 0
|
|
onSelected: function (i) {
|
|
QbzMyQbz.detailSetSort(i === 1 ? "name" : i === 2 ? "year"
|
|
: i === 3 ? "tracks" : "position")
|
|
}
|
|
}
|
|
|
|
// Filter — the LABELED arm, so `fillActive` (accent FILL).
|
|
QbzToolButton {
|
|
id: filterBtn
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: "list-filter"
|
|
label: root.filterCount > 0
|
|
? (root.filterCount === 1 ? root.trs("1 filter")
|
|
: root.trs("{} filters").replace("{}", root.filterCount))
|
|
: root.trs("Filter")
|
|
active: root.filterCount > 0
|
|
fillActive: true
|
|
onClicked: filterMenu.openBelowLeft(filterBtn)
|
|
}
|
|
|
|
// Reset — icon-only, so accent BORDER, never a fill.
|
|
QbzToolButton {
|
|
visible: root.hasAnyFilter
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: "rotate-ccw"
|
|
fillActive: false
|
|
onClicked: QbzMyQbz.detailResetFilters()
|
|
}
|
|
|
|
QbzToolButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: "square-check-big"
|
|
active: root.selectMode
|
|
fillActive: false
|
|
onClicked: QbzMyQbz.detailToggleSelectMode()
|
|
}
|
|
|
|
QbzSegToggle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 90
|
|
mode: root.viewMode
|
|
segments: [{ "id": "list", "icon": "list" },
|
|
{ "id": "grid", "icon": "layout-grid" },
|
|
{ "id": "expanded", "icon": "rows-3" }]
|
|
// The 3-segment ViewToggle metric set, NOT the control's
|
|
// Local defaults: MixtapeDetailView.slint:1058-1084 is a
|
|
// 90x30 r6 surface-elevated well holding THREE
|
|
// `ToggleButton { sm }` edge to edge — 30x30 each, no
|
|
// gap, 15px glyph, active fill surface-hover (never
|
|
// accent), hover surface-elevated, tint text-primary /
|
|
// text-muted (ToggleButton.slint:19-20,24-28,32-33,38).
|
|
// `segRadius` 6 rather than the reference's 0 because a
|
|
// QML clip is rectangular — see QbzSegToggle.qml.
|
|
segWidth: 30
|
|
segHeight: 30
|
|
segRadius: 6
|
|
segSpacing: 0
|
|
glyphSize: 15
|
|
activeFill: theme.surfaceHover
|
|
hoverFill: theme.surfaceElevated
|
|
activeTint: "textPrimary"
|
|
idleTint: "muted"
|
|
onSetMode: function (m) { QbzMyQbz.detailSetViewMode(m) }
|
|
}
|
|
}
|
|
}
|
|
|
|
Item { visible: root.itemCount > 0; width: 1; height: visible ? 10 : 0 }
|
|
}
|
|
|
|
// ----------------------------- the body ----------------------------
|
|
Item {
|
|
id: body
|
|
visible: root.itemCount > 0
|
|
anchors.top: headerCol.bottom
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
anchors.leftMargin: 32
|
|
anchors.rightMargin: 32
|
|
// NOT `bottomMargin: 100`. The .slint's 100px is `padding-bottom`
|
|
// INSIDE the Flickable (:657) — scroll RUNWAY past the last row,
|
|
// clearing the player bar. As an anchor margin here it shrank the
|
|
// ListView/GridView VIEWPORT instead: the last row was clipped
|
|
// mid-height and ~85px of dead band sat under it, unusable and
|
|
// unscrollable, on every collection. The runway now lives on the
|
|
// flickables themselves (`bottomMargin: 100` below), which is what
|
|
// the sibling `MyQbzGridView.qml:336,358` already does right.
|
|
anchors.bottomMargin: 0
|
|
|
|
// ================= BODY: LIST / EXPANDED =================
|
|
Column {
|
|
id: listHost
|
|
anchors.fill: parent
|
|
visible: root.viewMode !== "grid"
|
|
spacing: 0
|
|
|
|
Rectangle { width: parent.width; height: 1; color: theme.surfaceElevated }
|
|
|
|
// Column header (.slint :1114-1135) — the 8th cell is a bare
|
|
// 40px spacer, not a label (:1130).
|
|
Item {
|
|
width: parent.width
|
|
height: 34
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
spacing: 12
|
|
// The accordion chevron's leading cell (see
|
|
// MyQbzDetailRow.qml). Unlabelled — a header for a
|
|
// disclosure control would be noise — but while the
|
|
// details arm mounts it, it MUST be here at exactly the
|
|
// row's 24px or the eight reference columns below drift
|
|
// 36px out of the header. `visible: false` drops the
|
|
// cell AND its 12px gap, exactly like the row's.
|
|
Item { visible: root.rowExpander; width: 24; height: 1 }
|
|
ColHead { width: 40; text: "#"; horizontalAlignment: Text.AlignHCenter }
|
|
ColHead {
|
|
// Paired with MyQbzDetailRow's `titleColWidth`:
|
|
// 24 padding + 592 fixed cells + 84 gaps = 700,
|
|
// plus the chevron's 24 + 12 while it is mounted.
|
|
width: Math.max(0,
|
|
listHost.width - (root.rowExpander ? 736 : 700))
|
|
text: root.trs("Item")
|
|
}
|
|
ColHead { width: 140; text: root.trs("Type") }
|
|
ColHead { width: 80; text: root.trs("Source") }
|
|
ColHead { width: 160; text: root.trs("Quality") }
|
|
ColHead { width: 72; text: root.trs("Tracks"); horizontalAlignment: Text.AlignRight }
|
|
ColHead { width: 60; text: root.trs("Year"); horizontalAlignment: Text.AlignRight }
|
|
Item { width: 40; height: 1 }
|
|
}
|
|
}
|
|
|
|
Rectangle { width: parent.width; height: 1; color: theme.surfaceElevated }
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: Math.max(0, listHost.height - 36)
|
|
|
|
ListView {
|
|
id: itemList
|
|
anchors.fill: parent
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
cacheBuffer: 500
|
|
reuseItems: true
|
|
// The .slint's 100px page bottom padding, as scroll
|
|
// runway past the last row (clears the player bar).
|
|
bottomMargin: 100
|
|
model: root.rows
|
|
|
|
// EXPANDED mode is this SAME recycled list with the
|
|
// inline block unhidden — a nested ListView inside a
|
|
// recycled delegate cannot size itself (spec 02 §9.1),
|
|
// and switching the delegate per mode would reset
|
|
// contentY. The per-row ACCORDION rides the identical
|
|
// machinery: the outer list stays recycled and windowed
|
|
// whatever is open, and an open row's tracks stay a
|
|
// bounded `Repeater` inside a `Column` delegate, which
|
|
// is content-sized and therefore safe. Do not "upgrade"
|
|
// that Repeater to a nested ListView.
|
|
delegate: Column {
|
|
id: rowCell
|
|
required property var modelData
|
|
required property int index
|
|
width: itemList.width
|
|
spacing: 0
|
|
|
|
/// THE LIVE ROW, and the reason every read below goes
|
|
/// through it instead of `modelData`.
|
|
///
|
|
/// `model: root.rows` is a JavaScript array, and
|
|
/// QQmlDelegateModel SNAPSHOTS such a model — the
|
|
/// delegate's `modelData` is the engine's own copy of
|
|
/// the element, not the object in `root.rows`. So
|
|
/// `syncRows`' in-place `Object.assign` patch (the
|
|
/// whole point of which is to update a row WITHOUT
|
|
/// reassigning the array and throwing the user's
|
|
/// scroll position to the top) mutated objects the
|
|
/// delegates could never see. Bumping `patchRev` then
|
|
/// dutifully re-evaluated every binding — against the
|
|
/// stale copy.
|
|
///
|
|
/// Measured: 34 items resolved Rust-side with correct
|
|
/// tiers and 34 publishes, 71 QML refreshes, and the
|
|
/// Quality column stayed on its skeleton forever.
|
|
/// Expanded mode rendered nothing for the same reason
|
|
/// (`inlineTracks` is patched in exactly this way).
|
|
///
|
|
/// Indexing the live array closes it: `patchRev` is
|
|
/// the dependency, `root.rows[index]` is the object
|
|
/// Rust actually patched. `modelData` stays the
|
|
/// fallback for the frame where the array and the
|
|
/// delegate model are momentarily out of step.
|
|
readonly property var live: root.patchRev >= 0
|
|
? (root.rows[index] || modelData) : modelData
|
|
|
|
/// ONE notion of open, and it is Rust's `rowOpen`.
|
|
///
|
|
/// It used to be `viewMode === "expanded" &&
|
|
/// canExpand`, i.e. a second, QML-side definition
|
|
/// of the same thing. With the accordion there are
|
|
/// two writers (the chevron and the segment) and
|
|
/// two definitions would disagree the first time a
|
|
/// row was closed inside expanded mode — so the
|
|
/// view mode is not read here at all. Rust publishes
|
|
/// `rowOpen` only for a row the USER opened while
|
|
/// the details arm is up; `canExpand` is implied,
|
|
/// because a non-expandable row is never marked
|
|
/// open.
|
|
///
|
|
/// Through `live`, like every other row read — see
|
|
/// the note above.
|
|
readonly property bool expanded: root.patchRev >= 0
|
|
&& rowCell.live.rowOpen === true
|
|
readonly property var inlineTracks: (root.patchRev >= 0)
|
|
? (rowCell.live.inlineTracks || []) : []
|
|
readonly property bool expandLoading: root.patchRev >= 0
|
|
&& rowCell.live.expandLoading === true
|
|
readonly property bool tracksLoaded: root.patchRev >= 0
|
|
&& rowCell.live.tracksLoaded === true
|
|
|
|
MyQbzDetailRow {
|
|
width: parent.width
|
|
item: rowCell.live
|
|
ordinal: rowCell.index + 1
|
|
selectMode: root.selectMode
|
|
showExpander: root.rowExpander
|
|
dotPhase: root.dotPhase
|
|
rev: root.patchRev
|
|
detailKind: root.kind
|
|
}
|
|
|
|
// Inline tracks (.slint :1151-1218): padding
|
|
// 52 / 12 / 4 / 8, spacing 0.
|
|
//
|
|
// The reference's 52 is `12 padding + 40 ordinal
|
|
// cell`, i.e. exactly where the row's artwork
|
|
// column starts; the chevron cell adds `24 + 12` in
|
|
// front of the ordinal, so the same alignment is 88
|
|
// whenever the chevron is mounted. The right inset
|
|
// stays 12 either way (width - x - 12). Keep it tied
|
|
// to the row's template — off by one cell and the
|
|
// inline block stops hanging under its own title.
|
|
//
|
|
// Only the details arm can have an open row today
|
|
// (see `rowExpander`), so the 88 branch is the live
|
|
// one; the 52 keeps the two definitions honest.
|
|
Item {
|
|
visible: rowCell.expanded
|
|
width: parent.width
|
|
height: visible ? inlineCol.height + 12 : 0
|
|
Column {
|
|
id: inlineCol
|
|
x: root.rowExpander ? 88 : 52
|
|
y: 4
|
|
width: Math.max(0, parent.width - inlineCol.x - 12)
|
|
spacing: 0
|
|
|
|
Item {
|
|
visible: rowCell.expandLoading
|
|
&& rowCell.inlineTracks.length === 0
|
|
width: parent.width
|
|
height: visible ? 32 : 0
|
|
QbzSpinner { anchors.centerIn: parent; size: 16 }
|
|
}
|
|
Item {
|
|
visible: !rowCell.expandLoading && rowCell.tracksLoaded
|
|
&& rowCell.inlineTracks.length === 0
|
|
width: parent.width
|
|
height: visible ? 32 : 0
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: root.trs("No results found")
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
}
|
|
}
|
|
// Sub-header for the expanded track list.
|
|
// The item's OWN columns (Item/Type/Source/
|
|
// Quality/Tracks/Year) name the collection
|
|
// rows, not these child tracks — so a small,
|
|
// muted, column-aligned label band sits over
|
|
// the inline tracks instead. Reuses the
|
|
// shared TrackListHeader with the SAME arms
|
|
// the inline TrackRow sets (menu only), so
|
|
// "#", "Title", "Duration", "Quality" land on
|
|
// their columns by construction. Shown only
|
|
// once the tracks have loaded (never over the
|
|
// spinner or the empty state).
|
|
Item {
|
|
visible: !rowCell.expandLoading
|
|
&& rowCell.tracksLoaded
|
|
&& rowCell.inlineTracks.length > 0
|
|
width: parent.width
|
|
height: visible ? 22 : 0
|
|
TrackListHeader {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
showArtwork: false
|
|
showSource: false
|
|
showAlbum: false
|
|
showFavorite: false
|
|
showDownload: false
|
|
showMenu: true
|
|
showReorder: false
|
|
}
|
|
// Discreet hairline under the labels.
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
height: 1
|
|
color: theme.surfaceElevated
|
|
}
|
|
}
|
|
Repeater {
|
|
model: rowCell.inlineTracks
|
|
delegate: TrackRow {
|
|
required property var modelData
|
|
required property int index
|
|
width: inlineCol.width
|
|
item: modelData
|
|
// `number` is a DELEGATE property,
|
|
// never an item field (spec 02 §5.2).
|
|
number: index + 1
|
|
showArtwork: false
|
|
showFavorite: false
|
|
showDownload: false
|
|
showMenu: true
|
|
// `showAlbum` is LEFT AT ITS DEFAULT
|
|
// (false) — TrackRow.slint:43 does the
|
|
// same and MixtapeDetailView does not
|
|
// override it, so there is no album
|
|
// LINK on these rows.
|
|
draggable: false
|
|
menuShowRemove: false
|
|
// `primitives/TrackRow.slint:125`
|
|
// zebras UNCONDITIONALLY on odd
|
|
// `index` (there is no `zebra`
|
|
// property to turn it off), so the
|
|
// inline block IS striped in the
|
|
// reference. Qt gates it and
|
|
// defaults false; `number % 2 === 0`
|
|
// with `number = index + 1` stripes
|
|
// exactly the same rows.
|
|
zebra: true
|
|
// Go-to must route through the PARENT
|
|
// item, not the track's own ids —
|
|
// spec 01 §7.5 / OQ#9.
|
|
routeGoToExternally: true
|
|
onPlayRequested: QbzMyQbz.inlineTrackAction(
|
|
rowCell.live.sourceItemId || "",
|
|
modelData.id || "", "play")
|
|
// `next|later|queue` -> the wire's
|
|
// `play-next|play-later|queue`.
|
|
// Unmapped is a SILENT no-op.
|
|
onEnqueueRequested: function (mode) {
|
|
QbzMyQbz.inlineTrackAction(
|
|
rowCell.live.sourceItemId || "",
|
|
modelData.id || "",
|
|
mode === "next" ? "play-next"
|
|
: mode === "later" ? "play-later" : "queue")
|
|
}
|
|
onGoToRequested: function (goKind) {
|
|
if (goKind === "artist")
|
|
QbzMyQbz.openArtist(
|
|
rowCell.live.source || "",
|
|
modelData.artist || "",
|
|
modelData.artistId || "")
|
|
else
|
|
QbzMyQbz.inlineTrackAction(
|
|
rowCell.live.sourceItemId || "",
|
|
modelData.id || "", "go-to-album")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// ======================= BODY: GRID =======================
|
|
// THE STANDARD `cards/AlbumCard.qml`, at its app-wide size, with
|
|
// its own hover overlay and its own ⋯ / right-click menu.
|
|
//
|
|
// This REPLACES the port's `MyQbzDetailCard.qml` (deleted), which
|
|
// was a faithful 1:1 of the reference's local `DetailCard`
|
|
// (MixtapeDetailView.slint:495-594): a 150px-ish hand-rolled tile
|
|
// with no overlay buttons, no quality badge, no source badge and no
|
|
// menu. The owner ruled against that card (2026-07-31): "quiero el
|
|
// de siempre con su overlay". So this arm is a DELIBERATE, OWNER-
|
|
// ORDERED divergence from the .slint, and the .slint's `auto-fill
|
|
// minmax(150,1fr)` / gap-20 arithmetic went with it.
|
|
//
|
|
// Geometry is now the one every other album grid in the app uses
|
|
// (`views/AlbumCollection.qml:44-46`): a fixed 200x246 card in a
|
|
// 200x266 cell with a 24px gap. AlbumCard hardcodes its 200px
|
|
// width, so this grid cannot be fluid — matching the app is both
|
|
// the correct and the only option.
|
|
Item {
|
|
id: gridHost
|
|
anchors.fill: parent
|
|
visible: root.viewMode === "grid"
|
|
clip: true
|
|
|
|
readonly property int cardWidth: 200
|
|
readonly property int cardHeight: 266
|
|
readonly property int cardGap: 24
|
|
|
|
GridView {
|
|
id: itemGrid
|
|
x: 12
|
|
y: 0
|
|
// The app grid takes `floor((avail + gap) / (card + gap))`
|
|
// columns over `avail = width - 24` (the 12px content inset
|
|
// on both sides); GridView takes `floor(width / cellWidth)`.
|
|
// Since the gap and the two insets are both 24, handing it
|
|
// the UNINSET width makes the two agree exactly — the same
|
|
// correction the old 150/20 arm made with `+ 20 - 24`.
|
|
width: Math.max(0, gridHost.width)
|
|
height: gridHost.height
|
|
cellWidth: gridHost.cardWidth + gridHost.cardGap
|
|
cellHeight: gridHost.cardHeight + gridHost.cardGap
|
|
cacheBuffer: 500
|
|
reuseItems: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
// Same runway as the list arm — see `body`'s note.
|
|
bottomMargin: 100
|
|
model: root.rows
|
|
|
|
delegate: Item {
|
|
id: gcell
|
|
required property var modelData
|
|
required property int index
|
|
width: gridHost.cardWidth
|
|
height: gridHost.cardHeight
|
|
|
|
// Live row, not the delegate model's snapshot — see the
|
|
// list delegate's `live` note. AlbumCard has no `rev`
|
|
// property, so EVERY leaf below carries the
|
|
// `root.patchRev >= 0` guard itself: that is what makes
|
|
// `patchRev` a captured dependency of the binding, and
|
|
// an in-place `Object.assign` patch fires no notifier of
|
|
// its own. Always true; never "simplify" it away.
|
|
readonly property var live: root.patchRev >= 0
|
|
? (root.rows[index] || modelData) : modelData
|
|
readonly property int cPosition: root.patchRev >= 0 ? (gcell.live.position || 0) : 0
|
|
readonly property string cItemId: root.patchRev >= 0 ? (gcell.live.sourceItemId || "") : ""
|
|
readonly property string cItemType: root.patchRev >= 0 ? (gcell.live.itemType || "") : ""
|
|
readonly property string cSource: root.patchRev >= 0 ? (gcell.live.source || "") : ""
|
|
readonly property string cTitle: root.patchRev >= 0 ? (gcell.live.title || "") : ""
|
|
readonly property string cSubtitle: root.patchRev >= 0 ? (gcell.live.subtitle || "") : ""
|
|
readonly property string cYear: root.patchRev >= 0 ? (gcell.live.yearText || "") : ""
|
|
readonly property string cQuality: root.patchRev >= 0 ? (gcell.live.qualityTier || "") : ""
|
|
readonly property string cDetail: root.patchRev >= 0 ? (gcell.live.qualityDetail || "") : ""
|
|
/// The GRID rung of the cover, NOT the list row's.
|
|
/// `artPath` is the 50px thumbnail every list row draws
|
|
/// at 40px; this cell's artwork well is 200px, so
|
|
/// reading `artPath` here upscaled it 4x — the owner's
|
|
/// "la calidad de la portada es menor que la del
|
|
/// albumcard". Rust publishes both rungs per row
|
|
/// (`myqbz_detail_qt::DetailRow::art_url_large`) so the
|
|
/// list/grid toggle stays instant: re-deriving one rung
|
|
/// per view mode would republish the document, and a
|
|
/// republish resets the view's scroll offset.
|
|
readonly property string cArt: root.patchRev >= 0 ? (gcell.live.artPathLarge || "") : ""
|
|
/// The REMOTE url of that same rung — AlbumCard's
|
|
/// `artworkUrl`, i.e. the pin payload and the block
|
|
/// snapshot, never the `file://` cache path.
|
|
readonly property string cArtUrl: root.patchRev >= 0 ? (gcell.live.artUrlLarge || "") : ""
|
|
readonly property string cSourceKind: root.patchRev >= 0 ? (gcell.live.sourceKind || "") : ""
|
|
readonly property bool cSelected: root.patchRev >= 0 && gcell.live.selected === true
|
|
readonly property bool cFavorite: root.patchRev >= 0 && gcell.live.isFavorite === true
|
|
readonly property string cCardIdentity:
|
|
gcell.cItemType + ":" + gcell.cItemId
|
|
/// Is THIS cell's `sourceItemId` a Qobuz catalog album
|
|
/// id? The per-item gate for the card's catalog-only
|
|
/// affordances (heart and "Block this album"), and the
|
|
/// QML twin of `myqbz_detail_qt::is_catalog_album` — the
|
|
/// two must agree for the favorite seed. Pinning has a
|
|
/// separate string-key gate below so local albums can
|
|
/// participate too.
|
|
///
|
|
/// The STORED source (`source`, "qobuz" | "local"), not
|
|
/// the resolved `sourceKind`: the stored word is what
|
|
/// makes the id a catalog id. And the TYPE, because a
|
|
/// Qobuz track item carries a track id and a Qobuz
|
|
/// playlist item a playlist id — hearting either as
|
|
/// "album" would hit a different entity.
|
|
readonly property bool cCatalogAlbum: gcell.cSource === "qobuz"
|
|
&& gcell.cItemType === "album" && gcell.cItemId !== ""
|
|
/// track → music, playlist → list-music, else disc —
|
|
/// the reference card's empty-well glyph (.slint
|
|
/// :522-526), carried over on AlbumCard's opt-in
|
|
/// `placeholderIcon`.
|
|
readonly property string cGlyph: gcell.cItemType === "track" ? "music"
|
|
: (gcell.cItemType === "playlist" ? "list-music" : "disc")
|
|
|
|
/// RE-ESTABLISH the two self-mutating bindings whenever
|
|
/// this delegate changes row.
|
|
///
|
|
/// `AlbumCard` writes its OWN `isFavorite` / `isPinned`
|
|
/// — optimistically in `toggleFavorite()` /
|
|
/// `togglePin()`, and again from its
|
|
/// `libraryFavoriteChanged` / `pinChanged` Connections —
|
|
/// and a QML property assignment DESTROYS the binding
|
|
/// that fed it. On a non-recycling host that is
|
|
/// harmless: the delegate dies with its row. This grid
|
|
/// sets `reuseItems: true`, so the very same AlbumCard
|
|
/// instance is handed the NEXT row, with a dead
|
|
/// `isFavorite` binding — it would keep the previous
|
|
/// album's filled heart, and clicking it would toggle
|
|
/// the new album the wrong way (glyph says "remove", the
|
|
/// call adds). Same for the pin badge.
|
|
///
|
|
/// This is the idiom the port already uses for exactly
|
|
/// this hazard on the cards that own an `item`
|
|
/// (`cards/TrackCard.qml:49`, `cards/PlaylistCard.qml:117`,
|
|
/// `rows/TrackRow.qml:233`, `views/library/FeedListRow.qml:39`);
|
|
/// AlbumCard takes flat properties instead of an `item`,
|
|
/// so the restore belongs to the recycling HOST. Keyed
|
|
/// on the type + id pair, which is derived from
|
|
/// `index` and therefore changes on every reuse.
|
|
onCCardIdentityChanged: gcell.rebindCardState()
|
|
function rebindCardState() {
|
|
// The change signal can fire while the delegate is
|
|
// still being built, before the child below exists;
|
|
// at that point nothing has broken a binding yet, so
|
|
// skipping is the correct answer, not a workaround.
|
|
if (!gcard)
|
|
return
|
|
gcard.isFavorite = Qt.binding(function () { return gcell.cFavorite })
|
|
gcard.isPinned = Qt.binding(function () {
|
|
return gcell.cItemType === "album"
|
|
? QbzLibrary.pinState("album", gcell.cItemId)
|
|
: false
|
|
})
|
|
}
|
|
|
|
AlbumCard {
|
|
id: gcard
|
|
// localMode = ROUTING: open / play / enqueue go to
|
|
// the host signals below, because a My QBZ item can
|
|
// be a Plex or local album, a track or a playlist
|
|
// and only `QbzMyQbz` knows how to open and play
|
|
// one. TRUE for every cell, no exceptions.
|
|
localMode: true
|
|
// …but the catalog-only affordances are decided PER
|
|
// ITEM, which is the whole point of the split (see
|
|
// AlbumCard's `catalogAffordances`). A My QBZ
|
|
// container is multi-source per ROW: a Qobuz album
|
|
// cell's id IS a catalog album id and its heart /
|
|
// pin / block are perfectly valid. Local/server
|
|
// albums below opt back into pin independently;
|
|
// tracks/playlists still keep album affordances
|
|
// absent rather than dead.
|
|
catalogAffordances: gcell.cCatalogAlbum
|
|
// The heterogeneous grid also uses AlbumCard for
|
|
// tracks/playlists. Quick View belongs only to its
|
|
// real album rows, including local/server albums.
|
|
quickViewAffordance: gcell.cItemType === "album"
|
|
pinAffordance: gcell.cItemType === "album"
|
|
// Seeded by Rust behind the SAME predicate, then
|
|
// settled by the card itself on
|
|
// `QbzLibrary.libraryFavoriteChanged` / `pinChanged`
|
|
// — so a heart flipped here, on the album page or
|
|
// on a Home rail agrees everywhere, and no toggle
|
|
// republishes this document.
|
|
isFavorite: gcell.cFavorite
|
|
isPinned: pinAffordance
|
|
? QbzLibrary.pinState("album", gcell.cItemId) : false
|
|
albumId: gcell.cItemId
|
|
title: gcell.cTitle
|
|
artist: gcell.cSubtitle
|
|
// No artist link: the reference grid card's subtitle
|
|
// is a plain Text (.slint :578-583), and a My QBZ
|
|
// subtitle can be a Plex/local artist whose id would
|
|
// route QbzArtist.openArtist into a Qobuz lookup.
|
|
artistId: ""
|
|
genre: ""
|
|
year: gcell.cYear
|
|
qualityTier: gcell.cQuality
|
|
qualityDetail: gcell.cDetail
|
|
artSource: gcell.cArt
|
|
// The pin payload / block snapshot url (see
|
|
// `cArtUrl`). Without it a pinned My QBZ album
|
|
// lands in the Pinned rail as a placeholder.
|
|
artworkUrl: gcell.cArtUrl
|
|
pinArtworkUrl: artworkUrl !== "" ? artworkUrl : artSource
|
|
// Badge ON: a My QBZ container is multi-source BY
|
|
// DEFINITION and the sibling LIST arm gives every
|
|
// row a Source column (col 4, the same
|
|
// `sourceKind` through the same `SourceIcon`), so
|
|
// the grid arm hiding it would be the odd one out.
|
|
// All four kinds `sourceKind` can carry —
|
|
// qobuz / plex / local / offline — draw a mark;
|
|
// only "" (nothing resolved yet) draws none, which
|
|
// is AlbumCard's `source !== ""` gate.
|
|
source: gcell.cSourceKind
|
|
showSourceBadge: true
|
|
placeholderIcon: gcell.cGlyph
|
|
// The first menu entry's NOUN. `open_item` sends
|
|
// "album" AND "track" to the album page and only
|
|
// "playlist" elsewhere (myqbz_detail_qt.rs:1893-1901,
|
|
// 1:1 with qbz/src/main.rs:6275-6288), so "Open
|
|
// album" is accurate on an album and on a track and
|
|
// wrong on exactly one arm. A label, not an action:
|
|
// the routing below is unchanged.
|
|
openLabel: gcell.cItemType === "playlist"
|
|
? root.trs("Open playlist") : ""
|
|
// The container action the card cannot know about.
|
|
// Same msgid, same icon and same destructive styling
|
|
// as the list row's last entry
|
|
// (MyQbzDetailRow.qml:457-458 == .slint :481-486),
|
|
// after the same separator. It applies to a
|
|
// Collection, a Mixtape and an artist Collection
|
|
// alike: `remove_item` removes by POSITION from
|
|
// whatever container is open
|
|
// (myqbz_edit_qt.rs:360-362), so there is no arm
|
|
// here that could render and no-op.
|
|
extraMenuEntries: [
|
|
{ "sep": true },
|
|
{ "label": QbzSession.tr("Remove from collection", QbzSession.trRev),
|
|
"icon": "trash-2", "action": "remove", "danger": true },
|
|
]
|
|
onExtraMenuAction: function (a) {
|
|
if (a === "remove") QbzMyQbz.removeItem(gcell.cPosition)
|
|
}
|
|
// SELECT MODE is the card's, not this host's
|
|
// (discover/AlbumCard.slint:83 declares
|
|
// `select-mode` and :179-239 hides the pin and the
|
|
// whole hover action row behind it). Overlaying a
|
|
// tick from here — the shape
|
|
// `views/local/LocalAlbumCollection.qml` used until
|
|
// 2026-08-01 — can only add the indicator: it leaves
|
|
// the hover
|
|
// PLAY button live under the selection, so hovering
|
|
// a card you meant to tick and hitting play starts
|
|
// the album instead. The owner's own instruction
|
|
// for this round was to extend AlbumCard with the
|
|
// selection checkbox rather than bolt one on.
|
|
selectMode: root.selectMode
|
|
selected: gcell.cSelected
|
|
onSelectToggled: QbzMyQbz.detailToggleItemSelect(gcell.cPosition)
|
|
// Card body click, non-select mode only (the card
|
|
// routes a select-mode click to `selectToggled`).
|
|
onOpenRequested: QbzMyQbz.openItem(gcell.cSource,
|
|
gcell.cItemType, gcell.cItemId)
|
|
onPlayRequested: QbzMyQbz.playItem(gcell.cItemId)
|
|
// AlbumCard's `next|later|queue` -> the wire's
|
|
// `play-next|play-later|add-to-queue`
|
|
// (myqbz_play_qt.rs:84-91), the same mapping the
|
|
// row menu hands `itemAction`.
|
|
onEnqueueRequested: function (mode) {
|
|
QbzMyQbz.itemAction(gcell.cItemId,
|
|
mode === "next" ? "play-next"
|
|
: mode === "later" ? "play-later" : "add-to-queue")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The .slint floats its ListScrollbar at `parent.width - 14 - 4`
|
|
// (:1226-1235), i.e. INSIDE the page's 32px right padding. Mounted
|
|
// here, outside `body`, because the grid host clips.
|
|
// 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: itemList; scope: "mixtapedetail" }
|
|
QbzScrollBar {
|
|
target: itemList
|
|
visible: root.itemCount > 0 && root.viewMode !== "grid"
|
|
&& itemList.contentHeight > itemList.height
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
anchors.top: body.top
|
|
anchors.bottom: body.bottom
|
|
}
|
|
// 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: itemGrid; scope: "mixtapedetail" }
|
|
QbzScrollBar {
|
|
target: itemGrid
|
|
visible: root.itemCount > 0 && root.viewMode === "grid"
|
|
&& itemGrid.contentHeight > itemGrid.height
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
anchors.top: body.top
|
|
anchors.bottom: body.bottom
|
|
}
|
|
}
|
|
|
|
// ========================= BULK ACTION BAR ==========================
|
|
// A SIBLING of the scroller, floating over the last 100px of scroll runway
|
|
// (which is now inside the flickables, not carved out of the viewport —
|
|
// see `body`). `parent.width - 18 - 22` is asymmetric on purpose
|
|
// (.slint :1253).
|
|
QbzMultiSelectBar {
|
|
visible: root.selectMode && root.selectedCount > 0
|
|
x: 18
|
|
y: parent.height - height - 12
|
|
width: parent.width - 40
|
|
selectedCount: root.selectedCount
|
|
actions: root.bulkActions()
|
|
onAction: function (id) { QbzMyQbz.bulkAction(id) }
|
|
}
|
|
|
|
// Order is the .slint's (:1258-1266) and it puts LATER before NEXT — the
|
|
// opposite of the row menu. Do not "fix" it.
|
|
function bulkActions() {
|
|
var t = QbzSession.tr
|
|
var r = QbzSession.trRev
|
|
return [
|
|
{ "id": "add-to-queue", "label": t("Add to queue", r), "icon": "list-end",
|
|
"danger": false, "needsSelection": true },
|
|
{ "id": "play-later", "label": t("Play later", r), "icon": "list-plus",
|
|
"danger": false, "needsSelection": true },
|
|
{ "id": "play-next", "label": t("Play next", r), "icon": "list-start",
|
|
"danger": false, "needsSelection": true },
|
|
{ "id": "add-to-playlist", "label": t("Add to playlist", r), "icon": "list-music",
|
|
"danger": false, "needsSelection": true },
|
|
{ "id": "add-to-mixtape", "label": t("Add to Mixtape/Collection", r),
|
|
"icon": "cassette-tape", "danger": false, "needsSelection": true },
|
|
{ "id": "remove-selected", "label": t("Remove", r), "icon": "trash-2",
|
|
"danger": true, "needsSelection": true },
|
|
{ "id": "clear", "label": t("Clear", r), "icon": "x",
|
|
"danger": false, "needsSelection": true },
|
|
]
|
|
}
|
|
|
|
// ========================== HERO ⋯ MENU =============================
|
|
CardMenu {
|
|
id: heroMenu
|
|
menuWidth: 220
|
|
entries: root.heroMenuModel()
|
|
onPicked: function (a) { root.heroMenuAction(a) }
|
|
}
|
|
|
|
function heroMenuModel() {
|
|
var t = QbzSession.tr
|
|
var r = QbzSession.trRev
|
|
// ASSET GAP: `image` is not baked yet (spec 01 §14).
|
|
var m = [
|
|
{ "label": t("Rename", r), "icon": "pen-line", "action": "rename" },
|
|
{ "label": t("Edit description", r), "icon": "pen-line", "action": "description" },
|
|
{ "label": t("Upload cover", r), "icon": "image", "action": "upload-cover" },
|
|
]
|
|
if (root.doc.hasCustomCover === true)
|
|
m.push({ "label": t("Clear custom cover", r), "icon": "x", "action": "clear-cover" })
|
|
m.push({ "sep": true })
|
|
// The label is the OTHER mode (.slint :831-837).
|
|
m.push({ "label": (root.doc.playMode || "in_order") === "in_order"
|
|
? t("Album shuffle", r) : t("In order", r),
|
|
"icon": "shuffle", "action": "play-mode" })
|
|
if (root.kind !== "artist_collection")
|
|
m.push({ "label": root.kind === "mixtape"
|
|
? t("Convert to Collection", r) : t("Convert to Mixtape", r),
|
|
"icon": "cassette-tape", "action": "convert" })
|
|
// Sidebar visibility — only meaningful with the collapsible tree on.
|
|
// The label reflects the CURRENT state (the mechanism playlists carry).
|
|
if (root.collapsibleMyqbzOn) {
|
|
m.push({ "sep": true })
|
|
m.push({ "label": root.doc.hidden === true
|
|
? t("Show in sidebar", r) : t("Hide from sidebar", r),
|
|
"icon": root.doc.hidden === true ? "eye" : "eye-off",
|
|
"action": "toggle-hidden" })
|
|
}
|
|
m.push({ "sep": true })
|
|
m.push({ "label": t("Delete", r), "icon": "trash-2", "action": "delete", "danger": true })
|
|
return m
|
|
}
|
|
|
|
function heroMenuAction(a) {
|
|
if (a === "rename") QbzMyQbz.openRename()
|
|
else if (a === "description") QbzMyQbz.openDescription()
|
|
else if (a === "upload-cover") QbzMyQbz.uploadCover()
|
|
else if (a === "clear-cover") QbzMyQbz.removeCover()
|
|
else if (a === "play-mode") QbzMyQbz.togglePlayMode()
|
|
else if (a === "convert") QbzMyQbz.convertKind()
|
|
else if (a === "toggle-hidden")
|
|
QbzMyQbz.setCollectionHidden(root.doc.id, root.doc.hidden !== true)
|
|
else if (a === "delete") QbzMyQbz.openDelete()
|
|
}
|
|
|
|
// ========================= FILTER POPUP =============================
|
|
// `QbzContextMenu` HARDCODES padding 5 / `surfaceMain` / r8 /
|
|
// `borderMuted` and its content Column's spacing is unreachable through
|
|
// the `menuContent` alias, so the panel, the padding and the row spacing
|
|
// are all written here (spec 01 §3.3). Its default
|
|
// `CloseOnPressOutside` is what the .slint asks for (:952-956): these are
|
|
// radio / checkbox rows that must survive several toggles.
|
|
QbzContextMenu {
|
|
id: filterMenu
|
|
menuWidth: 200
|
|
padding: 4
|
|
background: Rectangle {
|
|
color: theme.surfaceMain
|
|
radius: 8
|
|
border.width: 1
|
|
border.color: theme.surfaceElevated
|
|
}
|
|
Column {
|
|
width: parent ? parent.width : 0
|
|
spacing: 1
|
|
|
|
SectionHead { text: root.trs("Type") }
|
|
MenuOption {
|
|
label: root.trs("All types")
|
|
selected: (root.doc.typeFilter || "all") === "all"
|
|
onChosen: QbzMyQbz.detailSetTypeFilter("all")
|
|
}
|
|
MenuOption {
|
|
label: root.trs("Album")
|
|
selected: root.doc.typeFilter === "album"
|
|
onChosen: QbzMyQbz.detailSetTypeFilter("album")
|
|
}
|
|
MenuOption {
|
|
label: root.trs("Track")
|
|
selected: root.doc.typeFilter === "track"
|
|
onChosen: QbzMyQbz.detailSetTypeFilter("track")
|
|
}
|
|
MenuOption {
|
|
label: root.trs("Playlist")
|
|
selected: root.doc.typeFilter === "playlist"
|
|
onChosen: QbzMyQbz.detailSetTypeFilter("playlist")
|
|
}
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 1
|
|
color: theme.surfaceElevated
|
|
}
|
|
SectionHead { text: root.trs("Source") }
|
|
MenuOption {
|
|
label: root.trs("Qobuz")
|
|
selected: root.doc.srcQobuz === true
|
|
onChosen: QbzMyQbz.detailToggleSourceFilter("qobuz")
|
|
}
|
|
MenuOption {
|
|
label: root.trs("Plex")
|
|
selected: root.doc.srcPlex === true
|
|
onChosen: QbzMyQbz.detailToggleSourceFilter("plex")
|
|
}
|
|
MenuOption {
|
|
label: root.trs("Local")
|
|
selected: root.doc.srcLocal === true
|
|
onChosen: QbzMyQbz.detailToggleSourceFilter("local")
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|