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
945 lines
45 KiB
QML
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
|
|
}
|
|
}
|
|
}
|