feat(qt): artist scene, musician page and the shared scene-discovery engine
Both views were absent from the Qt frontend, not partial. This lands them
together with the core engine they need and the two entry points that reach
them.
- qbz-core gains scene discovery ("artists from the same place"): a
cancellable run with a phase for the progress label and a typed
SceneDiscoveryError, so a view can tell offline, all-upstreams-failed and
an honest empty result apart. CancelToken, ScenePhase and SceneDiscoveryError
are re-exported for frontends.
- qbz-integrations: MusicBrainz location resolution plus its cache layer.
- qbz-qt: ArtistSceneView with its three scene components, MusicianPageView
and MusicianModal, backed by artist_scene_qt.rs, musician_qt.rs and the two
bridges. The five-branch musician dispatcher is wired to all six of its
consumers. Both scene doors are live: the sidebar location link and the
overflow row that shipped grey with action: "stub".
- Translations: the new msgids in all eight locales.
Three defects fixed on the way, all with the same signature — a comment
asserting a mechanism that does not exist:
- Volume opened at 100 %. The comment claimed the poll loop mirrored it from
the engine; now_playing::set_volume has two callers and neither runs in a
local session.
- mbAvailable was documented as "MusicBrainz enabled AND resolved to an MBID"
while being assigned from the opt-in alone. Consumers now test the MBID.
- Session restore could not resume from a cold cursor ("cannot resume - no
audio data available") for want of a has_loaded_audio() guard. Investigation
in qbz-nix-docs/qt-frontend/2026-08-14-cold-cursor-resume.md.
Contract and evidence:
qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md,
which wins over specs 01..09 and records 20 deliberate divergences from the
reference in section 6.
cc
This commit is contained in:
37 files changed
+9203
-83
No files matched your search
@@ -0,0 +1,135 @@
|
||||
// ArtistScene › the A-Z jump rail — 1:1 with Tauri's `.alpha-index`
|
||||
// (ArtistsByLocationView.svelte markup :699-711, CSS :1372-1413), per
|
||||
// 01-tauri-artist-scene.md §11.
|
||||
//
|
||||
// NOT controls/QbzAlphaStrip.qml. That one is the Library/Local rail: an 18px
|
||||
// gutter of 15px rows in 9px muted type, driven by a { letter, index } list of
|
||||
// the jumps that EXIST. This one is Tauri's: a fixed 27-slot pill — `#` first,
|
||||
// then A-Z, every slot always drawn — 20×20 letters at 11/600 on a translucent
|
||||
// black card, where a letter with no members stays visible at opacity 0.2.
|
||||
// Different alphabet contract, different metrics, different host; the
|
||||
// reference has two components as well.
|
||||
//
|
||||
// STATIC, NOT STICKY. §11 and §18 of the spec both call this rail "sticky"
|
||||
// (`position:sticky; top:0`), and 08-critique-findings settles that the sticky
|
||||
// never engages: `.alpha-index` is a flex child of a row that does not scroll,
|
||||
// and the real scroller is a descendant of its SIBLING. What pins it to the
|
||||
// top is `align-self: flex-start`. So: the host anchors this item's TOP and
|
||||
// lets it keep its implicit height. Do not wire it to a Flickable's contentY.
|
||||
//
|
||||
// DELIBERATE DIVERGENCE (recorded, small): Tauri styles a memberless letter
|
||||
// `.disabled` — opacity 0.2, `cursor: default` — but leaves the <button> live
|
||||
// and early-returns inside `scrollToGroup` (V:310). Here the letter is
|
||||
// genuinely inert: no hover paint, no pointer cursor, no signal. Same
|
||||
// user-visible result with less surprise, and one fewer no-op code path.
|
||||
//
|
||||
// MOUNTING — the root IS the 28px pill, and `.alpha-index`'s 4px `margin-left`
|
||||
// is the HOST's (ArtistSceneView.qml:1123 already writes it as
|
||||
// `x: gridPane.width + 4`). Do not bake it in here as well.
|
||||
//
|
||||
// AlphaIndexRail {
|
||||
// x: gridPane.width + 4 // the CSS margin-left
|
||||
// y: 0 // align-self: flex-start
|
||||
// activeKeys: root.alphaActiveKeys // ["#","A","D",…] or {"A": true}
|
||||
// onJumped: function (letter) { … } // host scrolls its own view
|
||||
// }
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// --- the frozen mount API (00-CONTRACT.md, scene lane) ---------------
|
||||
/// Which of the 27 slots have members. Accepts an array (["#","A","D"])
|
||||
/// or a set-shaped object ({"A": true}) — the host may hold either.
|
||||
property var activeKeys: []
|
||||
signal jumped(string letter)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// `ALPHA_LETTERS = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')` (V:271) —
|
||||
// 27 entries, `#` FIRST. Built once, never re-derived: this is a constant.
|
||||
readonly property var letters: [
|
||||
"#", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
|
||||
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"
|
||||
]
|
||||
|
||||
function hasMembers(letter) {
|
||||
var k = root.activeKeys
|
||||
if (!k)
|
||||
return false
|
||||
if (Array.isArray(k))
|
||||
return k.indexOf(letter) >= 0
|
||||
return k[letter] === true
|
||||
}
|
||||
|
||||
// 20px letters inside 4px of side padding — `padding: 6px 4px`.
|
||||
implicitWidth: 28
|
||||
implicitHeight: pill.height
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
width: 28
|
||||
height: col.height + 2 * 6
|
||||
radius: 10
|
||||
// `background: rgba(0,0,0,0.2)`. Qt.rgba with 0..1 floats, NEVER an
|
||||
// 8-digit hex — Qt reads #AARRGGBB where CSS writes #RRGGBBAA, so
|
||||
// "#00000033" would be a fully transparent black here.
|
||||
color: Qt.rgba(0, 0, 0, 0.2)
|
||||
|
||||
Column {
|
||||
id: col
|
||||
x: 4
|
||||
y: 6
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: root.letters
|
||||
delegate: Rectangle {
|
||||
id: slot
|
||||
required property var modelData
|
||||
|
||||
readonly property bool live: root.hasMembers(slot.modelData)
|
||||
|
||||
width: 20
|
||||
height: 20
|
||||
radius: 4
|
||||
color: (slot.live && slotArea.containsMouse) ? theme.surfaceElevated
|
||||
: "transparent"
|
||||
// `.disabled { opacity: 0.2 }` / `opacity: 0.9` -> 1 on
|
||||
// hover. `transition: all 100ms ease` — hover-bounded, the
|
||||
// one animation class the pulse law leaves alone.
|
||||
opacity: !slot.live ? 0.2 : (slotArea.containsMouse ? 1.0 : 0.9)
|
||||
Behavior on opacity { NumberAnimation { duration: 100 } }
|
||||
Behavior on color { ColorAnimation { duration: 100 } }
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
text: slot.modelData
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: slotArea
|
||||
anchors.fill: parent
|
||||
// Inert when the letter has no members: an enabled
|
||||
// MouseArea would still eat the press and paint a
|
||||
// cursor for a jump that cannot happen.
|
||||
enabled: slot.live
|
||||
hoverEnabled: slot.live
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.jumped(String(slot.modelData))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
// ArtistScene › the genre-filter popup — 1:1 with Tauri's `.genre-popup`
|
||||
// (ArtistsByLocationView.svelte markup :495-549, CSS :1128-1347), per
|
||||
// 00-CONTRACT.md §0 (Tauri wins on every number) and 01-tauri-artist-scene.md
|
||||
// §6, which is normative here.
|
||||
//
|
||||
// NOT the same component as controls/GenreFilterPopup.qml. That one is the
|
||||
// Discover/Library overlay: 470px wide, driven end-to-end by
|
||||
// QbzBridge.genreFilterJson, with a remember/advanced toggle pair and a
|
||||
// 3-level genre TREE. This one is a dumb, self-contained box over the genre
|
||||
// strings the scene's own candidates carry — no bridge, no tree, no
|
||||
// persistence, and a fixed 3-column grid of 36px cards. Same job title, two
|
||||
// different surfaces; the reference has two files as well.
|
||||
//
|
||||
// ⚠ TWO COMPONENTS NOW SHARE THE NAME `GenreFilterPopup`, and ArtistSceneView
|
||||
// imports BOTH directories ("../controls" and "scene"). QML resolves a type by
|
||||
// walking the imports in reverse, so "scene" — imported last — wins today and
|
||||
// the right box is drawn. It is still one reordered import away from silently
|
||||
// mounting the 470px Discover tree instead. The fix belongs to the integration
|
||||
// lane and is three tokens: `import "scene" as Scene`, then `Scene.` on the
|
||||
// three mounts. Flagged in this lane's report.
|
||||
//
|
||||
// MOUNTING — the root IS the 530px card, positioned by the host, which is what
|
||||
// ArtistSceneView.qml:1236-1242 already does:
|
||||
//
|
||||
// GenreFilterPopup {
|
||||
// z: 200
|
||||
// open: root.genrePopupOpen
|
||||
// x: … genreBtn.x + genreBtn.width - width // `right: 0`
|
||||
// y: … genreBtn.y + genreBtn.height + 6 // `calc(100% + 6px)`
|
||||
// genres: root.availableGenres // ["post-punk", …]
|
||||
// selected: root.activeGenres // array OR {g: true}
|
||||
// onToggled: function (g) { … }
|
||||
// onCleared: function () { … }
|
||||
// onCloseRequested: root.genrePopupOpen = false
|
||||
// }
|
||||
//
|
||||
// Tauri anchors the card to the 36×36 funnel button (`.genre-filter-container
|
||||
// {position:relative}` :1129-1131, which 01-…md §6 omits and 08-critique
|
||||
// records), i.e. `top: calc(100% + 6px); right: 0` of THAT button — NOT of the
|
||||
// nav bar and NOT of the view.
|
||||
//
|
||||
// OUTSIDE CLICK IS THE HOST'S. Tauri closes on a document-level handler
|
||||
// (V:240-246); the QML equivalent is a full-surface scrim, which this card
|
||||
// cannot be and still be positioned as a 530px box. ArtistSceneView already
|
||||
// owns that scrim (`outsideScrim`, :1168-1177, one scrim for this popup and the
|
||||
// group menu — its DV-4 fix). What this component contributes is the swallow
|
||||
// MouseArea below, without which a press INSIDE the card would fall through to
|
||||
// that scrim and close the popup on every genre click.
|
||||
//
|
||||
// SELECTION IS THE HOST'S. This box only reports `toggled(genre)` and
|
||||
// `cleared()`; the AND semantics of the filter chain (V:250-262 — an artist is
|
||||
// kept only when it carries EVERY selected genre, case-sensitive) live in the
|
||||
// consumer.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// --- the frozen mount API (00-CONTRACT.md, scene lane) ---------------
|
||||
/// Every genre string the scene's candidates carry, already deduped by
|
||||
/// the host. Order is the host's; this box does not sort.
|
||||
property var genres: []
|
||||
/// The active selection. Accepts an array (["post-punk"]) or a
|
||||
/// set-shaped object ({"post-punk": true}) — Tauri's is a JS Set and the
|
||||
/// host may hold either.
|
||||
property var selected: []
|
||||
/// Visibility. Named `open` to match the mount contract.
|
||||
property bool open: false
|
||||
|
||||
signal toggled(string genre)
|
||||
signal cleared()
|
||||
signal closeRequested()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// `width: 530px; max-height: 440px` (:1136-1137). The card is a flex
|
||||
// column whose only flexible band is the grid, so it is as tall as its
|
||||
// content up to 440.
|
||||
readonly property int headerH: 52
|
||||
readonly property int searchH: 41
|
||||
readonly property int footerH: 57
|
||||
width: 530
|
||||
height: Math.min(440, root.headerH + (root.searchShown ? root.searchH : 0)
|
||||
+ gridBand.contentH + root.footerH)
|
||||
|
||||
// Tauri's z-index:200 (`.genre-popup` :1145). This is a view-local popup,
|
||||
// not a modal, so ADR-009's ≥3000 floor does not apply — that floor is for
|
||||
// surfaces that must clear the shell chrome, and this one must not. The
|
||||
// host sets it too; this is the default for any other mount.
|
||||
z: 200
|
||||
visible: root.open
|
||||
enabled: root.open
|
||||
|
||||
// The search field is rendered only above 9 genres (V:508) — below that
|
||||
// the 3-column grid shows everything without scrolling and the row would
|
||||
// be dead chrome.
|
||||
readonly property bool searchShown: (root.genres || []).length > 9
|
||||
|
||||
/// Selection test that survives both shapes of `selected`.
|
||||
function isSelected(g) {
|
||||
var s = root.selected
|
||||
if (!s)
|
||||
return false
|
||||
if (Array.isArray(s))
|
||||
return s.indexOf(g) >= 0
|
||||
return s[g] === true
|
||||
}
|
||||
readonly property int selectedCount: {
|
||||
var s = root.selected
|
||||
if (!s)
|
||||
return 0
|
||||
if (Array.isArray(s))
|
||||
return s.length
|
||||
var n = 0
|
||||
for (var k in s)
|
||||
if (s[k] === true)
|
||||
n++
|
||||
return n
|
||||
}
|
||||
|
||||
// `.genre-name::first-letter { text-transform: capitalize }` (:1276-1278)
|
||||
// — the FIRST LETTER ONLY, not CSS `capitalize`, which would title-case
|
||||
// every word ("post punk" -> "Post punk", never "Post Punk"). QML has no
|
||||
// text-transform at all, so it happens here.
|
||||
function capFirst(s) {
|
||||
var t = String(s || "")
|
||||
return t.length === 0 ? t : t.charAt(0).toUpperCase() + t.slice(1)
|
||||
}
|
||||
|
||||
// Closing resets the needle, as Tauri does on every close path
|
||||
// (V:242-245, V:519, V:542).
|
||||
onOpenChanged: if (!root.open) searchInput.text = ""
|
||||
|
||||
readonly property var shownGenres: {
|
||||
var all = root.genres || []
|
||||
var q = searchInput.text.toLowerCase()
|
||||
if (!root.searchShown || q === "")
|
||||
return all
|
||||
var out = []
|
||||
for (var i = 0; i < all.length; i++)
|
||||
if (String(all[i]).toLowerCase().indexOf(q) >= 0)
|
||||
out.push(all[i])
|
||||
return out
|
||||
}
|
||||
|
||||
// ============================== the card =============================
|
||||
// `box-shadow: 0 8px 32px rgba(0,0,0,0.4)` (:1144), approximated by four
|
||||
// concentric frames that fade outward. Declared BEFORE the card so the
|
||||
// card's own fill covers them — a negative-z CHILD would not work: Qt
|
||||
// Quick draws an item's own geometry before any child, whatever its z, so
|
||||
// the frames would have painted dark bars across the card's top edge.
|
||||
//
|
||||
// Qt.rgba with 0..1 floats, NEVER an 8-digit hex: Qt reads #AARRGGBB
|
||||
// where CSS and Slint write #RRGGBBAA, so "#00000066" is a fully
|
||||
// TRANSPARENT black here. That trap has landed twice in this port.
|
||||
Repeater {
|
||||
model: 4
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
x: -(index + 1) * 2
|
||||
y: 8 - (index + 1) * 2
|
||||
width: root.width + (index + 1) * 4
|
||||
height: root.height + (index + 1) * 4
|
||||
radius: card.radius + (index + 1) * 2
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: Qt.rgba(0, 0, 0, 0.4 / (index + 2))
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
|
||||
anchors.fill: parent
|
||||
radius: 10
|
||||
color: theme.surfaceMain
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
|
||||
// Swallow presses on the card's own chrome (Tauri's
|
||||
// `onclick={e => e.stopPropagation()}`, V:498). Declared FIRST so the
|
||||
// content below keeps its own clicks.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
// ---------------------------- header -----------------------------
|
||||
Item {
|
||||
id: header
|
||||
width: parent.width
|
||||
height: root.headerH
|
||||
|
||||
Row {
|
||||
x: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
QbzIcon {
|
||||
name: "sliders-horizontal"
|
||||
width: 16
|
||||
height: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "textPrimary"
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzSession.tr("Filter by genre", QbzSession.trRev)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 14
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: parent.width - width - 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 6
|
||||
color: closeArea.containsMouse ? theme.bgHover : "transparent"
|
||||
// Hover-only, gesture-bounded — the one animation class the
|
||||
// pulse law leaves alone (cards/ArtistCard.qml:170).
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "x"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: closeArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.closeRequested()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
y: parent.height - 1
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- search row ---------------------------
|
||||
Item {
|
||||
id: searchRow
|
||||
visible: root.searchShown
|
||||
y: header.height
|
||||
width: parent.width
|
||||
height: root.searchH
|
||||
|
||||
Row {
|
||||
x: 16
|
||||
width: parent.width - 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
QbzIcon {
|
||||
name: "search"
|
||||
width: 14
|
||||
height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "muted"
|
||||
}
|
||||
TextInput {
|
||||
id: searchInput
|
||||
width: parent.width - 22 - (clearBtn.visible ? clearBtn.width + 8 : 0)
|
||||
height: 21
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
// Reusing the catalogue's existing needle msgid rather
|
||||
// than Tauri's "Search..." — 00-CONTRACT.md §7: reuse the
|
||||
// translated entry unless the owner asks otherwise.
|
||||
Text {
|
||||
visible: searchInput.text === ""
|
||||
anchors.fill: parent
|
||||
text: QbzSession.tr("Search…", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
id: clearBtn
|
||||
visible: searchInput.text !== ""
|
||||
width: 20
|
||||
height: 20
|
||||
radius: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: clearArea.containsMouse ? theme.bgHover : theme.surfaceElevated
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "x"
|
||||
width: 12
|
||||
height: 12
|
||||
tintName: clearArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: clearArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: searchInput.text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
y: parent.height - 1
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------ grid ------------------------------
|
||||
// `repeat(3, 1fr)`, gap 6, padding 12 — a FIXED 3 columns with no
|
||||
// breakpoint (:1228-1235), so the cell width is arithmetic, not a
|
||||
// layout negotiation.
|
||||
Item {
|
||||
id: gridBand
|
||||
|
||||
readonly property int pad: 12
|
||||
readonly property int gap: 6
|
||||
readonly property int cellW: Math.floor((root.width - 2 * gridBand.pad
|
||||
- 2 * gridBand.gap) / 3)
|
||||
readonly property int contentH: 2 * gridBand.pad
|
||||
+ (root.shownGenres.length > 0 ? grid.height : emptyLabel.height)
|
||||
|
||||
y: header.height + (root.searchShown ? searchRow.height : 0)
|
||||
width: parent.width
|
||||
height: root.height - y - root.footerH
|
||||
clip: true
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: gridBand.contentH
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
|
||||
Grid {
|
||||
id: grid
|
||||
visible: root.shownGenres.length > 0
|
||||
x: gridBand.pad
|
||||
y: gridBand.pad
|
||||
columns: 3
|
||||
columnSpacing: gridBand.gap
|
||||
rowSpacing: gridBand.gap
|
||||
|
||||
Repeater {
|
||||
model: root.shownGenres
|
||||
delegate: Rectangle {
|
||||
id: genreCard
|
||||
required property var modelData
|
||||
|
||||
readonly property bool picked: root.isSelected(genreCard.modelData)
|
||||
|
||||
width: gridBand.cellW
|
||||
height: 36
|
||||
radius: 6
|
||||
border.width: 1
|
||||
color: genreCard.picked
|
||||
? (cardArea.containsMouse ? theme.accentHover : theme.accent)
|
||||
: (cardArea.containsMouse ? theme.bgHover : theme.surfaceElevated)
|
||||
border.color: genreCard.picked
|
||||
? (cardArea.containsMouse ? theme.accentHover : theme.accent)
|
||||
: (cardArea.containsMouse ? theme.textMuted : theme.borderSubtle)
|
||||
// `transition: color/background-color/border-color
|
||||
// 150ms` (:1244-1247). Both arms of this colour are
|
||||
// gesture-bounded — hover, and a click on THIS card
|
||||
// (or the footer's Clear) is the only thing that can
|
||||
// flip `picked`. It is not a data feed in the sense
|
||||
// the pulse law forbids.
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
|
||||
Text {
|
||||
x: 10
|
||||
width: parent.width - 10 - 14 - 10 - 8
|
||||
height: parent.height
|
||||
text: root.capFirst(genreCard.modelData)
|
||||
// On the accent fill the label is the measured
|
||||
// on-accent colour, not Tauri's
|
||||
// `--btn-primary-text`: the same call the
|
||||
// sibling popup already makes
|
||||
// (controls/GenreFilterPopup.qml:429-430) —
|
||||
// accent-text on 34 of the 35 palettes, and a
|
||||
// legible colour on the one where it is not.
|
||||
color: genreCard.picked ? theme.accentGlyphColor : theme.textPrimary
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// `.check-circle` (:1284-1310) — 14px ring that
|
||||
// fills white and grows a CSS-drawn tick. The tick
|
||||
// is a 4×7 box wearing only its right and bottom
|
||||
// borders, rotated 45°: two Rectangles here,
|
||||
// because QML has no border-width shorthand and an
|
||||
// icon glyph would not be the same mark.
|
||||
Item {
|
||||
id: checkCircle
|
||||
x: parent.width - 10 - width
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 14
|
||||
height: 14
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 7
|
||||
// INVERTED host: the disc is the card's
|
||||
// foreground punched out of the accent
|
||||
// fill and the tick inside it is `accent`,
|
||||
// so the pair that has to clear the
|
||||
// contrast floor is (accent, this fill) —
|
||||
// exactly the swap
|
||||
// controls/GenreFilterPopup.qml:436-455
|
||||
// documents. Tauri paints it literal white.
|
||||
color: genreCard.picked ? theme.accentGlyphColor : "transparent"
|
||||
border.width: 1.5
|
||||
border.color: genreCard.picked
|
||||
? theme.accentGlyphColor : theme.textMuted
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
// `transform: translate(-50%,-60%) rotate(45deg)`
|
||||
// off `top:50%; left:50%` — the translate is of
|
||||
// the tick's OWN 4×7 box, the rotation is about
|
||||
// its centre.
|
||||
Item {
|
||||
visible: genreCard.picked
|
||||
x: parent.width * 0.5 - width * 0.5
|
||||
y: parent.height * 0.5 - height * 0.6
|
||||
width: 4
|
||||
height: 7
|
||||
rotation: 45
|
||||
transformOrigin: Item.Center
|
||||
// border-width: 0 1.5px 1.5px 0 — right
|
||||
// edge…
|
||||
Rectangle {
|
||||
x: parent.width - 1.5
|
||||
width: 1.5
|
||||
height: parent.height
|
||||
color: theme.accent
|
||||
}
|
||||
// …and bottom edge.
|
||||
Rectangle {
|
||||
y: parent.height - 1.5
|
||||
width: parent.width
|
||||
height: 1.5
|
||||
color: theme.accent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: cardArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggled(String(genreCard.modelData))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `.genre-popup-empty` (:1341-1347) — spans the whole grid,
|
||||
// centred, 12px muted, 16px of air above and below.
|
||||
Text {
|
||||
id: emptyLabel
|
||||
visible: root.shownGenres.length === 0
|
||||
x: gridBand.pad
|
||||
y: gridBand.pad
|
||||
width: root.width - 2 * gridBand.pad
|
||||
height: 16 + 16 + 16
|
||||
text: QbzSession.tr("No results found", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------- footer -----------------------------
|
||||
Item {
|
||||
id: footer
|
||||
y: root.height - root.footerH
|
||||
width: parent.width
|
||||
height: root.footerH
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: 16
|
||||
y: 12
|
||||
width: parent.width - 32
|
||||
height: 32
|
||||
radius: 6
|
||||
// `:disabled { opacity: 0.4 }` (:1337-1339).
|
||||
opacity: root.selectedCount > 0 ? 1.0 : 0.4
|
||||
color: (clearFilterArea.containsMouse && root.selectedCount > 0)
|
||||
? theme.bgHover : theme.surfaceElevated
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: QbzSession.tr("Clear filter", QbzSession.trRev)
|
||||
color: (clearFilterArea.containsMouse && root.selectedCount > 0)
|
||||
? theme.textPrimary : theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightMedium
|
||||
}
|
||||
MouseArea {
|
||||
id: clearFilterArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.selectedCount > 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
// Tauri clears AND closes (V:542).
|
||||
onClicked: {
|
||||
if (root.selectedCount === 0)
|
||||
return
|
||||
root.cleared()
|
||||
root.closeRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
// ArtistScene › sidepanel mode — the two-column artist→albums body, 1:1 with
|
||||
// Tauri's `.artist-two-column-layout` (ArtistsByLocationView.svelte markup
|
||||
// :715-817, CSS :1415-1509; the rail is VirtualizedFavoritesArtistList.svelte),
|
||||
// per 01-tauri-artist-scene.md §9 and §13.
|
||||
//
|
||||
// EDGE TO EDGE. Tauri's layout carries `margin: 0 -8px 0 -18px`, which exactly
|
||||
// cancels `.scene-view`'s 18px/8px padding, so this body starts at the pane's
|
||||
// left edge and the 18px re-appears INSIDE the rail as its own padding-left.
|
||||
// The host must therefore mount this component full-bleed — negating its own
|
||||
// padding if it has any — or the rail lands 18px too far right and the right
|
||||
// column loses its 8px tail.
|
||||
//
|
||||
// NOT views/library/LibraryArtistsPanel.qml, and deliberately so
|
||||
// (00-CONTRACT.md §4.4). That panel is bound to QbzLibrary and a Library host,
|
||||
// with a 300px rail, 56px rows, 40px portraits and 200×246 AlbumCards carrying
|
||||
// follow / play / pin / context-menu affordances the scene must not have.
|
||||
// Tauri's scene needs 240 / 64 / 48 and a 220px card with two actions. That is
|
||||
// far more than a 10 % variation, which is the justification TRACK-RULE 5 asks
|
||||
// for; the artwork-window mechanics ARE reused, verbatim, from that file.
|
||||
//
|
||||
// WINDOWED, because the reference is not. Tauri requests 500 albums, and the
|
||||
// nearest Qt panel walks every album url the moment the document lands
|
||||
// (LibraryArtistsPanel.qml:182-193) and instantiates every card through nested
|
||||
// Repeaters (:383-445) — 500 live delegates and 500 artwork downloads for a
|
||||
// pane showing eight. Here BOTH columns are single ListViews over a flat entry
|
||||
// model (the LocalArtistsTab / LibraryArtistsPanel rail shape, extended to
|
||||
// carry grid ROWS), and covers are requested only for the mounted band plus
|
||||
// one screen of buffer, capped at `coverBatchCap` urls per report. Geometry and
|
||||
// ordering are Tauri's; only the instantiation is bounded.
|
||||
//
|
||||
// PULSE LAW: the only continuous animation on this surface is the rail's name
|
||||
// ticker, and it rides QbzShell.pulseMs through a Connections that is DISABLED
|
||||
// while the row is not hovered — a row at rest writes nothing at all. There is
|
||||
// no local Timer, no NumberAnimation and no Behavior on a data-fed property;
|
||||
// the hover fades are gesture-bounded, which is the one class the law allows
|
||||
// (cards/ArtistCard.qml:170).
|
||||
//
|
||||
// NO SPINNER for the loading state: QbzSpinner owns an infinite
|
||||
// RotationAnimation at display rate and QbzSkeleton a repeating 900ms Timer,
|
||||
// and 00-CONTRACT.md §4.5 closes that door for new code. The three-dot
|
||||
// QbzLoadingDots is host-driven by design, so its phase is cycled here off the
|
||||
// shell pulse — three writes a second, only while the pane is actually loading.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../controls"
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// --- the frozen mount API (00-CONTRACT.md §13.1, scene lane) ---------
|
||||
/// `sceneJson.panel` — { state, artistId, error, discography, epsSingles,
|
||||
/// liveAlbums }.
|
||||
property var doc: ({})
|
||||
/// `sceneJson.artists` — the FILTERED list the host is showing. The rail
|
||||
/// is always A-Z grouped over it, whatever the view's own grouping toggle
|
||||
/// says (Tauri passes `groupArtists(filteredArtists)` unconditionally,
|
||||
/// V:718, which is why its Group select is hidden in this mode).
|
||||
property var artists: []
|
||||
/// The selected artist's Qobuz id, "" for none.
|
||||
property string selectedId: ""
|
||||
|
||||
signal artistSelected(string qobuzId)
|
||||
signal albumActivated(string albumId)
|
||||
signal albumPlayed(string albumId)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// ------------------------------ metrics ------------------------------
|
||||
// Every number is Tauri's; none is a theme token, because the theme's type
|
||||
// scale (fontBody 15, fontLegal 13) does not line up with this surface's
|
||||
// 14/13/12/11 and the contract makes Tauri normative for geometry.
|
||||
readonly property int railW: 240 // .artist-column width
|
||||
readonly property int railPadLeft: 18 // …its padding-left
|
||||
readonly property int rowH: 64 // ROW_HEIGHT
|
||||
readonly property int groupHeadH: 32 // HEADER_HEIGHT
|
||||
readonly property int cardW: 220 // AlbumCardLite .card width
|
||||
readonly property int coverS: 220 // …and its square cover
|
||||
// 220 cover + the card's 4px column gap + the meta row (a 14/1.3 title
|
||||
// over a 13/1.3 artist, 2px apart — 38 — against a 30px quality badge).
|
||||
readonly property int cardH: 262
|
||||
readonly property int gridGap: 22 // .artist-albums-grid gap
|
||||
readonly property int trackMin: 210 // …minmax(210px, 1fr)
|
||||
// Section header: a 16/600 title over 12px of air, a 1px rule, and 16px of
|
||||
// margin under it.
|
||||
readonly property int sectionHeadH: 51
|
||||
/// Hard bound on urls asked for in one report — see the windowing note.
|
||||
readonly property int coverBatchCap: 48
|
||||
|
||||
// ========================= artwork, url-keyed =========================
|
||||
// The mechanism LibraryArtistsPanel.qml:158-193 already uses for its right
|
||||
// pane and views/ArtistView.qml for its sidebar: ask QbzShell for a list of
|
||||
// urls, take (url, path) pairs back on QbzLibrary.libraryArtworkReady.
|
||||
// Arrivals are coalesced into ONE rebind per frame — per-arrival rebinding
|
||||
// is O(n²) over a big discography.
|
||||
//
|
||||
// `_asked` is what makes this bounded rather than merely lazy:
|
||||
// sidebar_artwork_window (main.rs:968) DOWNLOADS every url it is handed
|
||||
// that is not already on disk, so re-reporting the same band on every
|
||||
// scroll pixel would re-queue the same fetches. Never pruned — a cache
|
||||
// path that has already been resolved costs one map entry, and dropping it
|
||||
// would only make the cover flicker back to the placeholder.
|
||||
property var coverMap: ({})
|
||||
property var _coverInbox: ({})
|
||||
property var _asked: ({})
|
||||
Timer {
|
||||
id: coverFlush
|
||||
interval: 16
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
var m = Object.assign({}, root.coverMap, root._coverInbox)
|
||||
root._coverInbox = ({})
|
||||
root.coverMap = m
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: QbzLibrary
|
||||
function onLibraryArtworkReady(key, path) {
|
||||
root._coverInbox[key] = path
|
||||
if (!coverFlush.running)
|
||||
coverFlush.start()
|
||||
}
|
||||
}
|
||||
function requestCovers(urls) {
|
||||
var out = []
|
||||
for (var i = 0; i < urls.length && out.length < root.coverBatchCap; i++) {
|
||||
var u = urls[i]
|
||||
if (u === "" || root._asked[u] === true || root.coverMap[u])
|
||||
continue
|
||||
root._asked[u] = true
|
||||
out.push(u)
|
||||
}
|
||||
if (out.length > 0)
|
||||
QbzShell.sidebarArtworkWindow(JSON.stringify(out))
|
||||
}
|
||||
|
||||
// ------------------- field readers (the lane seam) -------------------
|
||||
// 00-CONTRACT.md §13.1 freezes the panel's SHAPE but not the album row's
|
||||
// field NAMES. The Rust lane landed them as `albumId` / `artworkUrl` /
|
||||
// `year` (4-digit string, already sliced) / `qualityTier` /
|
||||
// `qualityDetail` — src/artist_scene_qt.rs:233-279. The alternates below
|
||||
// are kept as one-token insurance across that seam: an `id`/`artUrl` row
|
||||
// from any other producer still draws, instead of a grid of blank cards
|
||||
// with no error anywhere.
|
||||
/// The artist's display name, whatever the producer called it.
|
||||
///
|
||||
/// Same seam as the album readers above, and it has already bitten once:
|
||||
/// the swap to the standard ArtistCard (owner ruling R8) moved the artist
|
||||
/// row's `name` to the house `title`, which silently collapsed the host
|
||||
/// grid's A-Z grouping into a single "#" bucket — every step stayed a
|
||||
/// legal operation on an empty string, so nothing errored.
|
||||
function artistNameOf(a) { return String((a && (a.title || a.name)) || "") }
|
||||
function albumIdOf(a) { return String(a.albumId || a.id || "") }
|
||||
function albumArtOf(a) { return String(a.artworkUrl || a.artUrl || a.imageUrl || "") }
|
||||
function albumYearOf(a) {
|
||||
if (a.year !== undefined && a.year !== null && String(a.year) !== "")
|
||||
return String(a.year)
|
||||
// Tauri: `Number(release_date_original?.slice(0,4)) || undefined`.
|
||||
var d = String(a.releaseDate || "")
|
||||
return d.length >= 4 ? d.slice(0, 4) : ""
|
||||
}
|
||||
|
||||
// ============================== the rail ==============================
|
||||
// Entry model — { t: 0, label } | { t: 1, item } — so ONE ListView windows
|
||||
// both the letter headers and the rows.
|
||||
property var railEntries: []
|
||||
/// Group keys that have members, for the host's alpha rail if it wants
|
||||
/// them. `alphaGroupKey(name)` is Tauri's: the first character uppercased,
|
||||
/// kept when /[A-Z]/, else '#' — so accented and non-Latin initials all
|
||||
/// collapse into '#' (V:271-276). That is design, not defect: the contract
|
||||
/// lists it under "deliberately NOT diverged".
|
||||
function alphaKey(name) {
|
||||
var c = String(name || "").charAt(0).toUpperCase()
|
||||
return (c >= "A" && c <= "Z") ? c : "#"
|
||||
}
|
||||
function rebuildRail() {
|
||||
var src = (root.artists || []).slice()
|
||||
// `groupArtists` sorts a COPY by localeCompare, buckets by key, then
|
||||
// orders the keys with '#' forced first (V:278-296).
|
||||
src.sort(function (a, b) {
|
||||
return root.artistNameOf(a).localeCompare(root.artistNameOf(b))
|
||||
})
|
||||
var buckets = {}
|
||||
var order = []
|
||||
for (var i = 0; i < src.length; i++) {
|
||||
var k = root.alphaKey(root.artistNameOf(src[i]))
|
||||
if (buckets[k] === undefined) {
|
||||
buckets[k] = []
|
||||
order.push(k)
|
||||
}
|
||||
buckets[k].push(src[i])
|
||||
}
|
||||
order.sort(function (a, b) {
|
||||
if (a === "#") return -1
|
||||
if (b === "#") return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
var out = []
|
||||
for (var g = 0; g < order.length; g++) {
|
||||
out.push({ "t": 0, "label": order[g] })
|
||||
var rows = buckets[order[g]]
|
||||
for (var r = 0; r < rows.length; r++)
|
||||
out.push({ "t": 1, "item": rows[r] })
|
||||
}
|
||||
root.railEntries = out
|
||||
}
|
||||
onArtistsChanged: { root.rebuildRail(); root.reportRailSoon() }
|
||||
Component.onCompleted: { root.rebuildRail(); root.reportRailSoon() }
|
||||
|
||||
function reportRail() {
|
||||
if (!root.visible || root.railEntries.length === 0)
|
||||
return
|
||||
var first = railList.indexAt(4, railList.contentY + 1)
|
||||
var last = railList.indexAt(4, railList.contentY + Math.max(1, railList.height) - 1)
|
||||
if (first < 0)
|
||||
first = 0
|
||||
if (last < 0)
|
||||
last = Math.min(root.railEntries.length - 1, first + 12)
|
||||
first = Math.max(0, first - 4)
|
||||
last = Math.min(root.railEntries.length - 1, last + 4)
|
||||
var urls = []
|
||||
for (var i = first; i <= last; i++) {
|
||||
var e = root.railEntries[i]
|
||||
if (e.t === 1 && String(e.item.imageUrl || "") !== "")
|
||||
urls.push(String(e.item.imageUrl))
|
||||
}
|
||||
root.requestCovers(urls)
|
||||
}
|
||||
/// Report now, then again once the ListView has laid out — `indexAt`
|
||||
/// answers -1 before that, which is the state of a rail that has just been
|
||||
/// rebuilt or just been mounted (views/local/LocalArtistsTab.qml:330).
|
||||
Timer {
|
||||
id: railSettle
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: root.reportRail()
|
||||
}
|
||||
function reportRailSoon() {
|
||||
root.reportRail()
|
||||
railSettle.restart()
|
||||
}
|
||||
|
||||
Item {
|
||||
id: rail
|
||||
width: root.railW
|
||||
height: parent.height
|
||||
clip: true
|
||||
|
||||
ListView {
|
||||
id: railList
|
||||
x: root.railPadLeft
|
||||
width: parent.width - root.railPadLeft
|
||||
height: parent.height
|
||||
// `.virtual-container { padding: 4px 12px 4px 0 }` — the 12px is
|
||||
// taken off the DELEGATE width below, not off the Flickable's
|
||||
// rightMargin, which on a vertical list only moves the horizontal
|
||||
// content bounds.
|
||||
topMargin: 4
|
||||
bottomMargin: 4
|
||||
clip: true
|
||||
cacheBuffer: root.rowH * 6
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.railEntries
|
||||
|
||||
onContentYChanged: root.reportRail()
|
||||
onModelChanged: root.reportRail()
|
||||
onHeightChanged: root.reportRail()
|
||||
onVisibleChanged: if (visible) root.reportRailSoon()
|
||||
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
width: railList.width - 12
|
||||
height: modelData.t === 0 ? root.groupHeadH : root.rowH
|
||||
sourceComponent: modelData.t === 0 ? groupHeadComp : artistRowComp
|
||||
|
||||
// `.group-header` — 11/600 uppercase muted, letter-spacing
|
||||
// 0.5, padding 12px 8px 4px, and NO count (the grid's header
|
||||
// has one; this one does not).
|
||||
Component {
|
||||
id: groupHeadComp
|
||||
Item {
|
||||
Text {
|
||||
x: 8
|
||||
y: parent.height - 4 - height
|
||||
text: String(modelData.label).toUpperCase()
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: artistRowComp
|
||||
Rectangle {
|
||||
id: artistRow
|
||||
|
||||
readonly property var item: modelData.item
|
||||
readonly property string qobuzId: String(artistRow.item.qobuzId || "")
|
||||
readonly property bool picked: root.selectedId !== ""
|
||||
&& root.selectedId === artistRow.qobuzId
|
||||
|
||||
width: parent.width
|
||||
height: root.rowH
|
||||
radius: 8
|
||||
color: artistRow.picked ? theme.surfaceElevated
|
||||
: (rowArea.containsMouse ? theme.bgHover : "transparent")
|
||||
// `transition: background-color 150ms ease`. Both arms
|
||||
// are gesture-bounded — hover, and a click on this very
|
||||
// row is the only thing that moves the selection.
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
// --- ticker state, all of it local to the hovered row
|
||||
// `--ticker-offset: -(overflow + 16)px`,
|
||||
// `--ticker-duration: (overflow + 16) / 40` seconds.
|
||||
readonly property real overflow:
|
||||
Math.max(0, nameText.implicitWidth - nameClip.width)
|
||||
readonly property bool ticking: rowArea.containsMouse && artistRow.overflow > 0
|
||||
readonly property real tickerOffset: -(artistRow.overflow + 16)
|
||||
readonly property real tickerMs: (artistRow.overflow + 16) * 25
|
||||
/// Loop phase 0..1, advanced on the SHELL PULSE. Must
|
||||
/// match settings_qt::shell_pulse_ms (default 33) — a
|
||||
/// mismatch only mis-speeds the ticker, never the
|
||||
/// present rate (immersive/EqualizerBars.qml:55).
|
||||
readonly property int _tickMs: 33
|
||||
property real phase: 0
|
||||
property real tickerX: 0
|
||||
|
||||
// Disabled while the row is not ticking, so a row at
|
||||
// rest writes NOTHING — an invisible or frozen
|
||||
// component that keeps assigning still dirties the
|
||||
// whole window, which is the entire GPU bill on this
|
||||
// shell.
|
||||
Connections {
|
||||
target: QbzShell
|
||||
enabled: artistRow.ticking
|
||||
function onPulseMsChanged() {
|
||||
artistRow.phase = (artistRow.phase
|
||||
+ artistRow._tickMs / artistRow.tickerMs) % 1.0
|
||||
artistRow.tickerX = artistRow.tickerAt(artistRow.phase)
|
||||
}
|
||||
}
|
||||
onTickingChanged: if (!artistRow.ticking) {
|
||||
// One write on the way out, bounded by the gesture.
|
||||
artistRow.phase = 0
|
||||
artistRow.tickerX = 0
|
||||
}
|
||||
// `@keyframes artist-name-ticker`, linear:
|
||||
// 0%,20% -> 0 · 70%,80% -> offset · 90%,100% -> 0.
|
||||
function tickerAt(u) {
|
||||
var o = artistRow.tickerOffset
|
||||
if (u < 0.2)
|
||||
return 0
|
||||
if (u < 0.7)
|
||||
return o * (u - 0.2) / 0.5
|
||||
if (u < 0.8)
|
||||
return o
|
||||
if (u < 0.9)
|
||||
return o * (1.0 - (u - 0.8) / 0.1)
|
||||
return 0
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 12
|
||||
|
||||
// 48px circle, `MicVocal size={20}` placeholder.
|
||||
Rectangle {
|
||||
width: 48
|
||||
height: 48
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: 24
|
||||
color: theme.surfaceElevated
|
||||
clip: true
|
||||
QbzIcon {
|
||||
visible: portrait.source === ""
|
||||
anchors.centerIn: parent
|
||||
name: "mic-vocal"
|
||||
width: 20
|
||||
height: 20
|
||||
tintName: "muted"
|
||||
}
|
||||
RoundedImage {
|
||||
id: portrait
|
||||
anchors.fill: parent
|
||||
// `artUrl` since the card swap (owner ruling R8): the row is
|
||||
// published in the house card shape now, and the old
|
||||
// `imageUrl` no longer exists. The `|| imageUrl` tail is
|
||||
// the same one-token seam insurance this file documents
|
||||
// for the album row's field names — a row from any other
|
||||
// producer still draws instead of a blank rail.
|
||||
source: root.coverMap[String(artistRow.item.artUrl
|
||||
|| artistRow.item.imageUrl || "")] || ""
|
||||
radius: 24
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: nameClip
|
||||
width: parent.width - 48 - 12
|
||||
height: parent.height
|
||||
clip: true
|
||||
Text {
|
||||
id: nameText
|
||||
x: artistRow.tickerX
|
||||
width: artistRow.ticking ? implicitWidth : nameClip.width
|
||||
height: parent.height
|
||||
text: root.artistNameOf(artistRow.item)
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 14
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
// `text-overflow: clip` while ticking.
|
||||
elide: artistRow.ticking ? Text.ElideNone : Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.artistSelected(artistRow.qobuzId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `border-right: 1px solid var(--bg-tertiary)` — the reference's rule is
|
||||
// the ELEVATED surface, not a border token.
|
||||
Rectangle {
|
||||
x: root.railW
|
||||
width: 1
|
||||
height: parent.height
|
||||
color: theme.surfaceElevated
|
||||
}
|
||||
|
||||
// ========================= the albums column =========================
|
||||
Item {
|
||||
id: pane
|
||||
|
||||
// `.artist-albums-column { padding: 0 8px 0 24px }` plus the scroll
|
||||
// area's own `padding-right: 8px`.
|
||||
readonly property int padLeft: 24
|
||||
readonly property int padRight: 16
|
||||
readonly property int gridW: Math.max(root.trackMin, pane.width - pane.padLeft - pane.padRight)
|
||||
readonly property int columns: Math.max(
|
||||
1, Math.floor((pane.gridW + root.gridGap) / (root.trackMin + root.gridGap)))
|
||||
// `auto-fill minmax(210px, 1fr)`: the TRACK stretches to 1fr, while the
|
||||
// card stays 220 wide and may overflow it. 00-CONTRACT.md §6 lists that
|
||||
// mismatch under "deliberately NOT diverged" — reproduce it, do not fix
|
||||
// it silently.
|
||||
readonly property real trackW: (pane.gridW - (pane.columns - 1) * root.gridGap) / pane.columns
|
||||
|
||||
// NOT `state`: every Item already owns a `state` string for QML states,
|
||||
// and redeclaring it is a hard parse error.
|
||||
readonly property string panelState: String(root.doc.state || "idle")
|
||||
readonly property bool showIdle: root.selectedId === "" || pane.panelState === "idle"
|
||||
readonly property bool showLoading: !pane.showIdle && pane.panelState === "loading"
|
||||
readonly property bool showError: !pane.showIdle && pane.panelState === "error"
|
||||
readonly property bool showEmpty: !pane.showIdle && pane.panelState !== "loading"
|
||||
&& pane.panelState !== "error" && root.albumEntries.length === 0
|
||||
readonly property bool showList: !pane.showIdle && pane.panelState !== "loading"
|
||||
&& pane.panelState !== "error" && root.albumEntries.length > 0
|
||||
|
||||
x: root.railW + 1
|
||||
y: 0
|
||||
width: parent.width - x
|
||||
height: parent.height
|
||||
clip: true
|
||||
|
||||
// --- 1 · nothing selected (`MicVocal size={48}`) ------------------
|
||||
// The four pre-list states share `.artist-albums-*`: a centred column,
|
||||
// gap 12, 14px muted.
|
||||
Column {
|
||||
visible: pane.showIdle
|
||||
anchors.centerIn: parent
|
||||
spacing: 12
|
||||
QbzIcon {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
name: "mic-vocal"
|
||||
width: 48
|
||||
height: 48
|
||||
tintName: "muted"
|
||||
}
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: QbzSession.tr("Select an artist to see their albums", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 14
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2 · loading --------------------------------------------------
|
||||
Column {
|
||||
visible: pane.showLoading
|
||||
anchors.centerIn: parent
|
||||
spacing: 12
|
||||
QbzLoadingDots {
|
||||
id: dots
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
// Cycled off the shell pulse instead of a local Timer, and only
|
||||
// while this column is up: ~300ms per step, three property
|
||||
// writes a second, zero when idle.
|
||||
property int accMs: 0
|
||||
Connections {
|
||||
target: QbzShell
|
||||
enabled: pane.showLoading
|
||||
function onPulseMsChanged() {
|
||||
dots.accMs += 33
|
||||
if (dots.accMs >= 300) {
|
||||
dots.accMs = 0
|
||||
dots.phase = (dots.phase + 1) % 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: QbzSession.tr("Loading albums...", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 14
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3 · error, with the raw detail under it ----------------------
|
||||
Column {
|
||||
visible: pane.showError
|
||||
anchors.centerIn: parent
|
||||
spacing: 12
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: QbzSession.tr("Failed to load albums", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 14
|
||||
}
|
||||
// `.error-detail { font-size: 12px; opacity: 0.7 }` — the raw
|
||||
// String(err), untranslated by construction.
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: Math.min(implicitWidth, pane.width - 48)
|
||||
text: String(root.doc.error || "")
|
||||
color: theme.textMuted
|
||||
opacity: 0.7
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4 · loaded, nothing to show (`Disc3 size={48}`) --------------
|
||||
Column {
|
||||
visible: pane.showEmpty
|
||||
anchors.centerIn: parent
|
||||
spacing: 12
|
||||
QbzIcon {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
name: "disc-3"
|
||||
width: 48
|
||||
height: 48
|
||||
tintName: "muted"
|
||||
}
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: QbzSession.tr("No albums found", QbzSession.trRev)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 14
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5 · the three sections ---------------------------------------
|
||||
ListView {
|
||||
id: albumList
|
||||
visible: pane.showList
|
||||
x: pane.padLeft
|
||||
width: pane.gridW
|
||||
height: parent.height
|
||||
clip: true
|
||||
cacheBuffer: root.cardH
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: root.albumEntries
|
||||
|
||||
onContentYChanged: root.reportAlbums()
|
||||
onModelChanged: root.reportAlbums()
|
||||
onHeightChanged: root.reportAlbums()
|
||||
onVisibleChanged: if (visible) root.reportAlbumsSoon()
|
||||
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
width: albumList.width
|
||||
height: modelData.h
|
||||
sourceComponent: modelData.t === 0 ? sectionHeadComp : albumRowComp
|
||||
|
||||
Component {
|
||||
id: sectionHeadComp
|
||||
Item {
|
||||
// `align-items: baseline; gap: 12px`, then 12px of air,
|
||||
// a 1px rule and 16px of margin.
|
||||
Text {
|
||||
id: secTitle
|
||||
y: 0
|
||||
text: modelData.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 16
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
Text {
|
||||
anchors.left: secTitle.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.baseline: secTitle.baseline
|
||||
text: modelData.count
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Rectangle {
|
||||
y: secTitle.height + 12
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.surfaceElevated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: albumRowComp
|
||||
Item {
|
||||
Repeater {
|
||||
model: modelData.albums
|
||||
delegate: SceneAlbumCard {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
x: index * (pane.trackW + root.gridGap)
|
||||
albumId: root.albumIdOf(modelData)
|
||||
title: String(modelData.title || "")
|
||||
artist: String(modelData.artist || modelData.artistName || "")
|
||||
genre: String(modelData.genre || "")
|
||||
year: root.albumYearOf(modelData)
|
||||
artSource: root.coverMap[root.albumArtOf(modelData)] || ""
|
||||
bitDepth: Number(modelData.bitDepth || 0)
|
||||
samplingRate: Number(modelData.samplingRate || 0)
|
||||
qualityTier: String(modelData.qualityTier || "")
|
||||
qualityDetail: String(modelData.qualityDetail || "")
|
||||
onActivated: root.albumActivated(albumId)
|
||||
onPlayed: root.albumPlayed(albumId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
target: albumList
|
||||
visible: albumList.visible && albumList.contentHeight > albumList.height
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== the album entry model =========================
|
||||
// Flat rows so ONE ListView windows headers AND grid rows:
|
||||
// { t: 0, title, count, h } | { t: 1, albums: [...], h }
|
||||
// The trailing margins live in `h` rather than in ListView.spacing,
|
||||
// because the three gaps differ: 22 between grid rows, 32 under a section,
|
||||
// 16 under the LAST one.
|
||||
property var albumEntries: []
|
||||
function rebuildAlbums() {
|
||||
var secs = [
|
||||
{ "title": QbzSession.tr("Discography", QbzSession.trRev),
|
||||
"albums": root.doc.discography || [] },
|
||||
{ "title": QbzSession.tr("EPs & Singles", QbzSession.trRev),
|
||||
"albums": root.doc.epsSingles || [] },
|
||||
{ "title": QbzSession.tr("Live Albums", QbzSession.trRev),
|
||||
"albums": root.doc.liveAlbums || [] }
|
||||
]
|
||||
// Fixed order, each rendered only when non-empty (V:748-813).
|
||||
var live = []
|
||||
for (var s = 0; s < secs.length; s++)
|
||||
if (secs[s].albums.length > 0)
|
||||
live.push(secs[s])
|
||||
|
||||
var out = []
|
||||
var cols = pane.columns
|
||||
for (s = 0; s < live.length; s++) {
|
||||
out.push({ "t": 0, "title": live[s].title,
|
||||
"count": live[s].albums.length, "h": root.sectionHeadH })
|
||||
var al = live[s].albums
|
||||
var rows = Math.ceil(al.length / cols)
|
||||
for (var r = 0; r < rows; r++) {
|
||||
var lastRow = (r === rows - 1)
|
||||
var tail = lastRow ? (s === live.length - 1 ? 16 : 32) : root.gridGap
|
||||
out.push({ "t": 1, "albums": al.slice(r * cols, (r + 1) * cols),
|
||||
"h": root.cardH + tail })
|
||||
}
|
||||
}
|
||||
root.albumEntries = out
|
||||
}
|
||||
onDocChanged: {
|
||||
// A new document is a new artist: forget which covers were asked for,
|
||||
// or the next pane starts with an `_asked` map full of the previous
|
||||
// artist's urls and re-requests nothing it needs.
|
||||
root._asked = ({})
|
||||
root.rebuildAlbums()
|
||||
root.reportAlbumsSoon()
|
||||
}
|
||||
// The column count is width-derived, so a resize re-slices the rows.
|
||||
Connections {
|
||||
target: pane
|
||||
function onColumnsChanged() { root.rebuildAlbums() }
|
||||
}
|
||||
// A live language switch must re-slice too. `rebuildAlbums` calls
|
||||
// `QbzSession.tr(...)` for the three section titles and stores the RESULT
|
||||
// in `albumEntries`, and the delegate renders that snapshot verbatim — so
|
||||
// the strings are data, not bindings, and `trRev` cannot reach them. Its
|
||||
// two other triggers (a new document, a resize) may never fire again after
|
||||
// the user changes language, which would leave the previous language's
|
||||
// headings on screen indefinitely. Same shape as the house pattern that
|
||||
// threads the revision into a builder (NavFlyout.qml:111,
|
||||
// ArtistView.qml:1363).
|
||||
Connections {
|
||||
target: QbzSession
|
||||
function onTrRevChanged() { root.rebuildAlbums() }
|
||||
}
|
||||
|
||||
function reportAlbums() {
|
||||
if (!root.visible || !albumList.visible || root.albumEntries.length === 0)
|
||||
return
|
||||
var first = albumList.indexAt(4, albumList.contentY + 1)
|
||||
var last = albumList.indexAt(4, albumList.contentY + Math.max(1, albumList.height) - 1)
|
||||
if (first < 0)
|
||||
first = 0
|
||||
if (last < 0)
|
||||
last = Math.min(root.albumEntries.length - 1, first + 3)
|
||||
// One screen of buffer either side, which is what keeps a fast scroll
|
||||
// from showing placeholders — and the cap above is what keeps a fast
|
||||
// scroll from queueing the whole discography.
|
||||
first = Math.max(0, first - 1)
|
||||
last = Math.min(root.albumEntries.length - 1, last + 1)
|
||||
var urls = []
|
||||
for (var i = first; i <= last; i++) {
|
||||
var e = root.albumEntries[i]
|
||||
if (e.t !== 1)
|
||||
continue
|
||||
for (var a = 0; a < e.albums.length; a++) {
|
||||
var u = root.albumArtOf(e.albums[a])
|
||||
if (u !== "")
|
||||
urls.push(u)
|
||||
}
|
||||
}
|
||||
root.requestCovers(urls)
|
||||
}
|
||||
Timer {
|
||||
id: albumSettle
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: root.reportAlbums()
|
||||
}
|
||||
function reportAlbumsSoon() {
|
||||
root.reportAlbums()
|
||||
albumSettle.restart()
|
||||
}
|
||||
|
||||
// ============================ the album card =========================
|
||||
// AlbumCardLite, not cards/AlbumCard.qml: 220 wide over a 220×220 cover at
|
||||
// radius 6, a 4px gap, then a meta row of the title/artist stack against
|
||||
// the quality badge. The shared card is 200×246 with a pin badge, a heart,
|
||||
// an award ribbon, a source badge and a nine-entry context menu — none of
|
||||
// which this surface has.
|
||||
//
|
||||
// Declared as an inline component, which QML requires at the file's top
|
||||
// level, and it reads NOTHING off an outer id: every input arrives as a
|
||||
// property (the rule rows/TrackRow.qml:359-363 records).
|
||||
component SceneAlbumCard: Item {
|
||||
id: acard
|
||||
|
||||
property string albumId: ""
|
||||
property string title: ""
|
||||
property string artist: ""
|
||||
property string genre: ""
|
||||
property string year: ""
|
||||
property string artSource: ""
|
||||
property int bitDepth: 0
|
||||
property real samplingRate: 0
|
||||
/// The tier the RUST side already resolved (artist_scene_qt.rs), not a
|
||||
/// number for QML to re-derive. See the badge mount below.
|
||||
property string qualityTier: ""
|
||||
property string qualityDetail: ""
|
||||
|
||||
signal activated()
|
||||
signal played()
|
||||
|
||||
QbzTheme { id: cardTheme }
|
||||
|
||||
width: 220
|
||||
height: 262
|
||||
|
||||
Rectangle {
|
||||
id: cover
|
||||
width: 220
|
||||
height: 220
|
||||
radius: 6
|
||||
color: cardTheme.surfaceElevated
|
||||
clip: true
|
||||
|
||||
RoundedImage {
|
||||
anchors.fill: parent
|
||||
source: acard.artSource
|
||||
radius: 6
|
||||
}
|
||||
|
||||
// `.cover-wrap::after` — a full scrim at 0.6, 150ms.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 6
|
||||
color: Qt.rgba(0, 0, 0, 0.6)
|
||||
opacity: coverArea.containsMouse ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
// `.meta` — top-left, and rendered ONLY when there is something to
|
||||
// say (`{#if genre || releaseLabel}`); a card with neither shows no
|
||||
// overlay text at all.
|
||||
Column {
|
||||
visible: acard.genre !== "" || acard.year !== ""
|
||||
x: 12
|
||||
y: 12
|
||||
width: parent.width * 0.7
|
||||
spacing: 2
|
||||
opacity: coverArea.containsMouse ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
Text {
|
||||
visible: acard.genre !== ""
|
||||
width: parent.width
|
||||
text: acard.genre
|
||||
// Fixed light-on-scrim by design, like every other overlay
|
||||
// in this port: the host is a 0.6 black wash under every
|
||||
// theme (theme/QbzIcon.qml, "FIXED (theme-independent)").
|
||||
color: Qt.rgba(1, 1, 1, 0.92)
|
||||
font.pixelSize: 14
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
visible: acard.year !== ""
|
||||
width: parent.width
|
||||
text: acard.year
|
||||
color: Qt.rgba(1, 1, 1, 0.92)
|
||||
opacity: 0.8
|
||||
font.pixelSize: 13
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// Card-open + hover detector, before the buttons so they win the
|
||||
// pointer (cards/ArtistCard.qml:172-174).
|
||||
MouseArea {
|
||||
id: coverArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: acard.activated()
|
||||
}
|
||||
|
||||
// `.actions` — bottom-centre, `bottom: 44px`, so the 44px row's top
|
||||
// sits at 220 - 44 - 44.
|
||||
//
|
||||
// ONE button, where Tauri draws three. Its Heart is inert by
|
||||
// construction (this view never passes `onFavorite`), and its kebab
|
||||
// opens AlbumQuickMenu with exactly one live item, "Go to album" —
|
||||
// which is what a click on the card already does. The frozen mount
|
||||
// API for this component carries no favourite and no menu signal,
|
||||
// and 00-CONTRACT.md's DV-2 rationale ("dead UI is neither pretty
|
||||
// nor functional") settles the rest. Recorded as a deviation.
|
||||
CardOverlayRow {
|
||||
y: 132
|
||||
width: parent.width
|
||||
shown: coverArea.containsMouse
|
||||
CardOverlayButton {
|
||||
name: "play-fill"
|
||||
primary: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: acard.played()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `.meta-row { align-items: center; gap: 8px }` over
|
||||
// `.text-stack { gap: 2px }`, 4px under the cover.
|
||||
Item {
|
||||
y: 224
|
||||
width: parent.width
|
||||
height: 38
|
||||
|
||||
Column {
|
||||
id: textStack
|
||||
// The badge hides itself on an unknown tier (DV-9), and an
|
||||
// invisible Item still HAS a width — so the 8px gap and the
|
||||
// badge's own box only come off the stack when it is drawn.
|
||||
width: parent.width - (badge.visible ? badge.width + 8 : 0)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: acard.title
|
||||
color: cardTheme.textPrimary
|
||||
font.pixelSize: 14
|
||||
font.weight: cardTheme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: acard.artist
|
||||
color: cardTheme.textMuted
|
||||
font.pixelSize: 13
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// DV-9: Tauri passes no quality at all here, so QualityBadgeStatic
|
||||
// falls through its tier ladder and EVERY scene album card claims
|
||||
// CD. Misinformation about audio quality is load-bearing in this
|
||||
// app, so the real numbers ride the row and the badge hides itself
|
||||
// when the depth is unknown (the home_qt.rs:1183-1200 rule, which
|
||||
// QualityBadge already implements through its own
|
||||
// `visible: resolvedTier !== ""` — do NOT override that binding).
|
||||
QualityBadge {
|
||||
id: badge
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// THE TIER COMES FROM RUST. Passing only the raw numbers let
|
||||
// QualityBadge re-derive it in QML, and its ladder ends in
|
||||
// `if (samplingRate >= 44.1) return "cd"` — so every album
|
||||
// whose depth was unknown fell out as CD. That is precisely
|
||||
// the reference defect DV-9 exists to remove: Tauri passed no
|
||||
// quality at all and stamped a CD badge on the entire grid.
|
||||
// `artist_scene_qt.rs` resolves the tier from the real depth
|
||||
// and BLANKS it when the depth is unknown, and the badge hides
|
||||
// itself on an empty tier — so an album we cannot vouch for
|
||||
// now shows nothing rather than a confident lie.
|
||||
//
|
||||
// Same shape as every other card in the app
|
||||
// (cards/AlbumCard.qml:687, local/LocalAlbumRow.qml:162,
|
||||
// library/FeedListRow.qml:278, cards/TrackCard.qml:223).
|
||||
tierOverride: acard.qualityTier
|
||||
label: acard.qualityDetail
|
||||
// Kept for the hover tooltip only, never for tier resolution.
|
||||
bitDepth: acard.bitDepth
|
||||
samplingRate: acard.samplingRate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user