Files
qbz/crates/qbz-qt/qml/controls/PlaylistImportModal.qml
T
vicrodh 7e233104f5 feat(import): playlist files, JSON, ListenBrainz and Last.fm as import sources
Implements qbz-nix-docs/deferred-2.0.3/playlist-importer-expansion-design.md,
whose §11 the owner ratified 2026-07-31. PlaylistSource is an enum, not a trait
(ADR-007): the set is closed, there is no plugin story, and every arm resolves
to the same ImportPlaylist the URL scrapers already produce — so the matcher,
the 2000-track split, the sink and the summary never learn a second kind of
source exists.

import_prepared_playlist takes the playlist BY VALUE and
import_public_playlist becomes a shim. That kills the double scrape the Tauri
re-fetch-on-execute left behind, and it is not optional for the new sources:
the rfd bytes of a picked file are gone by execute time and the dialog cannot
silently reopen. It also closes a real race — open() resets the session, so a
modal reopened between the Import click and the task's read handed a detached
task None.

FILES read the track list ONLY. The paths inside are never opened, copied or
added to the Local Library, and the modal says so in a persistent row rather
than a tooltip. Content sniffing beats the extension, with an HLS guard because
.m3u8 is also the stream-manifest extension, and a utf8-then-Windows-1252
fallback because .m3u and .pls predate UTF-8 and "Björk" has to stay matchable.

JSON locates the track list BY SHAPE. Depth is a DoS bound, not a locator: the
array sits at 0, 1, 2 or 3 levels depending on the export, and a hard two-level
rule drops an ordinary GraphQL envelope. Container demotion keeps a discography
from importing album names as track titles. Walls at 16 MiB, depth 8, 2M nodes
and 10k tracks.

SERVICES are public reads with no authentication; a connected account only
prefills the field. No qbz-integrations edge — its ListenBrainz client returns
tracks with no duration and drops the playlist title, both verified in its
source, and a direct GET /1/playlist/<mbid> reads all three in one request.

A Last.fm STATION IS A RADIO, and ?page=N is not pagination. Measured against a
live profile: pages 1 and 2 overlapped and batch sizes varied (20, 20, 36). The
first cut fetched 20 pages, never hit its empty-page exit, and returned 469 rows
holding 198 distinct tracks. It now dedupes as it draws, stops after two dry
draws, and stops at MAX_STATION_TRACKS = 200 — the number shown in "Found N
tracks" has to be one we can deliver, and a station is dynamic enough that a
bigger single import buys little (owner ruling: no false expectations of
"traeré casi 500" when we bring half). A failed draw no longer fails an import
that already has rows; Last.fm rate-limits a burst of these.

The summary reports THREE DISJOINT NUMBERS that sum to the total. skipped is
now counted from the matches that found nothing, not inferred as
total - matched, which folded duplicates into failures: that same import told
the user 271 tracks were skipped when 255 of them had matched perfectly well
and been deduplicated before the add.

23 new strings across the seven translated locales; en is the sparse fallback
catalogue and needs none.

cc
2026-08-19 23:55:56 -06:00

933 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()
}
}
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 }
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 {
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.surfaceHover : 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 {
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 {
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
}
}
}