Files
qbz/crates/qbz-qt/qml/controls/PlaylistImportModal.qml
T
vicrodh ed7fa588ea fix(qt): elevated controls lift on hover instead of darkening
Buttons that rest on surfaceElevated used surfaceHover for their hover
fill: a 6% white wash meant for rows that rest transparent. Over an
elevated button it lands DARKER than the rest fill — on a modal's dark
ground the Cancel/Save/footer buttons went near-black under the pointer.

QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived
so every palette has it), its ambient half-alpha twin, and the
elevatedFill / elevatedHoverFill pair an ambient-aware control paints.
Every site with the old pairing (65 across modals, toolbars, selects,
circle actions, chips and cards) now hovers to the lift.

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

945 lines
45 KiB
QML

// "Import Playlist" — QML port of
// crates/qbz-ui/ui/primitives/PlaylistImportModal.slint (itself a 1:1 port of
// Tauri's PlaylistImportModal.svelte).
//
// Two-step flow: URL entry -> provider auto-detect (RUST-side, one round trip
// per keystroke, exactly as the reference does) -> fetch preview -> rename +
// optional folder -> import with a live progress bar, a status line and an
// append-only log, then an in-panel Summary block. Every interpolated string
// arrives PRE-FORMATTED in the document (playlist_import_qt.rs); this file
// renders them verbatim and never pluralises or interpolates a count itself.
//
// Mounted ONCE in AppShell next to PlaylistPickerModal, NOT inside the sidebar
// (05 §5.8.5): the two surfaces that open it — the sidebar `...` menu and the
// closed-sidebar flyout — have no lifetime guarantee across a navigation, and
// closing the modal must never cancel an in-flight import.
//
// CLOSE SEMANTICS (reference §1.8): the footer Close disables while loading
// (Tauri-verbatim); the header X and the scrim stay clickable mid-import —
// closing only hides the UI, the tokio task runs to completion and still
// toasts, assigns the folder and refreshes the sidebar.
//
// OFFLINE is gated in TWO layers (05 §5.8.7), both required: the sidebar row is
// inert while `QbzSession.offline`, AND this modal carries its own banner and a
// `canFetch` that Rust computes as `provider detected && !offline`. The URL
// field stays enabled — only the fetch is refused.
//
// PROVIDER MARKS. Spotify / Apple Music / Deezer are self-coloured marks and
// render UNTINTED out of qml/assets/brand/, the rule SourceIcon.qml calls
// load-bearing. Tidal's upstream asset is a black `fill` with no colour to
// lose, so it goes through QbzIcon at `textPrimary` instead: the reference
// hardcodes it to white (PlaylistImportModal.slint:271-279) which is
// illegible on the 11 light themes, and a monochrome glyph is exactly what the
// tint table is for. The header's Qobuz mark is the port's existing
// `qobuz-logo-filled.svg` brand asset for the same reason — the reference tints
// its outline logo white, which disappears on a light `surface-card`.
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(QbzPlaylistImport.importJson)
readonly property var logRows: doc.log || []
readonly property bool loading: doc.loading === true
readonly property bool showPreview: doc.showPreview === true
readonly property bool importCompleted: doc.importCompleted === true
readonly property var folderOptions: doc.folderOptions || []
// --- Source picker (2.0.3 expansion) ----------------------------------
// 0 URL · 1 Playlist file · 2 JSON · 3 ListenBrainz · 4 Last.fm. The order
// is `playlist_import_qt::source_kind`; the labels come from the document
// pre-localized, like every other string here.
readonly property int sourceIndex: doc.sourceIndex || 0
readonly property bool srcUrl: root.sourceIndex === 0
readonly property bool srcFile: root.sourceIndex === 1
readonly property bool srcJson: root.sourceIndex === 2
readonly property bool srcLb: root.sourceIndex === 3
readonly property bool srcLastfm: root.sourceIndex === 4
/// File and JSON share their whole block except the caveat line.
readonly property bool srcAnyFile: root.srcFile || root.srcJson
readonly property bool srcAnyService: root.srcLb || root.srcLastfm
// Bumped by every QbzPlaylistImport.open(). The reference remounts the
// whole Svelte component per open and Slint reproduces it by clearing 14
// properties; here Rust resets its document and this counter tells the two
// QML-local text mirrors to drop what the user last typed (05 §5.8.3).
readonly property int resetSeq: doc.resetSeq || 0
onResetSeqChanged: {
urlInput.text = ""
nameInput.text = ""
serviceInput.text = ""
}
// Absolute qrc prefix for the brand SVGs — same rule as QbzIcon: a relative
// URL resolves against the CONSUMER's document depth.
readonly property string brandDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/brand/"
QbzTheme { id: theme }
// Scrim — click outside dismisses, EVEN MID-IMPORT (§1.8: closing only
// hides the UI, the task keeps running).
Rectangle {
anchors.fill: parent
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: QbzPlaylistImport.close()
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
id: card
anchors.centerIn: parent
// Tauri: 560px max, min-height 420px, max-height 90vh.
width: Math.min(parent.width - 80, 560)
// 140 = header 68 + 2 hairlines + footer 70 (16/34/20); the body
// scrolls inside the Flickable past the 90 % clamp.
height: Math.min(Math.max(420, bodyCol.implicitHeight + 140), parent.height * 0.9)
radius: theme.radiusLg
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
// Swallow clicks so they never reach the scrim.
MouseArea {
anchors.fill: parent
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
readonly property int contentWidth: card.width - 48
// ---------------------------- header ----------------------------
Item {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 68
Image {
id: qobuzMark
anchors.left: parent.left
anchors.leftMargin: 24
anchors.verticalCenter: parent.verticalCenter
width: 26
height: 26
source: root.brandDir + "qobuz-logo-filled.svg"
fillMode: Image.PreserveAspectFit
sourceSize.width: 52
sourceSize.height: 52
smooth: true
opacity: 0.9
}
Text {
anchors.left: qobuzMark.right
anchors.leftMargin: 10
anchors.right: closeX.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Import Playlist", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
// X close — allowed mid-import (§1.8).
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: closeXArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: closeXArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlaylistImport.close()
}
}
}
Rectangle {
id: headerDiv
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 1
color: theme.borderSubtle
}
// ---------------------------- footer ----------------------------
// Declared before the body so the body can anchor to the divider; the
// footer is pinned to the card bottom either way.
Item {
id: footerBar
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 70
Row {
anchors.right: parent.right
anchors.rightMargin: 24
anchors.bottom: parent.bottom
anchors.bottomMargin: 20
spacing: 12
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Close", QbzSession.trRev)
btnHeight: 34
minWidth: 0
// Tauri-verbatim: the FOOTER close disables while loading;
// the header X and the scrim do not.
enabled: !root.loading
onClicked: QbzPlaylistImport.close()
}
// The single accent confirm (ADR-008). Step A fetches, step B
// imports; it stays visible-but-disabled after completion.
QbzPrimaryButton {
anchors.verticalCenter: parent.verticalCenter
btnHeight: 34
label: root.showPreview
? (root.loading ? QbzSession.tr("Importing...", QbzSession.trRev)
: QbzSession.tr("Import", QbzSession.trRev))
: (root.loading ? QbzSession.tr("Fetching...", QbzSession.trRev)
: QbzSession.tr("Fetch playlist", QbzSession.trRev))
btnEnabled: root.showPreview
? (!root.loading && !root.importCompleted)
: (root.doc.canFetch === true && !root.loading)
onClicked: {
if (root.showPreview)
QbzPlaylistImport.execute()
else
QbzPlaylistImport.fetch()
}
}
}
}
Rectangle {
id: footerDiv
anchors.bottom: footerBar.top
anchors.left: parent.left
anchors.right: parent.right
height: 1
color: theme.borderSubtle
}
// ----------------------------- body -----------------------------
// Scrolls when the progress panel outgrows the card.
Flickable {
id: bodyFlick
anchors.top: headerDiv.bottom
anchors.bottom: footerDiv.top
anchors.left: parent.left
anchors.right: parent.right
clip: true
contentWidth: width
contentHeight: bodyCol.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: bodyCol
width: card.width
padding: 24
spacing: 16
// --- Offline banner ---------------------------------------
// The fetch is gated (Rust drives `canFetch` false); the URL
// input stays enabled, exactly as the reference does.
//
// The shared control, with the deltas BlacklistManagerView.qml
// already records for it: the copy goes in `title` because that
// is the TONE-coloured line, the glyph is `info` rather than
// `cloud-off`, and the tone alphas are 10 %/30 % against the
// reference's #eab3081a / #eab3084d (identical arithmetic, the
// theme's warning token instead of a literal).
WarningBanner {
width: card.contentWidth
visible: QbzSession.offline
variant: "warning"
title: QbzSession.tr("This feature requires internet connection", QbzSession.trRev)
}
// --- Error banner (preview or execute failure) -------------
WarningBanner {
width: card.contentWidth
visible: (root.doc.error || "") !== ""
variant: "error"
title: root.doc.error || ""
}
// --- Source picker ----------------------------------------
// The one control that is always visible. Everything below it
// is one source's block; they are mutually exclusive and a
// Column skips invisible children, so exactly one appears and
// nothing leaves a gap.
Column {
width: card.contentWidth
spacing: 8
Text {
text: QbzSession.tr("Source", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
QbzSelect {
width: parent.width
popupWidth: parent.width
enabled: !root.loading
options: root.doc.sourceOptions || []
currentIndex: root.sourceIndex
onSelected: function (i) { QbzPlaylistImport.sourceChanged(i) }
}
}
// --- URL input --------------------------------------------
// Every keystroke routes the provider detection through Rust
// (the reference does the same because Slint 1.16 strings have
// no `.contains`; here it is what keeps ONE detector — the
// crate's `detect_provider_key` — as the source of truth
// instead of a second JS copy that would drift).
Column {
width: card.contentWidth
spacing: 8
visible: root.srcUrl
Text {
text: QbzSession.tr("Playlist URL", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
// The standard 43px input box (the PlaylistPickerModal
// recipe — QbzLineEdit is 34px, commits only on Enter/blur
// in its plain arm and has no `enabled` gate, so it cannot
// serve a per-keystroke field that must disable mid-fetch).
Rectangle {
width: parent.width
height: 43
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: urlInput.activeFocus ? theme.accent : theme.borderSubtle
opacity: root.loading ? 0.5 : 1.0
TextInput {
QbzTextEditMenu { }
id: urlInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
enabled: !root.loading
onTextEdited: QbzPlaylistImport.urlEdited(text)
onAccepted: {
if (!root.showPreview && root.doc.canFetch === true && !root.loading)
QbzPlaylistImport.fetch()
}
// Re-seed from the document while the field is NOT
// being edited (the PlaylistPickerModal shape): user
// typing breaks a plain `text:` binding for good, so
// a reset would never reach the field.
Binding {
target: urlInput
property: "text"
value: root.doc.url || ""
when: !urlInput.activeFocus
}
}
Text {
// Placeholder. Plain string: not localized in the
// reference either (it is a URL shape, not copy).
visible: urlInput.text === ""
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
verticalAlignment: Text.AlignVCenter
text: "https://open.spotify.com/playlist/..."
color: theme.textMuted
font.pixelSize: theme.fontLink
elide: Text.ElideRight
}
}
}
// --- Allowed sources --------------------------------------
// The detected provider's mark at full opacity, the rest dimmed
// (the reference's §1.5 homologation: no glow / translate /
// scale, opacity 0.45 idle / 1.0 active).
Column {
width: card.contentWidth
spacing: 8
// URL ONLY. Four dimmed streaming logos over a file picker
// would say the file has to come from one of them.
visible: root.srcUrl
Text {
text: QbzSession.tr("ALLOWED SOURCES", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Row {
spacing: 10
Image {
width: 70
height: 24
source: root.brandDir + "spotify-logo.svg"
fillMode: Image.PreserveAspectFit
sourceSize.width: 140
sourceSize.height: 48
smooth: true
opacity: root.doc.activeProvider === "spotify" ? 1.0 : 0.45
}
Image {
width: 70
height: 24
source: root.brandDir + "apple-music-logo.svg"
fillMode: Image.PreserveAspectFit
sourceSize.width: 140
sourceSize.height: 48
smooth: true
opacity: root.doc.activeProvider === "apple" ? 1.0 : 0.45
}
// Monochrome mark -> the tint table, so it reads on the
// dark AND the light themes (see the file header).
QbzIcon {
width: 70
height: 24
name: "tidal-tidal"
tintName: "textPrimary"
opacity: root.doc.activeProvider === "tidal" ? 1.0 : 0.45
}
Image {
width: 70
height: 24
source: root.brandDir + "deezer-logo.svg"
fillMode: Image.PreserveAspectFit
sourceSize.width: 140
sourceSize.height: 48
smooth: true
opacity: root.doc.activeProvider === "deezer" ? 1.0 : 0.45
}
}
}
// --- File / JSON block ------------------------------------
// ONLY THE TRACK LIST IS READ. The disclaimer is not fine
// print: a user handing over an .m3u reasonably expects the
// referenced audio to be imported, and it never is. It is a
// persistent row next to the picker, not a tooltip.
Column {
width: card.contentWidth
spacing: 8
visible: root.srcAnyFile
Row {
spacing: 12
// Ghost button, the port's neutral secondary shape.
Rectangle {
width: pickLabel.implicitWidth + 28
height: 36
radius: theme.radiusSm
color: (pickArea.containsMouse && !root.loading)
? theme.surfaceElevatedHover : theme.surfaceElevated
opacity: root.loading ? 0.5 : 1.0
Text {
id: pickLabel
anchors.centerIn: parent
text: QbzSession.tr("Choose file…", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontLink
}
MouseArea {
id: pickArea
anchors.fill: parent
hoverEnabled: true
enabled: !root.loading
cursorShape: root.loading ? Qt.ArrowCursor
: Qt.PointingHandCursor
onClicked: QbzPlaylistImport.pickFile()
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, card.contentWidth - pickLabel.implicitWidth - 40)
text: (root.doc.pickedFileName || "") !== ""
? root.doc.pickedFileName
: QbzSession.tr("No file selected", QbzSession.trRev)
color: (root.doc.pickedFileName || "") !== ""
? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontLink
elide: Text.ElideMiddle
}
}
Text {
width: parent.width
wrapMode: Text.WordWrap
text: QbzSession.tr("Only the track list is read — the referenced audio files are never opened, copied, or added to your Local Library. Each entry is matched against the Qobuz catalog.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
// JSON carries one extra caveat: the parse is best-effort
// and the preview COUNT is the user's gate on it.
Text {
visible: root.srcJson
width: parent.width
wrapMode: Text.WordWrap
text: QbzSession.tr("JSON is read best-effort — check the track count below before importing.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// --- ListenBrainz / Last.fm block -------------------------
// One handle field for both, because they take the same thing:
// a public username or a URL. Nothing is authenticated; a
// connected account only PREFILLS the field.
Column {
width: card.contentWidth
spacing: 8
visible: root.srcAnyService
Text {
text: root.srcLb
? QbzSession.tr("Username or playlist URL", QbzSession.trRev)
: QbzSession.tr("Username or profile URL", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
// The same 43px box recipe as the URL field, for the same
// reason: QbzLineEdit commits on Enter/blur and this must
// recompute per keystroke.
Rectangle {
width: parent.width
height: 43
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: serviceInput.activeFocus ? theme.accent : theme.borderSubtle
opacity: root.loading ? 0.5 : 1.0
TextInput {
QbzTextEditMenu { }
id: serviceInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
enabled: !root.loading
onTextEdited: QbzPlaylistImport.serviceInputEdited(text)
onAccepted: {
if (!root.showPreview && root.doc.canFetch === true && !root.loading)
QbzPlaylistImport.fetch()
}
Binding {
target: serviceInput
property: "text"
value: root.doc.serviceUser || ""
when: !serviceInput.activeFocus
}
}
Text {
visible: serviceInput.text === ""
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("Enter a public username.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLink
elide: Text.ElideRight
}
}
// Last.fm PROFILE -> pick one of three stations. A
// PLAYLIST url (lastfmMode 1) skips this entirely and
// imports what the URL names.
Column {
width: parent.width
spacing: 8
visible: root.srcLastfm && (root.doc.lastfmMode || 0) === 0
Text {
text: QbzSession.tr("Station", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
QbzSelect {
width: parent.width
popupWidth: parent.width
enabled: !root.loading
options: root.doc.stationOptions || []
currentIndex: root.doc.stationIndex || 0
onSelected: function (i) { QbzPlaylistImport.setStationIndex(i) }
}
}
// ListenBrainz USERNAME -> its "created for you" list. A
// pasted /playlist/<mbid> resolves on its own, so the
// picker only appears once there is something to pick.
Column {
width: parent.width
spacing: 8
visible: root.srcLb && (root.doc.lbPlaylistOptions || []).length > 0
Text {
text: QbzSession.tr("Playlist", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
QbzSelect {
width: parent.width
popupWidth: parent.width
enabled: !root.loading
options: root.doc.lbPlaylistOptions || []
currentIndex: root.doc.lbPlaylistIndex || 0
onSelected: function (i) { QbzPlaylistImport.setLbPlaylistIndex(i) }
}
}
Text {
visible: root.srcLb && root.doc.lbListLoading === true
text: QbzSession.tr("Loading...", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// --- Customization panel (step B) -------------------------
// Rename + optional folder. Editing the URL reverts to step A
// (`showPreview` recomputes Rust-side on every keystroke).
Rectangle {
width: card.contentWidth
visible: root.showPreview
height: customCol.implicitHeight
radius: theme.radiusMd
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Column {
id: customCol
width: parent.width
padding: 16
spacing: 12
Column {
width: parent.width - 32
spacing: 8
Text {
text: QbzSession.tr("Playlist name", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
Rectangle {
width: parent.width
height: 43
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle
opacity: nameInput.enabled ? 1.0 : 0.5
TextInput {
QbzTextEditMenu { }
id: nameInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
enabled: !root.loading && !root.importCompleted
onTextEdited: QbzPlaylistImport.nameEdited(text)
onAccepted: {
// Enter here executes (step B).
if (!root.loading && !root.importCompleted)
QbzPlaylistImport.execute()
}
// Rust prefills this with the fetched
// playlist name, so the re-seed is not just
// a reset path here — it is how the name
// arrives at all.
Binding {
target: nameInput
property: "text"
value: root.doc.customName || ""
when: !nameInput.activeFocus
}
}
}
}
// Folder dropdown — only when the user HAS folders
// (index 0 is always "No folder", so length > 1).
Column {
width: parent.width - 32
spacing: 8
visible: root.folderOptions.length > 1
Text {
text: QbzSession.tr("Folder", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
QbzSelect {
options: root.folderOptions
currentIndex: root.doc.folderIndex || 0
menuWidth: parent.width
// The reference caps its menu at 380px; here the
// control is full-width, so the popup matches it.
popupWidth: parent.width
searchable: root.folderOptions.length > 8
onSelected: function (i) { QbzPlaylistImport.setFolderIndex(i) }
}
}
}
}
// --- Progress panel ---------------------------------------
// Spinner header, determinate bar, status + current-track
// lines, the append-only log, and the Summary block.
Rectangle {
width: card.contentWidth
visible: root.doc.progressVisible === true
height: progressCol.implicitHeight
radius: theme.radiusMd
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Column {
id: progressCol
width: parent.width
padding: 16
spacing: 10
Item {
width: parent.width - 32
height: 20
Text {
anchors.left: parent.left
anchors.right: spinnerSlot.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Conversion progress", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
elide: Text.ElideRight
}
// Tauri: a 14px accent ring while loading.
Item {
id: spinnerSlot
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 14
height: 14
QbzSpinner {
anchors.centerIn: parent
size: 14
visible: root.loading
spinning: root.loading
}
}
}
Column {
width: parent.width - 32
spacing: 6
visible: root.doc.hasProgress === true
// Determinate 6px bar (the LocalLibrarySettings
// recipe); the track uses the subtle border tone
// because the panel is already surface-elevated.
Rectangle {
width: parent.width
height: 6
radius: 3
color: theme.borderSubtle
clip: true
Rectangle {
width: parent.width * Math.max(0, Math.min(1, root.doc.progress || 0))
height: parent.height
radius: 3
color: theme.accent
}
}
// Status line — the 12px Tauri metric, fixed height
// (the CJK floor femtovg needed; harmless here and
// it keeps the panel from reflowing per event).
Text {
width: parent.width
height: 18
text: root.doc.statusLine || ""
color: theme.textSecondary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Current-track line — "Artist - Title" while
// matching, "Part i/n" while adding.
Text {
width: parent.width
visible: (root.doc.currentTrack || "") !== ""
height: visible ? 16 : 0
text: root.doc.currentTrack || ""
color: theme.textMuted
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
// Append-only log. The height is static arithmetic over
// the ROW COUNT, never row geometry (the reference calls
// that out as a recursion footgun). No autoscroll —
// Tauri parity. Render-only rows: no MouseAreas here.
Flickable {
width: parent.width - 32
visible: root.logRows.length > 0
height: visible ? Math.min(root.logRows.length * 20, 160) : 0
contentWidth: width
contentHeight: root.logRows.length * 20
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
width: parent.width
Repeater {
model: root.logRows
delegate: Text {
required property var modelData
width: parent ? parent.width : 0
height: 20
text: modelData.message || ""
// Hex literals kept — the reference
// hardcodes them (Tauri did too), and
// the port has no success/danger glyph
// ramp for a 13px log row.
color: modelData.status === "success" ? "#34d399"
: (modelData.status === "error" ? "#f87171" : theme.textMuted)
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
// Summary block — pre-formatted lines, hairline
// separated, "" = hidden.
Column {
width: parent.width - 32
spacing: 6
visible: (root.doc.summaryPlaylist || "") !== ""
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Text {
text: QbzSession.tr("Summary", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
}
Text {
width: parent.width
height: 18
text: root.doc.summaryPlaylist || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
width: parent.width
height: 18
text: root.doc.summaryMatched || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
width: parent.width
height: 18
text: root.doc.summarySkipped || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Only when the SOURCE repeated a track. A
// duplicate is not a skip, and folding it into one
// is what made a 453-of-469 match read as 198.
Text {
width: parent.width
visible: (root.doc.summaryDuplicates || "") !== ""
height: visible ? 18 : 0
text: root.doc.summaryDuplicates || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
width: parent.width
visible: (root.doc.summaryParts || "") !== ""
height: visible ? 18 : 0
text: root.doc.summaryParts || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
}
}
// The 14px gutter scrollbar, inset 4px from the right — the port's
// shared control, attached as a SIBLING of the Flickable (it is an
// Item with a `target`, not a QtQuick.Controls ScrollBar).
QbzScrollBar {
target: bodyFlick
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: bodyFlick.top
anchors.bottom: bodyFlick.bottom
}
}
}