Files
qbz/crates/qbz-qt/qml/controls/TrackReplacementModal.qml
T
vicrodh 34ea883d07 fix(qt): the replacement modal spells out each candidate's quality
The tier badge alone reads as a mark, and its hover tooltip never shows
on these rows because the row's MouseArea takes the hover. The exact
tier ("24-bit / 96 kHz") now sits beside the badge — it is the decision
aid the list exists for.

cc
2026-09-14 00:49:54 -06:00

531 lines
25 KiB
QML

// "Find available version" — the replacement modal for a track Qobuz PULLED
// from the catalogue (2026-08-17 unavailable-tracks contract §6).
//
// It has NO Slint counterpart: the feature never existed there, and the only
// prior art is the Tauri TrackReplacementModal.svelte, which showed Qobuz's raw
// relevance order with no scoring at all. The list here is RANKED by the shared
// weighted matcher (qbz-playlist-import), the head of it is preselected, and an
// ISRC-identical candidate is labelled as the exact relink it is — see
// track_replace_qt.rs for the ordering rules. The human still confirms; nothing
// swaps itself.
//
// Mounted ONCE in AppShell next to PlaylistPickerModal, for the same reason:
// the apply RELOADS the playlist underneath the modal, so a modal parented into
// the playlist view would be torn down by its own success.
//
// Data: QbzTrackReplace.replaceJson (track_replace_qt.rs ReplaceDoc). The
// candidate list is built in Rust — ranking, the ISRC short-circuit, the
// same-id guard and the streamable filter all live there, so this file renders
// rows and owns nothing but the query field's local text.
//
// No `z`: this file's neighbours in AppShell are ordered by DECLARATION, and
// ADR-009's z >= 3000 governs IN-PANE modals. A window-level overlay covering
// the content pane's rounded corners is correct — a window-wide scrim covers
// them too.
// No QtQuick.Controls import: every type here is QtQuick or a project
// component, and nothing on this surface uses the attached ToolTip that pulls
// the module into rows/TrackRow.qml.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
// An unopened overlay is an invisible, non-interactive Item and costs
// nothing (the AppShell mount comment states the contract).
visible: doc.open === true
readonly property var doc: JSON.parse(QbzTrackReplace.replaceJson)
readonly property var rows: doc.candidates || []
readonly property bool loading: doc.loading === true
readonly property bool applying: doc.applying === true
readonly property string selectedId: doc.selectedId || ""
readonly property string selectedAlbumId: doc.selectedAlbumId || ""
readonly property bool releaseMode: doc.mode === "release"
readonly property string targetKind: doc.targetKind || "playlist"
// The ONLY local state in the file: what the user has typed but not yet
// submitted. Everything else is Rust's document, so a republish (a late
// search landing, the apply latch) can never fight the view.
property string queryText: ""
onVisibleChanged: if (visible) queryText = root.doc.query || ""
// Re-seed when RUST changes the query (the open path builds "title artist")
// and the field is not being edited.
Connections {
target: QbzTrackReplace
function onReplaceJsonChanged() {
if (!queryInput.activeFocus)
root.queryText = root.doc.query || ""
}
}
function submitQuery() {
if (root.applying)
return
var q = root.queryText.trim()
if (q !== "")
QbzTrackReplace.search(q)
}
QbzTheme { id: theme }
// Scrim — click outside dismisses, unless a write is in flight: closing
// mid-apply would hide the outcome of a playlist mutation the user cannot
// undo from anywhere else.
Rectangle {
anchors.fill: parent
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: if (!root.applying) QbzTrackReplace.close()
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
id: card
anchors.centerIn: parent
width: Math.min(parent.width - 48, 760)
height: Math.min(parent.height - 48, 520)
radius: theme.radiusLg
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
clip: true
// Swallow clicks so they never reach the scrim.
MouseArea {
anchors.fill: parent
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
Column {
id: panel
width: parent.width
// --- Header ---------------------------------------------------
Item {
width: parent.width
height: 72
Column {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.right: closeX.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.releaseMode
? QbzSession.tr("Look for better replacement", QbzSession.trRev)
: QbzSession.tr("Find available version", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
// WHICH row is being repaired. The modal is opened from a
// row context menu and the list below is full of near-
// identical titles, so naming the dead track is not
// decoration — it is how the user checks they right-clicked
// the row they meant.
Text {
width: parent.width
text: (root.doc.deadTitle || "")
+ ((root.doc.deadArtist || "") !== ""
? " — " + root.doc.deadArtist : "")
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Item {
id: closeX
anchors.right: parent.right
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 17
height: 17
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !root.applying
onClicked: QbzTrackReplace.close()
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// --- Query --------------------------------------------------
// Editable and re-searchable: "title artist" is only a first guess,
// and the case this feature exists for is precisely the one where
// the recording moved and the words changed with it.
Item {
width: parent.width
height: 43 + 24
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.right: parent.right
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
height: 43
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: queryInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: queryInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 40
color: theme.textPrimary
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
enabled: !root.applying
text: root.queryText
onTextEdited: root.queryText = text
onAccepted: root.submitQuery()
// Re-seed while not being edited (the
// PlaylistPickerModal Binding shape).
Binding {
target: queryInput
property: "text"
value: root.queryText
when: !queryInput.activeFocus
}
}
Item {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 22
height: 22
QbzIcon {
anchors.centerIn: parent
name: "search"
width: 15
height: 15
tintName: searchArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: searchArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !root.applying
onClicked: root.submitQuery()
}
}
}
}
// --- Candidates ---------------------------------------------
Item {
width: parent.width
// Fills whatever the card has left between the query row and
// the footer, so the list scrolls instead of the card growing.
height: Math.max(120, card.height - 72 - 1 - 67 - 100)
QbzSpinner {
anchors.centerIn: parent
size: 30
visible: root.loading
}
// Nothing came back. It is a real outcome, not an error: the
// recording may simply not be on Qobuz any more in any form.
Text {
anchors.centerIn: parent
width: parent.width - 96
visible: !root.loading && root.rows.length === 0
text: root.releaseMode
? QbzSession.tr("No replacement releases found", QbzSession.trRev)
: QbzSession.tr("No available version found", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLink
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
ListView {
id: list
anchors.fill: parent
anchors.leftMargin: 24
anchors.rightMargin: 24
anchors.topMargin: 4
anchors.bottomMargin: 4
visible: !root.loading && root.rows.length > 0
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.rows
spacing: 2
delegate: Rectangle {
id: cand
required property var modelData
readonly property bool picked: root.selectedId === modelData.id
width: ListView.view ? ListView.view.width : 0
height: 64
radius: theme.radiusSm
color: picked ? theme.surfaceElevated
: (candArea.containsMouse ? theme.surfaceHover : "transparent")
border.width: picked ? 1 : 0
border.color: theme.accent
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 10
// Cover. `artPath` arrives EMPTY on the first
// publish and is filled by a second one once the
// download settles, so the placeholder is the
// normal first frame, not a failure.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 46
height: 46
radius: 3
color: theme.surfaceElevated
clip: true
Image {
anchors.fill: parent
visible: (cand.modelData.artPath || "") !== ""
source: cand.modelData.artPath || ""
sourceSize.width: 80
sourceSize.height: 80
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 40 - 10 - rightCell.width - 10
spacing: 2
Row {
width: parent.width
spacing: 6
Text {
width: Math.min(implicitWidth,
parent.width - (exactBadge.visible ? exactBadge.width + 6 : 0)
- (weakBadge.visible ? weakBadge.width + 6 : 0))
text: cand.modelData.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontLink
elide: Text.ElideRight
}
// THE EXACT RELINK. Same ISRC = the same
// recording under a new catalog id, which
// is the owner's "a veces cambia el ID del
// álbum" case. Saying so is the difference
// between a certainty and a good guess, and
// the user is the one signing off.
Rectangle {
id: exactBadge
anchors.verticalCenter: parent.verticalCenter
visible: cand.modelData.exact === true
width: exactText.implicitWidth + 12
height: 16
radius: 3
color: theme.accent
Text {
id: exactText
anchors.centerIn: parent
text: QbzSession.tr("Exact match", QbzSession.trRev)
color: "#ffffff"
font.pixelSize: 9
font.weight: theme.weightMedium
}
}
// Below the matcher's own confidence floor.
// Offered anyway — a human is confirming,
// and a weak candidate is often the only
// one there is — but never dressed up as a
// match.
Rectangle {
id: weakBadge
anchors.verticalCenter: parent.verticalCenter
visible: cand.modelData.weak === true
width: weakText.implicitWidth + 12
height: 16
radius: 3
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
id: weakText
anchors.centerIn: parent
text: QbzSession.tr("Low confidence", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 9
font.weight: theme.weightMedium
}
}
}
Text {
width: parent.width
text: (cand.modelData.artist || "")
+ (root.releaseMode
? (((cand.modelData.year || "") !== "")
? " · " + cand.modelData.year : "")
: (((cand.modelData.album || "") !== "")
? " · " + cand.modelData.album : ""))
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Row {
id: rightCell
anchors.verticalCenter: parent.verticalCenter
spacing: 8
// Quality is a DECISION AID here, not chrome:
// the owner's other case is a tier being pulled
// and another added, so which tier a candidate
// carries is often the whole choice.
QualityBadge {
anchors.verticalCenter: parent.verticalCenter
tierOverride: cand.modelData.qualityTier || ""
label: cand.modelData.qualityDetail || ""
}
// The mark alone says "CD" or "Hi-Res"; the
// exact tier is the decision, so it is spelled
// out beside it. (The badge's own hover tooltip
// never shows on this row: the row's MouseArea
// takes the hover.)
Text {
anchors.verticalCenter: parent.verticalCenter
visible: (cand.modelData.qualityDetail || "") !== ""
text: cand.modelData.qualityDetail || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.releaseMode ? "" : (cand.modelData.duration || "")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
}
MouseArea {
id: candArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !root.applying
onClicked: QbzTrackReplace.select(String(cand.modelData.id))
}
}
}
}
}
// --- Footer, pinned to the card bottom ---------------------------
Item {
id: footer
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 100
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: 1
color: theme.borderSubtle
}
// State the write separately from the action row. This keeps the
// disclosure readable after the shared third button is added.
Text {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.right: parent.right
anchors.rightMargin: 24
anchors.top: parent.top
anchors.topMargin: 9
visible: root.rows.length > 0
text: root.releaseMode
? (root.targetKind === "album"
? QbzSession.tr("The selected release replaces the unavailable album favorite.",
QbzSession.trRev)
: QbzSession.tr("The matching track from the selected release replaces the unavailable favorite.",
QbzSession.trRev))
: QbzSession.tr("The replacement takes the unavailable track's place in the playlist.",
QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
Row {
id: footerRow
anchors.right: parent.right
anchors.rightMargin: 24
anchors.bottom: parent.bottom
anchors.bottomMargin: 14
spacing: 10
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Cancel", QbzSession.trRev)
btnHeight: 34
minWidth: 0
enabled: !root.applying
onClicked: QbzTrackReplace.close()
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Open album", QbzSession.trRev)
btnHeight: 34
minWidth: 0
enabled: !root.applying && !root.loading
&& root.selectedAlbumId !== ""
onClicked: QbzTrackReplace.openSelectedAlbum()
}
// The single accent mutation (ADR-008). Open album remains a
// neutral inspection action and never writes.
QbzPrimaryButton {
anchors.verticalCenter: parent.verticalCenter
btnHeight: 34
label: root.releaseMode
? (root.applying
? QbzSession.tr("Replacing favorite...", QbzSession.trRev)
: QbzSession.tr("Replace in Library", QbzSession.trRev))
: (root.applying
? QbzSession.tr("Replacing...", QbzSession.trRev)
: QbzSession.tr("Replace", QbzSession.trRev))
btnEnabled: !root.applying && !root.loading && root.selectedId !== ""
onClicked: QbzTrackReplace.apply()
}
}
}
}
}