Files
qbz/crates/qbz-qt/qml/shell/MusicianModal.qml
T
vicrodh 016eb3cd23 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
2026-08-14 19:25:11 -06:00

578 lines
27 KiB
QML

// Musician modal — QML port of Tauri's MusicianModal.svelte (322 lines, read in
// full). The surface a WEAK / NONE / resolve-error musician click lands on: it
// is NOT a page. Its own docblock is accurate — "provides context only", "does
// NOT show albums, tracks, or playlists", "does NOT allow deep navigation" —
// and it is the majority case, because most sidemen are not Qobuz artists.
//
// THE PAIR: this and views/MusicianPageView.qml COEXIST. A single dispatcher in
// Rust (musician_qt.rs) picks between them by confidence and publishes either
// `musicianJson` (page) or `modalJson` (this). QML never branches on
// confidence — 00-CONTRACT §13.2.
//
// MOUNT: a global overlay in the AppShell tree, `anchors.fill: parent`, the
// same self-gated shape KeyboardShortcutsModal / CustomizeShortcutsModal use.
// While `modalJson` is "" this is an invisible, non-interactive Item that
// parses nothing. z:3200 satisfies ADR-009 (>= 3000) and clears the port's
// in-pane modals at 3100, so it also paints above the immersive overlay — which
// is the required behaviour for consumer #6: a weak/none click inside the
// immersive Track-info panel must open THIS above the still-open overlay, while
// a contextual one navigates underneath it (00-CONTRACT §11/O6).
//
// Anatomy, every number from the .svelte:
// :129-139 backdrop rgba(0,0,0,0.7) full-window, click-outside dismisses
// :146-155 card surface-card, radius 16, width 90% max 420, max-height 85vh,
// its own scroller, shadow 0/24/48 rgba(0,0,0,0.4)
// :168-174 header padding 24/24/16, align-items flex-start, 1px bottom rule
// :182-192 48px circular icon, surface-elevated, User glyph 24 muted
// (the page's is 56/32 — the modal is one step smaller)
// :200-212 name h2 20/600 lh 1.2, role 14 muted CAPITALIZED
// :214-232 close 32x32 radius 8 surface-elevated, muted -> hover bg-hover +
// text-primary, X glyph 18
// :234-253 content padding 24; section margin-bottom 20; h3 11/600 UPPERCASE
// letter-spacing 0.5 muted, margin-bottom 12
// :255-272 "Known for" — a VERTICAL list (the page uses horizontal chips),
// gap 8, rows 14 primary with a 14px muted Music glyph
// :274-298 info box: row gap 12, padding 16, surface-elevated, radius 12;
// Info glyph 16 muted nudged 2px down; copy 13 / line-height 1.5
// :300-321 the CTA — see UNREACHABLE below
//
// LIVE GEOMETRY, which reading those three margins in isolation gets wrong
// (08-critique-findings, MINOR on §4.3): `bands` is empty in practice, so the
// info section is BOTH the first and the last child of .modal-content. Its
// 24px margin-top does not collapse against the container's 24px padding, so
// the box sits 48px below the header rule and 0px above the card bottom (a
// last-child margin-bottom of 0, then the container's own 24px padding). A port
// that places it at 24 draws a visibly shorter, top-heavy modal. Hence the
// content Column below is padded 24 and the info block carries its own 24.
//
// DELIBERATE DIVERGENCES
// - DV-12/§7 the three explanatory strings are hardcoded English in Tauri
// and translated here. The catalogue's msgid IS the English
// source string, so the English output is byte-identical and the
// 1:1 is fully preserved; the other seven locales gain a real
// translation. Same for the close tooltip.
// - ADR-008 Tauri's band rows are plain text with no container, so this
// surface has no pill to demote — unlike the page's chips (DV-19).
// - no entry animation. Tauri fades the backdrop in over 150ms and slides the
// card up 20px over 200ms. No modal in this port animates its
// entry (KeyboardShortcutsModal, CustomizeShortcutsModal,
// QbzConfirmModal, AlbumInfoModal, TrackInfoModal — none), and
// matching them keeps one behaviour across the shell. Recorded so
// it is a decision, not an omission.
// - no backdrop blur. `backdrop-filter: blur(4px)` needs a live blur of
// everything behind the scrim; the port's shadow/blur doctrine
// (TrackInfoModal.qml:17-25) is that such a change owes its own
// parity pass rather than riding along with a port.
// - the shadow is FAKED (a translucent rounded rect behind the card), the
// LoginScreen / QbzConfirmModal / KeyboardShortcutsModal
// precedent, for the same reason.
// - `user-select: text` on the card (app.css:1580-1584) has no cheap QML
// equivalent — a selectable Text is a TextEdit — so the copy is
// not selectable here. Flagged, not silently dropped.
//
// COLOURS: tokens only, per 00-CONTRACT §4.3. The two rgba() literals are
// written as Qt.rgba(r,g,b,a) with 0..1 floats and NEVER as 8-digit hex: CSS
// and Slint spell it #RRGGBBAA, Qt parses #AARRGGBB, so `#00000066` reads as
// FULLY TRANSPARENT black here. That trap has already landed twice in this
// port. The two 1px rules bind `borderSubtle` rather than `surfaceElevated`
// (Tauri styles them with --bg-tertiary, which is the same literal on dark):
// a divider's ROLE token is the border one, and using a surface fill as a
// border colour is what breaks in the light themes.
//
// ESCAPE / FOCUS (00-CONTRACT §5.2). Tauri dismisses on Escape, backdrop click
// and the X. Qt's central Escape stack (hotkeys_qt.rs:629-683,
// hotkeys_bridge.rs:370-401) does not know this modal and CONSUMES Escape even
// when it closes nothing, so Escape had to be owned explicitly. It is owned the
// way every other modal in this tree owns it, and no Rust registration is
// needed: an Item/FocusScope modal's Keys.onEscapePressed fires BEFORE the
// central stack and accepts the event, because the scope holds active focus and
// Qt walks the focus chain up to the AppShell root dispatcher, not down
// (KeyboardShortcutsModal.qml:43-51, "trap 4"). The grab is DEFERRED 30ms past
// the open edge — focusing synchronously races the visibility transition, and
// this modal is especially exposed because it opens asynchronously right after
// AlbumInfo or TrackInfo closes. On the close edge Qt strands activeFocus on
// the now-invisible scope, which kills the AppShell dispatcher until the next
// click, so every close path hands focus back to the shell root by duck-walking
// to `isQbzShellRoot` (QbzConfirmModal.qml:85-94). The restore is on the EDGE,
// not inside close(), so a close driven from Rust (the dispatcher opening the
// page instead, a language switch, a shutdown) restores too.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// "" = closed. The document IS the open flag (00-CONTRACT §13.2), so there
// is no second bridge property to keep in sync. `musicianRev` is a pure
// binding dependency, the QbzSession.tr(msgid, rev) idiom: the parse
// ignores it, reading it is what re-runs this binding when Rust republishes
// a byte-identical document.
readonly property string raw: QbzMusician.modalJson || ""
readonly property bool opened: root.raw !== ""
readonly property var doc: root._parse(root.raw, QbzMusician.musicianRev)
function _parse(s, rev) {
try {
return JSON.parse(s || "{}")
} catch (e) {
return ({})
}
}
readonly property var bands: doc.bands || []
// CSS `text-transform: capitalize` — title-case every WORD and leave the
// rest of it alone. QML has no text-transform; see MusicianPageView.qml.
function _capitalize(s) {
if (!s)
return ""
return String(s).replace(/(^|\s)(\S)/g, function (m, sp, ch) {
return sp + ch.toUpperCase()
})
}
// `explanatoryCopy` (:27-36). Three arms, and the third is the `default:`
// one — it fires for ANY confidence the switch does not name, so it is not
// dead the way the CTA is.
readonly property string explanatoryCopy: {
var c = root.doc.confidence || ""
if (c === "weak")
return root.t("This musician appears in album credits but has limited catalog information available in Qobuz.")
if (c === "none")
return root.t("Unable to verify this musician's catalog. The information shown is based on album credits.")
return root.t("This musician appears in album credits.")
}
// UNREACHABLE BY CONSTRUCTION, and present anyway (:39-41, :117-123). The
// dispatcher only opens this modal for `confirmed` when the Qobuz id is
// absent or zero, so the CTA's own guard can never pass; Tauri is identical.
// `qobuzArtistId` is NOT a member of the frozen `modalJson` shape
// (00-CONTRACT §13.2), so this reads `undefined` and the gate is false —
// which is exactly the wanted behaviour, and lights the block up unchanged
// the day the Rust lane adds the field. Reading an absent key off a parsed
// JSON object is not an error in QML, and it is not a bridge member, so
// qml_singleton_xref has nothing to complain about.
readonly property bool showArtistCta: root.doc.confidence === "confirmed"
&& (root.doc.qobuzArtistId || 0) > 0
visible: root.opened
enabled: root.opened
// ADR-009 (>= 3000), above the port's in-pane modals at 3100 and above the
// immersive overlay — see the header note on consumer #6.
z: 3200
function _close() {
QbzMusician.closeModal()
}
// :43-48 — navigate, THEN close, in that order (Tauri's own ordering, and
// the same order §2.2 records for the two parent modals).
function _openArtist() {
if (!root.showArtistCta)
return
QbzArtist.openArtist(String(root.doc.qobuzArtistId))
root._close()
}
// Focus lifecycle on the OPEN/CLOSE EDGE — see the header.
onOpenedChanged: {
if (root.opened) {
focusGrab.restart()
} else {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
}
Timer {
id: focusGrab
interval: 30
repeat: false
onTriggered: keyScope.forceActiveFocus()
}
// The keyboard focus ring — the modal is keyboard-operable for the same
// reason the page is (§5.2): every Tauri control here is a native <button>.
component FocusRing: Rectangle {
property Item host: null
property real inset: 3
anchors.fill: parent
anchors.margins: -inset
color: "transparent"
border.width: 2
border.color: theme.focusRing
visible: host !== null && host.activeFocus
z: 5
}
FocusScope {
id: keyScope
anchors.fill: parent
// The modal's OWN Escape. Accepts, so the central §1.2 stack never
// double-fires on the same press.
Keys.onEscapePressed: function (event) {
root._close()
event.accepted = true
}
// Backdrop — rgba(0, 0, 0, 0.7). A click ON THE BACKDROP dismisses;
// Tauri gates that on `e.target === e.currentTarget` so a click that
// bubbled out of the card never closes. Here the card's own MouseArea
// swallows those before they can reach this one.
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.7)
MouseArea {
anchors.fill: parent
onClicked: root._close()
}
}
// Faked drop shadow (Tauri: 0 24px 48px rgba(0,0,0,0.4)) — see header.
Rectangle {
x: card.x
y: card.y + 12
width: card.width
height: card.height
radius: theme.radiusLg
color: Qt.rgba(0, 0, 0, 0.4)
opacity: 0.5
}
Rectangle {
id: card
width: Math.min(root.width * 0.9, 420)
height: Math.min(root.height * 0.85, bodyCol.implicitHeight)
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusLg
color: theme.surfaceCard
clip: true
// FIRST child: swallows clicks so they never reach the backdrop.
MouseArea { anchors.fill: parent }
// `.modal { overflow-y: auto; max-height: 85vh }` — the WHOLE card
// scrolls, header included; the header is not sticky here.
Flickable {
id: flick
anchors.fill: parent
contentWidth: width
contentHeight: bodyCol.implicitHeight
boundsBehavior: Flickable.StopAtBounds
clip: true
Column {
id: bodyCol
width: flick.width
spacing: 0
// ---- Header (:76-89) ----------------------------------
// padding 24 / 24 / 16, align-items FLEX-START (the close
// button sits at the top of the row, not centred on it).
Item {
id: headerBox
width: parent.width
height: headerRow.height + 40
Row {
id: headerRow
x: 24
y: 24
width: parent.width - 48
spacing: 16
// .musician-icon — 48px circle, User glyph 24.
Rectangle {
width: 48
height: 48
radius: 24
color: theme.surfaceElevated
QbzIcon {
anchors.centerIn: parent
name: "user"
width: 24
height: 24
tintName: "muted"
}
}
// .musician-details — column, gap 4.
Column {
width: parent.width - 48 - 16 - 32 - 16
spacing: 4
Text {
width: parent.width
text: root.doc.name || ""
color: theme.textPrimary
font.pixelSize: 20
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root._capitalize(root.doc.role || "")
color: theme.textMuted
font.pixelSize: 14
elide: Text.ElideRight
}
}
// .close-btn — 32x32, radius 8, surface-elevated;
// muted glyph that goes text-primary on hover, and
// the fill goes bg-hover. (The page's back button
// was 36px and had no colour change — this one is
// its own control.)
Item {
id: closeCell
width: 32
height: 32
activeFocusOnTab: true
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space) {
root._close()
event.accepted = true
}
}
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
color: closeArea.containsMouse
? theme.bgHover : theme.surfaceElevated
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 18
height: 18
tintName: closeArea.containsMouse
? "textPrimary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root._close()
}
}
FocusRing {
host: closeCell
radius: theme.radiusSm + 3
}
}
}
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: theme.borderSubtle
}
}
// ---- Content (.modal-content, padding 24) -------------
Column {
width: parent.width
leftPadding: 24
rightPadding: 24
topPadding: 24
bottomPadding: 24
spacing: 0
readonly property real contentWidth: width - 48
// ==== Block 1 — "Known for" (:94-106) =============
// DEAD in Tauri (`bands` is hardcoded empty on both
// resolver return paths, in BOTH backends) and made
// real by DV-2. Absent, not empty, when there is
// nothing to show.
Column {
visible: root.bands.length > 0
width: parent.contentWidth
spacing: 12
Text {
text: root.t("Known for")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.5
}
// .bands-list — a VERTICAL list, gap 8. Not
// clickable: Tauri renders plain rows here (the
// horizontal chips on the PAGE are the clickable
// ones), so there is nothing to make live.
Column {
width: parent.width
spacing: 8
Repeater {
model: root.bands
delegate: Row {
required property var modelData
width: parent ? parent.width : 0
spacing: 10
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: "music"
width: 14
height: 14
tintName: "muted"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: modelData.name || ""
color: theme.textPrimary
font.pixelSize: 14
}
}
}
}
}
// ==== Block 2 — the explanatory box (:109-114) ====
// Always rendered. Its own 24px top margin, which does
// NOT collapse into the container's padding — see the
// LIVE GEOMETRY note in the header.
Column {
width: parent.contentWidth
topPadding: 24
Rectangle {
width: parent.width
height: infoRow.height + 32
radius: theme.radiusMd
color: theme.surfaceElevated
Row {
id: infoRow
x: 16
y: 16
width: parent.width - 32
spacing: 12
// The glyph is nudged 2px down so it sits
// on the first line of copy, not above it.
QbzIcon {
y: 2
name: "info"
width: 16
height: 16
tintName: "muted"
}
Text {
width: parent.width - 16 - 12
text: root.explanatoryCopy
color: theme.textSecondary
font.pixelSize: 13
lineHeight: 1.5
lineHeightMode: Text.ProportionalHeight
wrapMode: Text.WordWrap
}
}
}
}
// ==== Block 3 — the CTA (:117-123) ================
// Present and unreachable — see showArtistCta.
Column {
visible: root.showArtistCta
width: parent.contentWidth
topPadding: 24
spacing: 20
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
Item {
id: ctaCell
width: parent.width
height: 44
activeFocusOnTab: true
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space) {
root._openArtist()
event.accepted = true
}
}
// width 100%, padding 12/20, radius 10 — a
// literal, not a token: 10 sits between
// Radius.sm (8) and Radius.md (12), and this is
// not a pill at 44px tall, so ADR-008 has
// nothing to say about it.
Rectangle {
anchors.fill: parent
radius: 10
color: ctaArea.containsMouse
? theme.accentHover : theme.accent
Text {
anchors.centerIn: parent
text: root.t("View Artist Page")
// The on-accent glyph selector, this
// port's owner-approved WCAG deviation
// (QbzTheme.qml:337) — Tauri hardcodes
// --btn-primary-text #ffffff, which is
// unreadable on the light accents some
// of the 30 themes carry.
color: theme.accentGlyphColor
font.pixelSize: 14
font.weight: theme.weightSemibold
}
MouseArea {
id: ctaArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root._openArtist()
}
}
FocusRing {
host: ctaCell
radius: 13
}
}
}
}
}
}
QbzScrollBar {
target: flick
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}