feat(qt): playlist manager, folders, importer and the Library parity round
Ports the whole playlist-organisation domain, which had no counterpart in this frontend, plus the Library work the owner asked not to defer. Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the already-ported folders_qt repository, which until now had zero consumers. Grid, list and folder-tree modes; sort, filter and search; folder cards and chips; drag reorder and up/down arrows. Reached from the sidebar's "..." menu, whose New folder / Manage playlists / Import rows had been rendering dimmed and inert behind a comment saying the seam did not exist. Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls its LOCAL members' folder_id explicitly, because the app keeps the foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for them. QbzPlaylistEdit. One shared rename/description/delete editor replacing the inline popup in PlaylistView. Three corrections the old path had wrong: the description is carried through a rename instead of being written as NULL, a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and silently no-ops on a playlist you do not own), and the post-delete back navigation is conditional on actually being on that playlist's page, so deleting from the manager no longer navigates you off it. Importer. Spotify / Apple Music / Tidal / Deezer, over the shared qbz-playlist-import crate. Picker local mode. The reference's four-case matrix — target local or Qobuz, payload local refs or Qobuz ids. Local playlists list unconditionally and Qobuz ones only when online. Local refs onto a Qobuz playlist write the library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the 0-based enumerate the reference used before collided slots and lost rows. The carried payload can no longer be parsed as a catalog id when it is a library row id. Unblocks the local bulk add-to-playlist. Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab persistence and multi-select. Preferences land in the same <data-dir>/qbz/favorites_ui.json the shipping build writes, so switching between the two frontends keeps them. LibraryView is split into views/library/ on the way past 1800 lines. Local library. Group-by now persists; playback queues the order on screen rather than the SQL page order; switching album-identity mode invalidates the Artists tab; artist matching folds diacritics and compares normalized credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being two artists. Offline. activate_offline never persisted a last-user marker, so every per-user path resolved to None and all local reads came back empty; a session that started offline saw no local playlists at all. The sidebar's load-once latch was function-local and therefore unreachable from the logout reset, so a second login never rebuilt the tree. Both are session-scoped now, and the sidebar Refresh row runs the local-safe verb instead of being dimmed with no way to recover. Also fixes, found while verifying rather than reported: artwork derivatives were written straight to their final path, so a QML Image resolving one mid-encode opened a truncated png and Qt cached that failure against the URL for the rest of the session; Library's track group separators referenced a Component declared in another delegate's scope and could never render; the genre filter was drawn on Tracks and Albums but applied only on All; an All-feed row called a player method that does not exist; and the sidebar's drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot. 17 new msgids across all 8 locales. cc
This commit is contained in:
115 files changed
+20778
-1637
No files matched your search
@@ -0,0 +1,688 @@
|
||||
// "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 || []
|
||||
|
||||
// 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 = ""
|
||||
}
|
||||
|
||||
// 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 || ""
|
||||
}
|
||||
|
||||
// --- 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
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user