Files
qbz/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml
T
vicrodh 83c1aec4f5 feat(qt): collapsible rows in the My QBZ detail, and ephemeral leaves no trace
**The accordion the owner asked for.** Each expandable row gets a chevron;
clicking it opens THAT row's tracks inline and nothing else. Verified by driving
the real app: in list mode every row is collapsed with the chevron pointing
right, clicking row 3 — a PLEX row, so the hybrid path — flips its chevron,
renders its three tracks with durations and quality badges, and leaves rows 1,
2, 4 and 5 untouched.

This is a DELIBERATE divergence from both references, decided by the owner with
the facts in hand: neither Slint nor Tauri has a per-row accordion. Both render
inline tracks only as a view MODE, for every row at once, and both say "no
chevron" in their own comments (MixtapeDetailView.slint:1139,
MixtapeCollectionDetailView.svelte:124-127). The owner first believed Tauri had
it, asked for the code to be checked rather than the comment, and on the
evidence chose the accordion anyway. Do not "restore parity" by removing it.

Built additively on machinery that already existed: the document already carried
`canExpand` / `inlineTracks` / `expandLoading` / `tracksLoaded`. What was added
is per-row OPEN state in Rust, a toggle invokable, and lazy per-row fetching —
opening a row fetches only its tracks, once. The "expanded" view mode survives
and now means "open all", sharing ONE notion of open rather than a second
parallel state.

Also here:
- `body`'s `anchors.bottomMargin: 100` shrank the list VIEWPORT; in the .slint
  that 100px is padding INSIDE the Flickable, i.e. scroll runway. Measured on
  screen before the fix: the last row clipped mid-height with ~85px of dead
  band under it.
- `apply_resolved` cloned and serialised the WHOLE document per resolved item
  (34 serialize→parse→patch cycles for one collection, 200 for a big one, on the
  GUI thread), and `patch_expanded` did the same while the document carried
  every loaded item's inline tracks — O(n²).
- The DEAD affordances the audit filed against the modals and the grids.

**Ephemeral honours its name** (owner rule): an ephemeral folder plays and
queues, and writes nothing durable. Recently Played and Most Played are now
guarded — an ephemeral album played through would previously have outranked real
albums in Most Played, one row per track start. The Slint reference does NOT
have this exemption; that is a gap in both frontends, not a Qt regression, and
this does not restore parity by removing it.

Two stores were deliberately NOT changed and need the owner's word: the
scrobblers and Discord presence. Neither is "las bibliotecas" — nothing is
stored on our side — but both publish the track to a third party, so if "ni now
playing ni nada" is meant literally they belong in the same decision. The
one-line fix location is in the lane report.

cc
2026-07-30 18:41:06 -06:00

159 lines
5.9 KiB
QML

// MyQbzCard — the Mixtape/Collection index tile. ONE file for BOTH of
// MyQbzShared.slint's index widgets, because they render the same fields with
// the same affordance and differ only in shape:
// * default arm = `MyQbzCard` (:76-116) — the 208x296 grid card;
// * `compact` = `MyQbzListRow` (:120-163) — the 64px list row.
// Slint keeps them apart only because its layouts are declarative; folding
// them here is the same call MyQbzGridView.qml makes for the two views.
//
// Grid card: 208 wide x (184 + 12 + 12 + 18 + 2*20 + 18 + 12) = 296 tall, r8,
// bg hover -> surface-hover, border 1px that ALSO goes surface-hover on hover
// (:82-84); inner padding 12 all round, spacing 8, top-aligned: 184 mosaic, a
// 2px spacer, a 40px 2-line name clamp (so cards with a one-line and a two-line
// name still align), then the muted subtitle.
// List row: 64 tall, r6, bg hover -> surface-hover else TRANSPARENT, padding
// 12 left/right, spacing 12, a 48px mosaic and a 2px-spaced text column.
//
// Both are entirely `item.*` driven (the GridDoc `GridCard` object) and the
// WHOLE tile is the hit target with a pointer cursor. There is NO context menu
// on either shape in Slint and no hover animation is declared — do not add
// either.
//
// The two arms live behind a Loader because the mosaic is a Canvas with up to
// nine image probes: mounting both trees would double that per delegate.
import QtQuick
import com.blitzfc.qbz
import "../../cards"
import "../../theme"
Rectangle {
id: root
/// One GridDoc `GridCard`: id, name, kind, label, meta, itemCount,
/// hasCustomCover, customCoverPath, coverCount, cellUrls, cellPaths.
property var item: ({})
/// false = 208x296 grid card, true = 64px list row.
property bool compact: false
signal clicked()
QbzTheme { id: theme }
// implicitWidth (not width): the grid delegate wants the fixed 208 and the
// list delegate assigns the view's width over it.
implicitWidth: 208
height: root.compact ? 64 : (184 + 12 + 12 + 18 + 2 * 20 + 18 + 12)
radius: root.compact ? 6 : 8
color: cardArea.containsMouse ? theme.surfaceHover
: (root.compact ? "transparent" : theme.surfaceCard)
border.width: root.compact ? 0 : 1
border.color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
// `label · meta`, but never a bare " · ": a default/half-built `item` (a
// standalone instance, or a pre-publish frame) has neither half and would
// otherwise render the separator on its own.
readonly property string subtitle: {
var l = root.item.label || ""
var m = root.item.meta || ""
return (l !== "" && m !== "") ? (l + " · " + m) : (l + m)
}
Loader {
anchors.fill: parent
sourceComponent: root.compact ? rowBody : cardBody
}
// --- Grid card ------------------------------------------------------
Component {
id: cardBody
Column {
x: 12
y: 12
width: parent.width - 24
spacing: 8
CollectionMosaic {
size: 184
urls: root.item.cellUrls || []
paths: root.item.cellPaths || []
coverCount: root.item.coverCount || 0
kind: root.item.kind || ""
itemCount: root.item.itemCount || 0
hasCustomCover: root.item.hasCustomCover === true
customCoverPath: root.item.customCoverPath || ""
}
Item { width: 1; height: 2 }
Text {
width: parent.width
// 2 lines RESERVED, not "up to 2" — the clamp is what keeps a
// row of cards aligned (MyQbzShared.slint:102).
height: 40
text: root.item.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
wrapMode: Text.WordWrap
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.subtitle
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
}
// --- List row -------------------------------------------------------
Component {
id: rowBody
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 12
CollectionMosaic {
anchors.verticalCenter: parent.verticalCenter
size: 48
urls: root.item.cellUrls || []
paths: root.item.cellPaths || []
coverCount: root.item.coverCount || 0
kind: root.item.kind || ""
itemCount: root.item.itemCount || 0
hasCustomCover: root.item.hasCustomCover === true
customCoverPath: root.item.customCoverPath || ""
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 48 - 12)
spacing: 2
Text {
width: parent.width
text: root.item.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.subtitle
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
}
}
MouseArea {
id: cardArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}