Files
qbz/crates/qbz-qt/qml/views/myqbz/DiscoCandidateRow.qml
T
vicrodh 3cfce8a769 feat(qt): the media-server settings panel, driven and verified in the window
ONE component instantiated twice, because it is one form twice. Every
difference between the protocols is a property set at the call site, so
they are visible in one place instead of drifting across two files:
"Test" proves the address for Jellyfin and only "a Subsonic server
answered" for Subsonic (which has no credential-free endpoint), the
password is discarded for Jellyfin and kept for Subsonic (no session,
md5(password+salt) per request), and the sync cost is stated up front —
45.8 s per 4924 tracks against 0.81 s per 6678 — so a long sweep does not
read as a hang. The credential never leaves Rust; the panel gets
`hasCredential`.

Assets: the colour marks for cards, the monochrome ones for rows. Both
mono files arrived as BLACK ink and are invisible on a dark theme
untinted, so they were normalised to the #ffffff master ink and joined
`icon_tint_qt::MASTERS` — the port's only shader-free way to recolour an
SVG per theme. The Jellyfin colour asset had a white rounded-rect plate
behind the mark; it was stripped, because every other brand mark here is
transparent and a white tile beside them reads as a rendering bug.
Rendered with Qt itself and looked at before any of it was wired.

CAVEAT, recorded in SourceIcon: the Subsonic mark is NAVIDROME's logo,
and `subsonic` covers Gonic, Airsonic, Astiga and Ampache too. `ping`
reports the server's own type and it is already persisted, so branching
later is cheap — a known wrongness, not an unnoticed one.

THREE BUGS THAT ONLY THE RUNNING WINDOW COULD SHOW, all found by
driving it under Xvfb rather than by reading the log:

1. `MediaServerSettings is not a type` — a .qml missing from build.rs is
   not in the QML module. It resolves fine on disk, compiles nothing,
   and blanks the WHOLE settings page at runtime. All three existing
   audits passed. `qml_module_registration_audit.py` is the missing
   check and is now in the gate.
2. The toggle would not move. The panel reads its state from the
   settings DOCUMENT, unlike Plex's toggle which reads a bridge
   property, so every mutation has to republish it — connect, enable,
   disconnect and the end of a sweep now do.
3. Every Jellyfin album in the grid drew the LOCAL HARD-DRIVE badge:
   `local_rows::badge_source` had never been taught the two words and
   folded them into `_ => local`. Not cosmetic — that string is what the
   source chips filter on.

`play_single_from_source` also grew the arm it needed: a media-server row
from the recently-played rail has no library.db row to mint a LocalTrack
from, so it resolves through `registry.tracks_of` instead.

END TO END, from the UI, against the real server: toggle on -> type the
address -> Connect -> "jellyfin sync: 500/4924 ... 4924 saved, 0 pruned,
4924 cached" -> Local Library shows 461 albums with their covers and the
Jellyfin badge. The cached quality distribution is identical to what the
server reported in the original protocol sweep.

428/428.

cc
2026-08-20 10:57:28 -06:00

460 lines
19 KiB
QML

// One candidate row of the Discography Builder table — QML port of
// myqbz/DiscographyBuilderView.slint:248-416 (`CandidateRow`), carrying with
// it the three helpers Slint declares in the same file: `TypeOverride` +
// its popover (:62-240), `TypeColor` (:38-46) and `SourceIndicator` (:50-59).
//
// Slint keeps all four inside the view file; here the row is its own file so
// DiscoBuilderView.qml holds only the page frame (spec 01 §8.5), and the
// type-override menu stays with the button it belongs to — one call site.
//
// 7-COLUMN GRID, the alignment backbone (Slint :242-244):
// 28 / 56 / 72 / 1fr / 72 / 60 / 156, padding-l/r 8, spacing 10.
// The header row in DiscoBuilderView.qml inlines the SAME template — they are
// a MATCHED PAIR (the AlbumListHeader / AlbumListRow convention). Change both.
//
// `alt` (an alternate release inside a group) dims the row, swaps the col[0]
// checkbox for a connector + checkbox, and shrinks every text by 1px.
//
// HOVER is ORed across the MouseAreas this row owns, because a child
// MouseArea with `hoverEnabled` steals hover from the row-wide one — the same
// reason rows/TrackRow.qml:213 ORs its four areas. SelectCheck does not expose
// its internal area, so hovering the 13px checkbox itself does not lift the
// row; TrackRow lives with the identical limitation on its artist/album links.
//
// TYPE MENU: Slint hand-rolls a Rectangle plus a 4000x4000 backdrop TouchArea
// and a global `open-type-menu-key` because it has no popup that closes on
// outside-click (:114-127). Qt has one, so `QbzContextMenu` owns the open
// state and that global does NOT exist on the bridge (spec 01 §8.6). The
// popup is a delegate child and dies when this row is recycled out of the
// ListView — accepted (spec 01 §3.3.6): Slint's menu is a row child too.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
import "../local"
Rectangle {
id: root
/// One `DiscoCandidate` (spec 02 §5.8): key, source, sourceItemId, title,
/// year, tracks, releaseType, isOverridden, qualityTier, qualityDetail,
/// checked. (`artUrl`/`artPath` are unused — the table has no art cell.)
property var candidate: ({})
/// The OWNING group's key — `toggleChecked` is keyed by (group, candidate).
property string groupKey: ""
/// Alternate row (rendered under its group's primary).
property bool alt: false
/// Whether the OWNING group is a compilation. Slint :253-255: the
/// "compilation" tag is driven by the GROUP flag, not by the candidate's
/// effective release type.
property bool groupIsCompilation: false
/// Resting opacity, lifted to 1.0 on hover (:263). The parent passes it:
/// primary = `groupIsCompilation ? 0.65 : 1.0`, alternate = 0.72
/// unconditionally (:713,720 — an alternate inside a compilation group is
/// still 0.72).
property real restOpacity: 1.0
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// --- Column geometry (the matched pair — see the header note) ----------
readonly property int padH: 8
readonly property int colGap: 10
readonly property int colCheck: 28
readonly property int colYear: 56
readonly property int colType: 72
readonly property int colTracks: 72
readonly property int colSource: 60
readonly property int colQuality: 156
// 28 + 56 + 72 + 72 + 60 + 156 = 444 fixed, 6 gaps of 10 = 60.
readonly property real titleWidth: Math.max(
0, root.width - 2 * root.padH - 444 - 6 * root.colGap)
readonly property string releaseType: root.candidate.releaseType || ""
readonly property bool isOverridden: root.candidate.isOverridden === true
readonly property bool hovered: rowArea.containsMouse || titleArea.containsMouse
|| typeArea.containsMouse
/// Per-release-type tint (Slint `DiscoTint`, :30-35 / `TypeColor` :38-46).
/// The ONE legitimately-hardcoded palette in this view — spec 01 §2 keeps
/// these four as literals ("the spec intends fixed non-token colours");
/// plain "album" falls through to the muted token.
function typeColor(kind) {
return kind === "compilation" ? "#f59e0b"
: kind === "live" ? "#e91e63"
: kind === "ep" ? "#60a5fa"
: kind === "single" ? "#a78bfa"
: theme.textMuted
}
/// Menu rows in Slint's order (:142-147). Built by a function that TAKES
/// `rev` so the array rebuilds on a live language switch.
function typeChoices(rev) {
return [
{ "id": "album", "label": QbzSession.tr("Album", rev) },
{ "id": "ep", "label": QbzSession.tr("EP", rev) },
{ "id": "single", "label": QbzSession.tr("Single", rev) },
{ "id": "live", "label": QbzSession.tr("Live", rev) },
{ "id": "compilation", "label": QbzSession.tr("Compilation", rev) }
]
}
height: 44
radius: 6
color: root.hovered ? theme.surfaceHover : "transparent"
opacity: root.hovered ? 1.0 : root.restOpacity
// The row's own area does NOTHING but drive the hover lift (:265 —
// `mouse-cursor: default`, no handler). Declared BEFORE the cells so
// every interactive child sits above it.
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.ArrowCursor
}
// Bottom hairline, so adjacent rows (and their quality badges) read as
// separated instead of stuck together (:269-273).
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
Row {
anchors.fill: parent
anchors.leftMargin: root.padH
anchors.rightMargin: root.padH
spacing: root.colGap
// [0] 28px — checkbox (alternate: connector + checkbox).
Item {
width: root.colCheck
height: parent.height
Row {
anchors.left: parent.left
height: parent.height
spacing: 3
Text {
visible: root.alt
anchors.verticalCenter: parent.verticalCenter
// Untranslated in Slint too (:286) — a glyph, not copy.
text: "↳"
color: theme.textMuted
font.pixelSize: 12
}
SelectCheck {
anchors.verticalCenter: parent.verticalCenter
on: root.candidate.checked === true
onToggled: QbzDisco.toggleChecked(root.groupKey,
root.candidate.key || "")
}
}
}
// [1] 56px — year.
Text {
width: root.colYear
height: parent.height
text: root.candidate.year || ""
color: root.alt ? theme.textMuted : theme.textSecondary
font.pixelSize: root.alt ? 12 : 13
horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// [2] 72px — release-type override button + popover.
Item {
width: root.colType
height: parent.height
Rectangle {
id: typeBtn
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
// Slint sizes the wrapper to the button's preferred size
// (:71-72) and the button is a 4/2-padded label (:79-83).
implicitWidth: typeLabel.implicitWidth + 8
implicitHeight: typeLabel.implicitHeight + 4
width: implicitWidth
height: implicitHeight
radius: 4
color: typeArea.containsMouse ? theme.surfaceHover : "transparent"
Text {
id: typeLabel
x: 4
y: 2
text: root.releaseType
color: root.typeColor(root.releaseType)
font.pixelSize: root.alt ? 10 : 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.4
}
// Active-override marker. Slint has no text-decoration and
// draws a 1px rule under the label (:96-103); QML's
// font.underline exists but the rule IS the reference shape.
Rectangle {
visible: root.isOverridden
x: typeLabel.x
y: parent.height - 3
width: typeLabel.width
height: 1
color: root.typeColor(root.releaseType)
opacity: 0.6
}
MouseArea {
id: typeArea
anchors.fill: parent
anchors.margins: -4 // the .type-btn `margin: 0 -4px` bleed
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// CloseOnPressOutside fires BEFORE this handler, so
// without the guard a click on an open menu's trigger
// closes and immediately reopens it (spec 01 §3.3.5).
if (typeMenu.opened) {
typeMenu.close()
return
}
typeMenu.openBelowLeft(typeBtn)
}
}
}
// Slint: `x: 0; y: parent.height + 4`, 160px (:128-131) — LEFT
// aligned, hence openBelowLeft (openBelowRight would push a 160px
// panel ~120px left of this ~40px button).
QbzContextMenu {
id: typeMenu
menuWidth: 160
// The component hardcodes padding 5 / surfaceMain / r8 /
// borderMuted; this panel is 4 / surfaceCard / r6 /
// surfaceElevated (:132-135), so both are overridden here
// (spec 01 §3.3.1-2).
padding: 4
background: Rectangle {
color: theme.surfaceCard
radius: 6
border.width: 1
border.color: theme.surfaceElevated
}
Repeater {
model: root.typeChoices(QbzSession.trRev)
delegate: Rectangle {
id: choiceRow
required property var modelData
readonly property bool current:
root.releaseType === choiceRow.modelData.id
width: parent ? parent.width : 0
height: 28
radius: 4
color: choiceArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
// Always a 12x12 leading slot so the labels stay
// aligned (:157-171).
Item {
width: 12
height: parent.height
QbzIcon {
visible: choiceRow.current
anchors.centerIn: parent
name: "check"
width: 12
height: 12
tintName: "accent"
}
}
Text {
width: Math.max(0, parent.width - 20)
height: parent.height
text: choiceRow.modelData.label
color: choiceRow.current ? theme.accent : theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: choiceArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
typeMenu.close()
QbzDisco.setTypeOverride(
root.candidate.source || "",
root.candidate.sourceItemId || "",
choiceRow.modelData.id)
}
}
}
}
// Reset rows — only while an override is active (:193-236):
// a 9px band carrying a 1px rule at y=4, then a 28px row.
Item {
visible: root.isOverridden
width: parent ? parent.width : 0
height: visible ? 9 : 0
Rectangle {
y: 4
width: parent.width
height: 1
color: theme.surfaceElevated
}
}
Rectangle {
visible: root.isOverridden
width: parent ? parent.width : 0
height: visible ? 28 : 0
radius: 4
color: resetArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
Item {
width: 12
height: parent.height
QbzIcon {
anchors.centerIn: parent
name: "rotate-ccw"
width: 12
height: 12
tintName: "muted"
}
}
Text {
width: Math.max(0, parent.width - 20)
height: parent.height
text: root.t("Use auto-detected")
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: resetArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
typeMenu.close()
QbzDisco.resetTypeOverride(
root.candidate.source || "",
root.candidate.sourceItemId || "")
}
}
}
}
}
// [3] 1fr — title (flexes) + the italic tag.
Item {
width: root.titleWidth
height: parent.height
Row {
anchors.fill: parent
spacing: 8
Item {
width: Math.max(0, parent.width
- (rowTag.visible ? rowTag.implicitWidth + 8 : 0))
height: parent.height
Text {
anchors.fill: parent
text: root.candidate.title || ""
color: titleArea.containsMouse
? theme.accent
: (root.alt ? theme.textMuted : theme.textPrimary)
font.pixelSize: root.alt ? 13 : 14
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: titleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzDisco.openAlbum(root.candidate.source || "",
root.candidate.sourceItemId || "")
}
}
// Alternates always show "alternate"; a primary shows
// "compilation" only when its GROUP is one (:358-375).
Text {
id: rowTag
visible: root.alt || root.groupIsCompilation
anchors.verticalCenter: parent.verticalCenter
text: root.alt ? root.t("alternate") : root.t("compilation")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightMedium
font.italic: true
font.letterSpacing: 0.8
}
}
}
// [4] 72px — tracks (centred).
Text {
width: root.colTracks
height: parent.height
text: root.candidate.tracks || ""
color: root.alt ? theme.textMuted : theme.textSecondary
font.pixelSize: root.alt ? 12 : 13
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// [5] 60px — source glyph, centred in a 28x28 cell (`SourceIndicator`).
Item {
width: root.colSource
height: parent.height
Item {
anchors.centerIn: parent
width: 28
height: 28
SourceIcon {
anchors.centerIn: parent
kind: root.candidate.source || ""
// A dense ROW: the media marks draw monochrome and tinted, like the
// hard-drive beside them. Colour logos are for cards — a list of
// them fights the text it labels.
mono: true
// Row glyph — SourceGlyph.slint:31: 15px, and the three
// Qobuz kinds one pixel larger (the wordmark's
// proportions). Explicit since the size rule became a
// per-arm knob; it used to be a hardcoded +2.
glyphSize: 15
qobuzSize: 16
localTint: "muted"
}
}
}
// [6] 156px — quality badge (implicitWidth floors at 136, so it fits).
Item {
width: root.colQuality
height: parent.height
QualityBadgeFull {
anchors.centerIn: parent
tier: root.candidate.qualityTier || ""
detail: root.candidate.qualityDetail || ""
}
}
}
}