diff --git a/crates/Cargo.lock b/crates/Cargo.lock
index 74e792e98..ed18a94a1 100644
--- a/crates/Cargo.lock
+++ b/crates/Cargo.lock
@@ -6626,6 +6626,7 @@ dependencies = [
"qbz-core",
"qbz-credentials",
"qbz-i18n",
+ "qbz-integrations",
"qbz-library",
"qbz-log",
"qbz-lyrics",
@@ -6633,6 +6634,7 @@ dependencies = [
"qbz-offline-cache",
"qbz-qobuz",
"qbz-text-utils",
+ "qbz-theme",
"rand 0.10.1",
"reqwest",
"rusqlite",
diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml
index 2e88c5a3c..6e34e08d7 100644
--- a/crates/qbz-qt/Cargo.toml
+++ b/crates/qbz-qt/Cargo.toml
@@ -37,6 +37,8 @@ qbz-text-utils = { path = "../qbz-text-utils" }
qbz-credentials = { path = "../qbz-credentials" }
qbz-i18n = { path = "../qbz-i18n" }
qbz-log = { path = "../qbz-log" }
+qbz-theme = { path = "../qbz-theme" }
+qbz-integrations = { path = "../qbz-integrations" }
rusqlite = { version = "0.31", features = ["bundled"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs
index 6bde35837..bd7f3e868 100644
--- a/crates/qbz-qt/build.rs
+++ b/crates/qbz-qt/build.rs
@@ -55,6 +55,17 @@ fn main() {
"qml/HomeView.qml",
"qml/LibraryView.qml",
"qml/SettingsView.qml",
+ "qml/AppearanceSettings.qml",
+ "qml/IntegrationsSettings.qml",
+ "qml/SettingRow.qml",
+ "qml/GroupHeader.qml",
+ "qml/SettingsDivider.qml",
+ "qml/SettingsSpacer.qml",
+ "qml/SettingsButton.qml",
+ "qml/QbzToggle.qml",
+ "qml/QbzSelect.qml",
+ "qml/QbzSlider.qml",
+ "qml/QbzLineEdit.qml",
"qml/SearchView.qml",
"qml/Cortinilla.qml",
"qml/PlaylistView.qml",
diff --git a/crates/qbz-qt/qml/AppearanceSettings.qml b/crates/qbz-qt/qml/AppearanceSettings.qml
new file mode 100644
index 000000000..d0c831740
--- /dev/null
+++ b/crates/qbz-qt/qml/AppearanceSettings.qml
@@ -0,0 +1,471 @@
+// Settings > Appearance (phase 19) — the QML port of
+// crates/qbz-ui/ui/settings/AppearanceSettings.slint. Row order, group
+// headers and gating mirror the Slint 1:1; every control rides the single
+// settingsJson document (root.doc) and the settingsBool/Select/String
+// invokables — never local truth.
+//
+// The THEME row is live: QbzBridge.themeSlug / themeListJson / themeFilter
+// (theme_qt.rs) and themeSet() repaints the whole app through QbzTheme.qml
+// (the Slint theme::push_colors equivalent).
+//
+// POC-NOTEs (deliberate cuts):
+// - "Auto (dynamic)" rows: the Source dropdown persists, Regenerate
+// re-resolves via AutoSource::System; the "Select Image..." flow
+// (file dialog + AutoSource::Image) is not ported.
+// - "Custom" rows: the Slint token editor is not ported (owner: read-only
+// apply of custom_theme.json) — an info row stands in.
+// - The "system" registry theme maps to the Dark palette (Slint reads the
+// OS palette).
+// - Language persists but applies on next launch (live switch = phase 20).
+// - UI scale / renderer / preferred GPU persist with restart semantics;
+// the Slint restart toasts are logs here.
+// - Preferred GPU: no adapter enumeration — Auto + the persisted adapter.
+// - The commented-out Slint blocks (immersive background/panels, title
+// template) stay out, 1:1 the owner's cut.
+
+import QtQuick
+import com.blitzfc.qbz
+
+Column {
+ id: root
+
+ property var doc: ({})
+
+ QbzTheme { id: theme }
+
+ spacing: 4
+
+ // --- Theme row state (theme_qt.rs via the bridge) --------------------
+ readonly property var themeEntries: {
+ try {
+ return JSON.parse(QbzBridge.themeListJson)
+ } catch (e) {
+ return []
+ }
+ }
+ // 0 All / 1 Dark / 2 Light; the Auto/Custom synthetics only list under
+ // All (theme.rs:217-227).
+ readonly property var filteredThemes: {
+ const f = QbzBridge.themeFilter
+ const out = []
+ for (let i = 0; i < themeEntries.length; i++) {
+ const e = themeEntries[i]
+ const synthetic = e.slug === "auto" || e.slug === "custom"
+ if (f === 1 && (e.isLight || synthetic)) continue
+ if (f === 2 && (!e.isLight || synthetic)) continue
+ out.push(e)
+ }
+ return out
+ }
+ readonly property var filteredLabels: filteredThemes.map(function (e) { return e.label })
+ readonly property int currentThemeIndex: {
+ for (let i = 0; i < filteredThemes.length; i++) {
+ if (filteredThemes[i].slug === QbzBridge.themeSlug) return i
+ }
+ return -1
+ }
+ readonly property bool tbLocked: doc.hideTitleBar === true || doc.useSystemTitleBar === true
+
+ // ============================ THEME ==================================
+ GroupHeader { text: QbzBridge.tr("THEME") }
+ SettingRow {
+ label: QbzBridge.tr("Theme")
+ Row {
+ spacing: 8
+ // Filter cycle (All -> Dark -> Light): sun-moon / moon / sun.
+ Rectangle {
+ width: 34
+ height: 34
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderSubtle
+ color: fArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
+ QbzIcon {
+ anchors.centerIn: parent
+ name: QbzBridge.themeFilter === 1 ? "moon"
+ : QbzBridge.themeFilter === 2 ? "sun" : "sun-moon"
+ width: 16
+ height: 16
+ tintName: "secondary"
+ }
+ MouseArea {
+ id: fArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzBridge.themeSetFilter((QbzBridge.themeFilter + 1) % 3)
+ }
+ }
+ QbzSelect {
+ menuWidth: 220
+ searchable: true
+ options: root.filteredLabels
+ currentIndex: root.currentThemeIndex
+ onSelected: function (i) {
+ if (i >= 0 && i < root.filteredThemes.length)
+ QbzBridge.themeSet(root.filteredThemes[i].slug)
+ }
+ }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Album header gradient")
+ description: QbzBridge.tr("Use artwork-derived blur as a backdrop in album and artist detail views.")
+ QbzToggle {
+ checked: root.doc.albumHeaderGradient === true
+ onToggled: function (v) { QbzBridge.settingsBool("album-header-gradient", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Dynamic background")
+ QbzSelect {
+ menuWidth: 200
+ options: root.doc.appBackgroundModes || []
+ currentIndex: root.doc.appBackgroundIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("app-background", i) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Intelligent Search")
+ QbzToggle {
+ checked: root.doc.intelligentSearch === true
+ onToggled: function (v) { QbzBridge.settingsBool("intelligent-search", v) }
+ }
+ }
+ // Auto-theme rows (theme slug "auto" only).
+ SettingRow {
+ visible: QbzBridge.themeSlug === "auto"
+ label: QbzBridge.tr("Source")
+ QbzSelect {
+ menuWidth: 200
+ options: [QbzBridge.tr("System Colors"), QbzBridge.tr("Wallpaper Sync"), QbzBridge.tr("Custom Image")]
+ currentIndex: root.doc.autoThemeSourceIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("auto-theme-source", i) }
+ }
+ }
+ SettingRow {
+ visible: QbzBridge.themeSlug === "auto"
+ label: QbzBridge.tr("Regenerate")
+ description: QbzBridge.tr("Rebuild the palette from the source (System colors in the POC).")
+ SettingsButton {
+ text: QbzBridge.tr("Regenerate")
+ onClicked: QbzBridge.themeSet(QbzBridge.themeSlug)
+ }
+ }
+ // Custom-theme rows (theme slug "custom" only) — no token editor.
+ SettingRow {
+ visible: QbzBridge.themeSlug === "custom"
+ label: QbzBridge.tr("Custom theme")
+ description: QbzBridge.tr("Applied read-only from custom_theme.json — the token editor is not ported to the Qt frontend (POC).")
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // ===================== TYPOGRAPHY & LANGUAGE =========================
+ GroupHeader { text: QbzBridge.tr("TYPOGRAPHY & LANGUAGE") }
+ SettingRow {
+ label: QbzBridge.tr("Language")
+ QbzSelect {
+ menuWidth: 200
+ options: root.doc.languages || []
+ currentIndex: root.doc.languageIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("language", i) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Interface size")
+ description: QbzBridge.tr("Restart to apply.")
+ QbzSelect {
+ menuWidth: 200
+ options: root.doc.uiScales || []
+ currentIndex: root.doc.uiScaleIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("ui-scale", i) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Immersive search")
+ QbzSelect {
+ menuWidth: 220
+ options: root.doc.immersiveSearchActions || []
+ currentIndex: root.doc.immersiveSearchActionIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("immersive-search-action", i) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Immersive default view")
+ QbzSelect {
+ menuWidth: 220
+ options: root.doc.immersiveDefaultViews || []
+ currentIndex: root.doc.immersiveDefaultViewIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("immersive-default-view", i) }
+ }
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // ======================= LIBRARY & VISUALS ===========================
+ GroupHeader { text: QbzBridge.tr("LIBRARY & VISUALS") }
+ SettingRow {
+ label: QbzBridge.tr("Show navigation in sidebar")
+ QbzToggle {
+ checked: root.doc.navInSidebar === true
+ onToggled: function (v) { QbzBridge.settingsBool("nav-in-sidebar", v) }
+ }
+ }
+ SettingRow {
+ // ADR-010: only mounted when navigation is NOT in the sidebar.
+ visible: root.doc.navInSidebar !== true
+ label: QbzBridge.tr("Compact header navigation")
+ QbzToggle {
+ checked: root.doc.navHeaderCompact === true
+ onToggled: function (v) { QbzBridge.settingsBool("nav-header-compact", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("My QBZ")
+ Row {
+ spacing: 8
+ RoundedImage {
+ width: 34
+ height: 34
+ source: "assets/qbz-logo.png"
+ radius: theme.radiusSm
+ }
+ QbzLineEdit {
+ width: 150
+ text: root.doc.myQbzLabel || ""
+ placeholder: QbzBridge.tr("My QBZ")
+ onCommitted: function (v) { QbzBridge.settingsString("myqbz-label", v) }
+ }
+ SettingsButton {
+ iconName: "rotate-ccw"
+ onClicked: QbzBridge.settingsString("myqbz-label", "")
+ }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Show playlist cover collages in sidebar")
+ QbzToggle {
+ checked: root.doc.sidebarPlaylistCollage === true
+ onToggled: function (v) { QbzBridge.settingsBool("sidebar-playlist-collage", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Show album artwork in Local Library track list")
+ QbzToggle {
+ checked: root.doc.localLibraryTrackArtwork === true
+ onToggled: function (v) { QbzBridge.settingsBool("local-library-track-artwork", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Play indicator animation")
+ QbzToggle {
+ checked: root.doc.playIndicatorAnimation === true
+ onToggled: function (v) { QbzBridge.settingsBool("play-indicator-animation", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Invert swipe navigation direction")
+ QbzToggle {
+ checked: root.doc.invertSwipeNavigation === true
+ onToggled: function (v) { QbzBridge.settingsBool("invert-swipe-navigation", v) }
+ }
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // ========================= NOTIFICATIONS =============================
+ GroupHeader { text: QbzBridge.tr("NOTIFICATIONS") }
+ SettingRow {
+ label: QbzBridge.tr("In-app toasts notifications")
+ QbzToggle {
+ checked: root.doc.inAppToasts === true
+ onToggled: function (v) { QbzBridge.settingsBool("in-app-toasts", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("System Notifications")
+ QbzToggle {
+ checked: root.doc.systemNotifications === true
+ onToggled: function (v) { QbzBridge.settingsBool("system-notifications", v) }
+ }
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // ========================= WINDOW TITLE ==============================
+ GroupHeader { text: QbzBridge.tr("WINDOW TITLE") }
+ SettingRow {
+ label: QbzBridge.tr("Show track in window title")
+ QbzToggle {
+ checked: root.doc.windowTitleShow === true
+ onToggled: function (v) { QbzBridge.settingsBool("window-title-show", v) }
+ }
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // ========================== TITLE BAR ================================
+ GroupHeader { text: QbzBridge.tr("TITLE BAR") }
+ SettingRow {
+ label: QbzBridge.tr("Use system title bar")
+ description: QbzBridge.tr("Restart to apply.")
+ QbzToggle {
+ checked: root.doc.useSystemTitleBar === true
+ onToggled: function (v) { QbzBridge.settingsBool("use-system-title-bar", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Hide title bar")
+ rowEnabled: root.doc.useSystemTitleBar !== true
+ QbzToggle {
+ enabled: root.doc.useSystemTitleBar !== true
+ checked: root.doc.hideTitleBar === true
+ onToggled: function (v) { QbzBridge.settingsBool("hide-title-bar", v) }
+ }
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // ======================= WINDOW CONTROLS =============================
+ GroupHeader { text: QbzBridge.tr("WINDOW CONTROLS") }
+ SettingRow {
+ label: QbzBridge.tr("Window controls position")
+ rowEnabled: !root.tbLocked
+ QbzSelect {
+ enabled: !root.tbLocked
+ menuWidth: 160
+ options: root.doc.wcPositions || []
+ currentIndex: root.doc.wcPositionIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("wc-position", i) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Show window controls")
+ rowEnabled: !root.tbLocked
+ QbzToggle {
+ enabled: !root.tbLocked
+ checked: root.doc.showWindowControls === true
+ onToggled: function (v) { QbzBridge.settingsBool("show-window-controls", v) }
+ }
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // ========================= PLAYER VIEWS ==============================
+ GroupHeader { text: QbzBridge.tr("PLAYER VIEWS") }
+ SettingRow {
+ label: QbzBridge.tr("Show volume +/- buttons")
+ QbzToggle {
+ checked: root.doc.showVolumeSteppers === true
+ onToggled: function (v) { QbzBridge.settingsBool("show-volume-steppers", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Mini player default view")
+ QbzSelect {
+ menuWidth: 220
+ options: root.doc.miniDefaultViews || []
+ currentIndex: root.doc.miniDefaultViewIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("mini-default-view", i) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Startup page")
+ QbzSelect {
+ menuWidth: 220
+ options: root.doc.startupPages || []
+ currentIndex: root.doc.startupPageIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("startup-page", i) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Show Purchases")
+ QbzToggle {
+ checked: root.doc.showPurchases === true
+ onToggled: function (v) { QbzBridge.settingsBool("show-purchases", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Purchases in title bar")
+ rowEnabled: !root.tbLocked
+ QbzToggle {
+ enabled: !root.tbLocked
+ checked: root.doc.navTbPurchases === true
+ onToggled: function (v) { QbzBridge.settingsBool("nav-tb-purchases", v) }
+ }
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // ========================== SYSTEM TRAY ==============================
+ GroupHeader { text: QbzBridge.tr("SYSTEM TRAY") }
+ SettingRow {
+ label: QbzBridge.tr("Enable tray icon")
+ QbzToggle {
+ checked: root.doc.trayEnable === true
+ onToggled: function (v) { QbzBridge.settingsBool("tray-enable", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Close to tray")
+ rowEnabled: root.doc.trayEnable === true
+ QbzToggle {
+ enabled: root.doc.trayEnable === true
+ checked: root.doc.trayCloseToTray === true
+ onToggled: function (v) { QbzBridge.settingsBool("tray-close-to-tray", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Tray icon variant")
+ QbzSelect {
+ menuWidth: 200
+ options: root.doc.trayIconThemes || []
+ currentIndex: root.doc.trayIconThemeIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("tray-icon-theme", i) }
+ }
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // =========================== RENDERER ================================
+ GroupHeader { text: QbzBridge.tr("RENDERER") }
+ SettingRow {
+ label: QbzBridge.tr("Rendering backend")
+ description: QbzBridge.tr("Restart to apply.")
+ QbzSelect {
+ menuWidth: 220
+ options: root.doc.renderers || []
+ currentIndex: root.doc.rendererIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("renderer", i) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Preferred GPU")
+ description: QbzBridge.tr("Restart to apply.")
+ QbzSelect {
+ menuWidth: 260
+ options: root.doc.gpuPowers || []
+ currentIndex: root.doc.gpuPowerIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("gpu-power", i) }
+ }
+ }
+}
diff --git a/crates/qbz-qt/qml/GroupHeader.qml b/crates/qbz-qt/qml/GroupHeader.qml
new file mode 100644
index 000000000..007207ff8
--- /dev/null
+++ b/crates/qbz-qt/qml/GroupHeader.qml
@@ -0,0 +1,15 @@
+// Settings group header (AppearanceSettings.slint GroupHeader) — extracted
+// from SettingsView.qml in phase 19: 11px muted uppercase, 1.5px tracking,
+// semibold.
+
+import QtQuick
+import com.blitzfc.qbz
+
+Text {
+ QbzTheme { id: theme }
+
+ color: theme.textMuted
+ font.pixelSize: 11
+ font.letterSpacing: 1.5
+ font.weight: theme.weightSemibold
+}
diff --git a/crates/qbz-qt/qml/IntegrationsSettings.qml b/crates/qbz-qt/qml/IntegrationsSettings.qml
new file mode 100644
index 000000000..4fc1b5c51
--- /dev/null
+++ b/crates/qbz-qt/qml/IntegrationsSettings.qml
@@ -0,0 +1,238 @@
+// Settings > Integrations (phase 19) — the QML port of
+// crates/qbz-ui/ui/settings/IntegrationsSettings.slint. Row order, group
+// headers and auth-gating mirror the Slint 1:1; all state rides the single
+// settingsJson document (integrations_qt.rs over the SAME scrobbler /
+// discover / ui_prefs stores the Slint app uses).
+//
+// POC-NOTEs (named in integrations_qt.rs too):
+// - No live scrobbling (scrobble::start / on_track_changed not ported) —
+// toggles + credentials persist for the Slint scrobbler / a future port.
+// - Discord: the pref + live DiscordRpc flag flip; presence updates are
+// not fed (no NowListening pushes on track change).
+// - The Last.fm connect flow opens the system browser (open::that) — the
+// offscreen smoke never clicks it.
+
+import QtQuick
+import com.blitzfc.qbz
+
+Column {
+ id: root
+
+ property var doc: ({})
+
+ QbzTheme { id: theme }
+
+ spacing: 4
+
+ // ======================= RECOMMENDATIONS =============================
+ GroupHeader { text: QbzBridge.tr("RECOMMENDATIONS") }
+ SettingRow {
+ label: QbzBridge.tr("Show Recommendations in Discover")
+ description: QbzBridge.tr("A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases.")
+ QbzToggle {
+ checked: root.doc.showRecommendations === true
+ onToggled: function (v) { QbzBridge.settingsBool("show-recommendations", v) }
+ }
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // =========================== METADATA ================================
+ GroupHeader { text: QbzBridge.tr("METADATA") }
+ SettingRow {
+ label: QbzBridge.tr("MusicBrainz")
+ description: QbzBridge.tr("Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections.")
+ QbzToggle {
+ checked: root.doc.musicbrainzEnabled === true
+ onToggled: function (v) { QbzBridge.settingsBool("musicbrainz", v) }
+ }
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // ========================== SCROBBLERS ===============================
+ // Master header row (toggle + collapse chevron), then the body gated
+ // on enabled && !collapsed (IntegrationsSettings.slint:113-169).
+ Item {
+ width: parent.width
+ height: 64
+ Column {
+ anchors.left: parent.left
+ anchors.right: scrobbleControl.left
+ anchors.rightMargin: 24
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 3
+ Text {
+ width: parent.width
+ text: QbzBridge.tr("SCROBBLERS")
+ color: theme.textMuted
+ font.pixelSize: 11
+ font.letterSpacing: 1.5
+ font.weight: theme.weightSemibold
+ elide: Text.ElideRight
+ }
+ Text {
+ width: parent.width
+ text: QbzBridge.tr("Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks.")
+ color: theme.textMuted
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+ }
+ Row {
+ id: scrobbleControl
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+ // Collapse chevron (only when the master toggle is on).
+ Rectangle {
+ visible: root.doc.scrobbleEnabled === true
+ width: 28
+ height: 28
+ radius: theme.radiusSm
+ color: colArea.containsMouse ? theme.surfaceHover : "transparent"
+ QbzIcon {
+ anchors.centerIn: parent
+ name: root.doc.scrobbleUiCollapsed === true ? "chevron-right" : "chevron-down"
+ width: 16
+ height: 16
+ tintName: "secondary"
+ }
+ MouseArea {
+ id: colArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzBridge.settingsBool("scrobble-collapse", root.doc.scrobbleUiCollapsed !== true)
+ }
+ }
+ QbzToggle {
+ anchors.verticalCenter: parent.verticalCenter
+ checked: root.doc.scrobbleEnabled === true
+ onToggled: function (v) { QbzBridge.settingsBool("scrobble-enable", v) }
+ }
+ }
+ }
+
+ Column {
+ visible: root.doc.scrobbleEnabled === true && root.doc.scrobbleUiCollapsed !== true
+ width: parent.width
+ spacing: 4
+
+ // ------------------------- LAST.FM ------------------------------
+ GroupHeader { text: QbzBridge.tr("LAST.FM") }
+ SettingRow {
+ label: QbzBridge.tr("Scrobble to Last.fm")
+ description: root.doc.lastfmAuthed === true
+ ? QbzBridge.tr("Signed in as {}.").replace("{}", root.doc.lastfmUsername || "")
+ : QbzBridge.tr("Connect your Last.fm account to enable scrobbling.")
+ QbzToggle {
+ checked: root.doc.lastfmEnabled === true
+ onToggled: function (v) { QbzBridge.settingsBool("lastfm-enable", v) }
+ }
+ }
+ SettingRow {
+ visible: root.doc.lastfmAuthed !== true
+ label: QbzBridge.tr("Connect")
+ description: QbzBridge.tr("Authorize QBZ in your browser, then click Finish.")
+ Row {
+ spacing: 8
+ SettingsButton {
+ text: root.doc.lastfmBusy === true ? QbzBridge.tr("Working...") : QbzBridge.tr("Connect Last.fm")
+ enabled: root.doc.lastfmBusy !== true
+ onClicked: QbzBridge.integrationsAction("lastfm-connect")
+ }
+ SettingsButton {
+ visible: (root.doc.lastfmAuthUrl || "") !== ""
+ text: QbzBridge.tr("Open authorize page")
+ onClicked: QbzBridge.integrationsAction("lastfm-open-auth-url")
+ }
+ SettingsButton {
+ visible: (root.doc.lastfmAuthUrl || "") !== ""
+ text: QbzBridge.tr("Finish")
+ onClicked: QbzBridge.integrationsAction("lastfm-finish")
+ }
+ }
+ }
+ SettingRow {
+ visible: root.doc.lastfmAuthed === true
+ label: QbzBridge.tr("Disconnect Last.fm")
+ description: QbzBridge.tr("Sign out of Last.fm.")
+ SettingsButton {
+ danger: true
+ text: QbzBridge.tr("Disconnect")
+ onClicked: QbzBridge.integrationsAction("lastfm-disconnect")
+ }
+ }
+
+ SettingsSpacer { }
+
+ // ----------------------- LISTENBRAINZ ----------------------------
+ GroupHeader { text: QbzBridge.tr("LISTENBRAINZ") }
+ SettingRow {
+ label: QbzBridge.tr("Scrobble to ListenBrainz")
+ description: root.doc.listenbrainzAuthed === true
+ ? QbzBridge.tr("Signed in as {}.").replace("{}", root.doc.listenbrainzUsername || "")
+ : QbzBridge.tr("Paste your ListenBrainz user token to enable scrobbling.")
+ QbzToggle {
+ checked: root.doc.listenbrainzEnabled === true
+ onToggled: function (v) { QbzBridge.settingsBool("listenbrainz-enable", v) }
+ }
+ }
+ SettingRow {
+ visible: root.doc.listenbrainzAuthed !== true
+ label: QbzBridge.tr("User token")
+ description: QbzBridge.tr("From listenbrainz.org/settings.")
+ QbzLineEdit {
+ isPassword: true
+ placeholder: QbzBridge.tr("ListenBrainz token")
+ onCommitted: function (v) { QbzBridge.settingsString("listenbrainz-token", v) }
+ }
+ }
+ SettingRow {
+ visible: root.doc.listenbrainzAuthed === true
+ label: QbzBridge.tr("Disconnect ListenBrainz")
+ description: QbzBridge.tr("Sign out of ListenBrainz.")
+ SettingsButton {
+ danger: true
+ text: QbzBridge.tr("Disconnect")
+ onClicked: QbzBridge.integrationsAction("listenbrainz-disconnect")
+ }
+ }
+
+ // Status line (scrobble.rs set_status; kind 1 info / 2 ok / 3 error).
+ Text {
+ visible: (root.doc.integrationsStatusText || "") !== ""
+ width: parent.width
+ text: root.doc.integrationsStatusText || ""
+ color: root.doc.integrationsStatusKind === 2 ? theme.success
+ : root.doc.integrationsStatusKind === 3 ? theme.danger
+ : theme.textMuted
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
+
+ // =========================== DISCORD =================================
+ GroupHeader { text: QbzBridge.tr("DISCORD") }
+ SettingRow {
+ label: QbzBridge.tr("Discord Rich Presence")
+ description: QbzBridge.tr("Show what you're listening to as your Discord status. Opt-in — Discord must be running.")
+ QbzToggle {
+ checked: root.doc.discordEnabled === true
+ onToggled: function (v) { QbzBridge.settingsBool("discord-rpc", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Flatpak socket access")
+ description: QbzBridge.tr("Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\nflatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz")
+ }
+}
diff --git a/crates/qbz-qt/qml/QbzLineEdit.qml b/crates/qbz-qt/qml/QbzLineEdit.qml
new file mode 100644
index 000000000..4716867b3
--- /dev/null
+++ b/crates/qbz-qt/qml/QbzLineEdit.qml
@@ -0,0 +1,57 @@
+// QbzLineEdit (std-widgets LineEdit, settings styling) — extracted from
+// SettingsView.qml in phase 19. 240px / 34px elevated r8 bordered input;
+// commits on Enter AND on focus loss (the Tauri onchange semantics —
+// PlaybackSettings.slint).
+
+import QtQuick
+import com.blitzfc.qbz
+
+Rectangle {
+ property string text: ""
+ property string placeholder: ""
+ property bool isPassword: false
+ signal committed(string value)
+
+ QbzTheme { id: theme }
+
+ width: 240
+ height: 34
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderSubtle
+ color: theme.surfaceElevated
+
+ TextInput {
+ id: input
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ color: theme.textPrimary
+ font.pixelSize: theme.fontBody
+ verticalAlignment: Text.AlignVCenter
+ clip: true
+ echoMode: parent.isPassword ? TextInput.Password : TextInput.Normal
+ text: parent.text
+ onAccepted: parent.committed(text)
+ onActiveFocusChanged: if (!activeFocus) parent.committed(text)
+ // External republish (e.g. Reset) re-seeds the field while it is
+ // not being edited.
+ Binding {
+ target: input
+ property: "text"
+ value: input.parent.text
+ when: !input.activeFocus
+ }
+ }
+ Text {
+ visible: input.text === ""
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ text: placeholder
+ color: theme.textMuted
+ font.pixelSize: theme.fontBody
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+}
diff --git a/crates/qbz-qt/qml/QbzSelect.qml b/crates/qbz-qt/qml/QbzSelect.qml
new file mode 100644
index 000000000..1531d78a1
--- /dev/null
+++ b/crates/qbz-qt/qml/QbzSelect.qml
@@ -0,0 +1,292 @@
+// QbzSelect (primitives/QbzSelect.slint, standard size) — extracted from
+// SettingsView.qml in phase 19 so every settings panel shares the one
+// replica (the ui-control-alignment-standard searchable dropdown: the
+// filter box shows past ~8 options via `searchable: true`).
+// 34px bordered control (elevated, hover -> surface-hover) + a Popup
+// list (surface-main r8 border-muted, 32px rows, capped at 360px with
+// scroll, optional 42px filter box, 24px group-header rows, "BP" badge
+// / speaker glyph on device options). `options` entries are either
+// plain strings or {label, bp, group} objects (the device list).
+
+import QtQuick
+import QtQuick.Controls
+import com.blitzfc.qbz
+
+Rectangle {
+ property var options: []
+ property int currentIndex: 0
+ property int menuWidth: 240
+ property int popupWidth: 0
+ property bool enabled: true
+ property bool searchable: false
+ signal selected(int index)
+
+ QbzTheme { id: theme }
+
+ id: selectRoot
+ width: menuWidth
+ height: 34
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderSubtle
+ color: selArea.containsMouse && enabled ? theme.surfaceHover : theme.surfaceElevated
+ opacity: enabled ? 1.0 : 0.4
+
+ readonly property int listWidth: Math.max(popupWidth, menuWidth)
+ readonly property int rowHeight: 32
+ readonly property int headerHeight: 24
+ readonly property int searchHeight: searchable ? 42 : 0
+ readonly property int maxListHeight: 360
+ property string filter: ""
+
+ function optLabel(i) {
+ const o = options[i]
+ return (typeof o === "string") ? o : (o && o.label !== undefined ? o.label : "")
+ }
+ function optHasBadges() {
+ return options.length > 0 && typeof options[0] !== "string"
+ }
+ function optBp(i) {
+ const o = options[i]
+ return o && o.bp === true
+ }
+ function optGroup(i) {
+ const o = options[i]
+ return (o && o.group !== undefined) ? o.group : ""
+ }
+
+ Row {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 10
+ spacing: 8
+ Text {
+ width: parent.width - 16 - 8 - badgeSlot.width - (badgeSlot.visible ? 8 : 0)
+ height: parent.height
+ text: selectRoot.currentIndex >= 0 && selectRoot.currentIndex < selectRoot.options.length
+ ? selectRoot.optLabel(selectRoot.currentIndex) : ""
+ color: theme.textPrimary
+ font.pixelSize: theme.fontBody
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ // Trailing BP badge / speaker glyph for the CURRENT device option.
+ Item {
+ id: badgeSlot
+ visible: selectRoot.optHasBadges()
+ width: visible ? 20 : 0
+ height: parent.height
+ Text {
+ visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length
+ && selectRoot.optBp(selectRoot.currentIndex)
+ anchors.centerIn: parent
+ text: "BP"
+ color: theme.accent
+ font.pixelSize: theme.fontLegal
+ font.weight: theme.weightSemibold
+ font.letterSpacing: 0.5
+ }
+ QbzIcon {
+ visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length
+ && !selectRoot.optBp(selectRoot.currentIndex)
+ name: "volume-2"
+ width: 14
+ height: 14
+ anchors.centerIn: parent
+ tintName: "muted"
+ }
+ }
+ QbzIcon {
+ name: "chevron-down"
+ width: 16
+ height: 16
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: "muted"
+ }
+ }
+ MouseArea {
+ id: selArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: selectRoot.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: {
+ if (selectRoot.enabled) {
+ selectRoot.filter = ""
+ popup.open()
+ }
+ }
+ }
+
+ Popup {
+ id: popup
+ parent: selectRoot
+ // Right-anchored: a list wider than the control grows leftward.
+ x: selectRoot.width - selectRoot.listWidth
+ y: selectRoot.height + 4
+ width: selectRoot.listWidth
+ height: selectRoot.searchHeight + Math.min(listContent.contentHeight, selectRoot.maxListHeight) + 10
+ padding: 0
+ closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
+
+ background: Rectangle {
+ color: theme.surfaceMain
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderMuted
+ }
+ contentItem: Item {
+ implicitWidth: selectRoot.listWidth
+ implicitHeight: popup.height
+
+ // Filter box (searchable lists only).
+ Rectangle {
+ id: searchBox
+ visible: selectRoot.searchable
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ height: selectRoot.searchHeight
+ color: "transparent"
+ Row {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ anchors.bottomMargin: 6
+ spacing: 8
+ QbzIcon {
+ name: "search"
+ width: 14
+ height: 14
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: "muted"
+ }
+ Item {
+ width: parent.width - 14 - 8
+ height: parent.height
+ TextInput {
+ id: searchInput
+ anchors.fill: parent
+ color: theme.textPrimary
+ font.pixelSize: theme.fontBody
+ verticalAlignment: Text.AlignVCenter
+ clip: true
+ text: selectRoot.filter
+ onTextChanged: selectRoot.filter = text
+ }
+ Text {
+ visible: searchInput.text === ""
+ anchors.fill: parent
+ text: QbzBridge.tr("Search…")
+ color: theme.textMuted
+ font.pixelSize: theme.fontBody
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+ }
+ }
+
+ ListView {
+ id: listContent
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: searchBox.bottom
+ anchors.bottom: parent.bottom
+ anchors.topMargin: 5
+ anchors.bottomMargin: 5
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ model: selectRoot.options
+
+ delegate: Column {
+ id: optRow
+ required property int index
+ required property var modelData
+ width: listContent.width
+
+ readonly property string label: selectRoot.optLabel(index)
+ readonly property bool shown: selectRoot.filter === ""
+ || label.toLowerCase().indexOf(selectRoot.filter.toLowerCase()) >= 0
+
+ // Group-header row (ALSA device sections).
+ Rectangle {
+ visible: optRow.shown && selectRoot.optGroup(optRow.index) !== ""
+ width: parent.width
+ height: visible ? selectRoot.headerHeight : 0
+ color: "transparent"
+ Text {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ anchors.top: parent.top
+ anchors.topMargin: 4
+ height: parent.height - 4
+ text: selectRoot.optGroup(optRow.index)
+ color: theme.textMuted
+ font.pixelSize: theme.fontLegal
+ font.weight: theme.weightSemibold
+ font.letterSpacing: 0.5
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ }
+ Rectangle {
+ visible: optRow.shown
+ width: parent.width
+ height: visible ? selectRoot.rowHeight : 0
+ color: optArea.containsMouse ? theme.surfaceHover : "transparent"
+ Row {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ spacing: 8
+ Text {
+ width: parent.width - rowBadge.width - (rowBadge.visible ? 8 : 0)
+ height: parent.height
+ text: optRow.label
+ color: optRow.index === selectRoot.currentIndex
+ ? theme.accent : theme.textSecondary
+ font.pixelSize: theme.fontBody
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ Item {
+ id: rowBadge
+ visible: selectRoot.optHasBadges()
+ width: visible ? 20 : 0
+ height: parent.height
+ Text {
+ visible: selectRoot.optBp(optRow.index)
+ anchors.centerIn: parent
+ text: "BP"
+ color: theme.accent
+ font.pixelSize: theme.fontLegal
+ font.weight: theme.weightSemibold
+ font.letterSpacing: 0.5
+ }
+ QbzIcon {
+ visible: !selectRoot.optBp(optRow.index)
+ name: "volume-2"
+ width: 14
+ height: 14
+ anchors.centerIn: parent
+ tintName: "muted"
+ }
+ }
+ }
+ MouseArea {
+ id: optArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ popup.close()
+ selectRoot.selected(optRow.index)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/crates/qbz-qt/qml/QbzSlider.qml b/crates/qbz-qt/qml/QbzSlider.qml
new file mode 100644
index 000000000..524dc515c
--- /dev/null
+++ b/crates/qbz-qt/qml/QbzSlider.qml
@@ -0,0 +1,74 @@
+// QbzSlider (primitives/QbzSlider.slint) — extracted from SettingsView.qml
+// in phase 19. 200x22, 4px r2 track, accent fill, 16px thumb; integer
+// steps. Like the Slint original the thumb follows the pointer during a
+// drag (local dragValue) and commits each step via changed(int).
+
+import QtQuick
+import com.blitzfc.qbz
+
+Item {
+ property int minimum: 0
+ property int maximum: 10
+ property int value: 0
+ signal changed(int newValue)
+
+ QbzTheme { id: theme }
+
+ width: 200
+ height: 22
+
+ readonly property int thumbSize: 16
+ readonly property real travel: width - thumbSize
+ readonly property real fraction: maximum > minimum
+ ? Math.max(0, Math.min(1, (value - minimum) / (maximum - minimum))) : 0
+ property bool dragging: false
+ property real dragFraction: fraction
+ readonly property real shownFraction: dragging ? dragFraction : fraction
+ onFractionChanged: if (!dragging) dragFraction = fraction
+
+ function commit(frac) {
+ const v = Math.round(minimum + Math.max(0, Math.min(1, frac)) * (maximum - minimum))
+ if (v !== value) changed(v)
+ }
+
+ Rectangle { // track
+ x: 0
+ y: Math.round((parent.height - height) / 2)
+ width: parent.width
+ height: 4
+ radius: 2
+ color: theme.surfaceElevated
+ }
+ Rectangle { // accent fill
+ x: 0
+ y: Math.round((parent.height - height) / 2)
+ width: parent.thumbSize / 2 + parent.shownFraction * parent.travel
+ height: 4
+ radius: 2
+ color: theme.accent
+ }
+ Rectangle { // thumb
+ width: parent.thumbSize
+ height: parent.thumbSize
+ radius: parent.thumbSize / 2
+ x: parent.shownFraction * parent.travel
+ anchors.verticalCenter: parent.verticalCenter
+ color: theme.textPrimary
+ }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onPressed: {
+ parent.dragging = true
+ parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel))
+ parent.commit(parent.dragFraction)
+ }
+ onPositionChanged: {
+ if (pressed) {
+ parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel))
+ parent.commit(parent.dragFraction)
+ }
+ }
+ onReleased: parent.dragging = false
+ }
+}
diff --git a/crates/qbz-qt/qml/QbzTheme.qml b/crates/qbz-qt/qml/QbzTheme.qml
index 2a8028f52..dde8b3ada 100644
--- a/crates/qbz-qt/qml/QbzTheme.qml
+++ b/crates/qbz-qt/qml/QbzTheme.qml
@@ -1,43 +1,101 @@
-// Shared dark-theme constants for the POC — the values the Slint app
-// pushes into its ThemeColors struct for the dark theme
-// (crates/qbz-ui/ui/foundation/theme.slint, dark block), plus the spacing /
-// radius / typography / layout tokens the shell uses (foundation/*.slint).
+// Shared theme tokens (phase 19: LIVE) — the color tokens now bind to
+// QbzBridge.themeJson, the serialized qbz-theme ThemeColors for the
+// persisted ui_prefs `theme` slug (theme_qt.rs — the Qt equivalent of the
+// Slint theme::push_colors: switching the theme republishes the document
+// and every consumer repaints, no restart). Until the first publish lands
+// the baked dark fallback below applies (the bridge seeds themeJson at
+// construction, so the fallback is purely defensive).
+//
+// The non-color tokens (spacing / radius / typography / layout from
+// foundation/*.slint) stay static regardless of theme.
//
// Kept as a plain instantiable QtObject (NOT a pragma Singleton — the
// cxx-qt-build generated qmldir cannot mark singletons).
import QtQuick
+import com.blitzfc.qbz
QtObject {
- // --- Theme colors (dark) ------------------------------------------
- readonly property color surfaceMain: "#0f0f0f"
- readonly property color surfaceCard: "#1a1a1a"
- readonly property color surfaceElevated: "#2a2a2a"
- readonly property color surfaceHover: "#10ffffff"
- readonly property color textPrimary: "#ffffff"
- readonly property color textSecondary: "#cccccc"
- readonly property color textMuted: "#888888"
- readonly property color textDisabled: "#555555"
- readonly property color accent: "#4285f4"
- readonly property color accentHover: "#5a9bf4"
- readonly property color accentPressed: "#3275e4"
- readonly property color accentText: "#ffffff"
- readonly property color warning: "#fbbf24"
- readonly property color favorite: "#ef4444"
- // #ffffff14 / #ffffff38 / #00000066 in the Slint ARGB notation.
- readonly property color borderSubtle: "#14ffffff"
- readonly property color borderMuted: "#38ffffff"
- readonly property color cardShadow: "#66000000"
- // Ambient-background layering (phase 14): the alpha variants the Slint
- // applies when the dynamic background is active — chrome surface-card
- // at 0.5, the frosted content panel surface-main at 0.22 (+ hairline),
- // thin bars surface-main at 0.3 (AppearanceState knobs baked at the
- // defaults; the live values ride QbzBridge.ambientSurfaceAlpha /
- // ambientBarAlpha for the QBZ_BG_* env knobs).
- readonly property color surfaceCardA50: "#801a1a1a"
- readonly property color surfaceMainA22: "#380f0f0f"
- readonly property color surfaceMainA30: "#4d0f0f0f"
- readonly property color frostBorder: "#1affffff"
+ // Baked dark fallback (pre-publish only) — the old static POC tokens.
+ readonly property var _fallback: ({
+ "surfaceMain": "#0f0f0f", "surfaceCard": "#1a1a1a",
+ "surfaceElevated": "#2a2a2a", "surfaceHover": "#10ffffff",
+ "bgHover": "#2a2a2a",
+ "textPrimary": "#ffffff", "textSecondary": "#cccccc",
+ "textMuted": "#888888", "textDisabled": "#555555",
+ "accent": "#4285f4", "accentHover": "#5a9bf4",
+ "accentPressed": "#3275e4", "accentText": "#ffffff",
+ "danger": "#e0564f", "dangerBg": "#26e0564f",
+ "dangerBorder": "#59e0564f", "dangerHover": "#e87670",
+ "warning": "#fbbf24", "warningBg": "#26fbbf24",
+ "warningBorder": "#59fbbf24", "warningHover": "#fdcb47",
+ "success": "#3fae6a", "successBg": "#263fae6a",
+ "successBorder": "#593fae6a", "successHover": "#55bd7e",
+ "borderSubtle": "#14ffffff", "borderMuted": "#38ffffff",
+ "borderStrong": "#59ffffff", "focusRing": "#4285f4",
+ "favorite": "#ef4444", "cardShadow": "#66000000",
+ "surfaceCardA50": "#801a1a1a", "surfaceMainA22": "#380f0f0f",
+ "surfaceMainA30": "#4d0f0f0f", "frostBorder": "#1affffff",
+ "isDark": true
+ })
+
+ readonly property var _doc: {
+ if (QbzBridge.themeJson === "") return _fallback
+ try {
+ return JSON.parse(QbzBridge.themeJson)
+ } catch (e) {
+ return _fallback
+ }
+ }
+ function _c(key) {
+ const v = _doc[key]
+ return v === undefined ? _fallback[key] : v
+ }
+
+ // --- Theme colors (live; ThemeColors contract) ----------------------
+ readonly property bool isDark: _doc.isDark === undefined ? true : _doc.isDark
+ readonly property color surfaceMain: _c("surfaceMain")
+ readonly property color surfaceCard: _c("surfaceCard")
+ readonly property color surfaceElevated: _c("surfaceElevated")
+ readonly property color surfaceHover: _c("surfaceHover")
+ readonly property color bgHover: _c("bgHover")
+ readonly property color textPrimary: _c("textPrimary")
+ readonly property color textSecondary: _c("textSecondary")
+ readonly property color textMuted: _c("textMuted")
+ readonly property color textDisabled: _c("textDisabled")
+ readonly property color accent: _c("accent")
+ readonly property color accentHover: _c("accentHover")
+ readonly property color accentPressed: _c("accentPressed")
+ readonly property color accentText: _c("accentText")
+ readonly property color danger: _c("danger")
+ readonly property color dangerBg: _c("dangerBg")
+ readonly property color dangerBorder: _c("dangerBorder")
+ readonly property color dangerHover: _c("dangerHover")
+ readonly property color warning: _c("warning")
+ readonly property color warningBg: _c("warningBg")
+ readonly property color warningBorder: _c("warningBorder")
+ readonly property color warningHover: _c("warningHover")
+ readonly property color success: _c("success")
+ readonly property color successBg: _c("successBg")
+ readonly property color successBorder: _c("successBorder")
+ readonly property color successHover: _c("successHover")
+ readonly property color borderSubtle: _c("borderSubtle")
+ readonly property color borderMuted: _c("borderMuted")
+ readonly property color borderStrong: _c("borderStrong")
+ readonly property color focusRing: _c("focusRing")
+ readonly property color favorite: _c("favorite")
+ readonly property color cardShadow: _c("cardShadow")
+ // 24 alpha tiers ([4..95]%, white-based dark / black-based light).
+ readonly property var alphaTiers: _doc.alpha === undefined ? [] : _doc.alpha
+ // Ambient-background layering (phase 14; theme-derived since phase 19):
+ // chrome surface-card @50%, frosted content panel surface-main @22%,
+ // thin bars surface-main @30%, hairline = alpha tier 10%. The live
+ // values for the QBZ_BG_* env knobs still ride QbzBridge
+ // .ambientSurfaceAlpha / .ambientBarAlpha.
+ readonly property color surfaceCardA50: _c("surfaceCardA50")
+ readonly property color surfaceMainA22: _c("surfaceMainA22")
+ readonly property color surfaceMainA30: _c("surfaceMainA30")
+ readonly property color frostBorder: _c("frostBorder")
// --- Spacing (spacing.slint) --------------------------------------
readonly property int spacingXs: 4
diff --git a/crates/qbz-qt/qml/QbzToggle.qml b/crates/qbz-qt/qml/QbzToggle.qml
new file mode 100644
index 000000000..4fdef807e
--- /dev/null
+++ b/crates/qbz-qt/qml/QbzToggle.qml
@@ -0,0 +1,36 @@
+// QbzToggle (primitives/QbzToggle.slint) — extracted from SettingsView.qml
+// in phase 19 so every settings panel shares the one replica.
+// 40x22 pill r11, 16px knob, accent when on, opacity .4 disabled,
+// 120ms ease-out knob travel. Emits toggled(newValue); never self-flips.
+
+import QtQuick
+import com.blitzfc.qbz
+
+Rectangle {
+ property bool checked: false
+ property bool enabled: true
+ signal toggled(bool value)
+
+ QbzTheme { id: theme }
+
+ width: 40
+ height: 22
+ radius: 11
+ color: checked ? theme.accent : theme.surfaceElevated
+ opacity: enabled ? 1.0 : 0.4
+
+ Rectangle {
+ width: 16
+ height: 16
+ radius: 8
+ color: theme.textPrimary
+ y: 3
+ x: parent.checked ? parent.width - width - 3 : 3
+ Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutQuad } }
+ }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: if (parent.enabled) parent.toggled(!parent.checked)
+ }
+}
diff --git a/crates/qbz-qt/qml/SettingRow.qml b/crates/qbz-qt/qml/SettingRow.qml
new file mode 100644
index 000000000..e33facc20
--- /dev/null
+++ b/crates/qbz-qt/qml/SettingRow.qml
@@ -0,0 +1,50 @@
+// SettingRow (settings/SettingRow.slint) — extracted from SettingsView.qml
+// in phase 19. 52px (64 with a description); label 15 medium + description
+// 12 muted on the left (opacity .45 when disabled), the control flush right.
+
+import QtQuick
+import com.blitzfc.qbz
+
+Item {
+ property string label: ""
+ property string description: ""
+ property bool rowEnabled: true
+ default property alias control: controlHost.data
+
+ QbzTheme { id: theme }
+
+ width: parent ? parent.width : 0
+ height: description === "" ? 52 : 64
+
+ Column {
+ anchors.left: parent.left
+ anchors.right: controlHost.left
+ anchors.rightMargin: 24
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 3
+ opacity: rowEnabled ? 1.0 : 0.45
+ Text {
+ width: parent.width
+ text: label
+ color: theme.textPrimary
+ font.pixelSize: theme.fontBody
+ font.weight: theme.weightMedium
+ elide: Text.ElideRight
+ }
+ Text {
+ visible: description !== ""
+ width: parent.width
+ text: description
+ color: theme.textMuted
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+ }
+ Item {
+ id: controlHost
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ width: childrenRect.width
+ height: childrenRect.height
+ }
+}
diff --git a/crates/qbz-qt/qml/SettingsButton.qml b/crates/qbz-qt/qml/SettingsButton.qml
new file mode 100644
index 000000000..ef4fbda46
--- /dev/null
+++ b/crates/qbz-qt/qml/SettingsButton.qml
@@ -0,0 +1,55 @@
+// Settings SecondaryButton (IntegrationsSettings.slint:27 /
+// AppearanceSettings.slint:83) — min 160px (compact variant 34px square for
+// icon buttons), 34px tall, bordered, optional danger red (#e0564f ≈
+// theme.danger), busy spinner text.
+
+import QtQuick
+import com.blitzfc.qbz
+
+Rectangle {
+ property string text: ""
+ property string iconName: ""
+ property bool danger: false
+ property bool busy: false
+ property bool enabled: true
+ signal clicked()
+
+ QbzTheme { id: theme }
+
+ width: Math.max(iconName !== "" && text === "" ? 34 : 160, row.implicitWidth + 24)
+ height: 34
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: danger ? theme.danger : theme.borderSubtle
+ color: enabled && btnArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
+ opacity: enabled ? 1.0 : 0.4
+
+ Row {
+ id: row
+ anchors.centerIn: parent
+ spacing: 8
+ QbzIcon {
+ visible: iconName !== ""
+ name: iconName
+ width: 15
+ height: 15
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: danger ? "favorite" : "secondary"
+ }
+ Text {
+ visible: text !== ""
+ text: parent.parent.text
+ color: parent.parent.danger ? theme.danger : theme.textPrimary
+ font.pixelSize: theme.fontBody
+ font.weight: theme.weightMedium
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+ MouseArea {
+ id: btnArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: if (parent.enabled) parent.clicked()
+ }
+}
diff --git a/crates/qbz-qt/qml/SettingsDivider.qml b/crates/qbz-qt/qml/SettingsDivider.qml
new file mode 100644
index 000000000..8063c0d01
--- /dev/null
+++ b/crates/qbz-qt/qml/SettingsDivider.qml
@@ -0,0 +1,14 @@
+// Settings 1px divider (settings panel group separator) — extracted from
+// SettingsView.qml in phase 19 (named SettingsDivider so it never shadows
+// QtQuick.Controls.Divider in the shared qml directory).
+
+import QtQuick
+import com.blitzfc.qbz
+
+Rectangle {
+ QbzTheme { id: theme }
+
+ width: parent ? parent.width : 0
+ height: 1
+ color: theme.borderSubtle
+}
diff --git a/crates/qbz-qt/qml/SettingsSpacer.qml b/crates/qbz-qt/qml/SettingsSpacer.qml
new file mode 100644
index 000000000..a6d8e332d
--- /dev/null
+++ b/crates/qbz-qt/qml/SettingsSpacer.qml
@@ -0,0 +1,5 @@
+// Settings 12px group spacer — extracted from SettingsView.qml in phase 19.
+
+import QtQuick
+
+Item { width: 1; height: 12 }
diff --git a/crates/qbz-qt/qml/SettingsView.qml b/crates/qbz-qt/qml/SettingsView.qml
index 4cf680531..f49a8fb62 100644
--- a/crates/qbz-qt/qml/SettingsView.qml
+++ b/crates/qbz-qt/qml/SettingsView.qml
@@ -10,9 +10,10 @@
// "single source of truth in SettingsState" pattern).
//
// POC-NOTEs (deliberate cuts, named for the effort report):
-// - Sub-nav: only Audio + Playback exist (sections 0/1). Appearance,
-// Offline, Local Library, Blacklist, Integrations, Developer, Flatpak/
-// Snap and the "Share logs" entry have no backing glue in the POC.
+// - Sections: Audio / Playback / Appearance / Integrations render live
+// (phase 19); Offline / Local Library / Blacklist / Developer render an
+// honest placeholder page with the exact section header; the conditional
+// Flatpak/Snap entry and the "Share logs" LogViewer are not ported.
// - The NavButtons row inside the 92px header is a 0px placeholder (nav
// history lives in the global HeaderBar here, like every other view).
// - Audio > "Detected device limit" read-only row: skipped — the #638
@@ -33,7 +34,8 @@ Item {
// The parsed settingsJson document ({}) until the first publish lands).
property var doc: ({})
- // Active sub-section: 0 = Audio, 1 = Playback (SettingsState.section).
+ // Active sub-section (display order): 0 Audio, 1 Playback, 2 Appearance,
+ // 3 Offline, 4 Local Library, 5 Blacklist, 6 Integrations, 7 Developer.
property int section: 0
function reload() {
@@ -49,496 +51,6 @@ Item {
function onSettingsJsonChanged() { root.reload() }
}
- // --- QbzToggle (primitives/QbzToggle.slint) ---------------------------
- // 40x22 pill r11, 16px knob, accent when on, opacity .4 disabled,
- // 120ms ease-out knob travel. Emits toggled(newValue); never self-flips.
- component QbzToggle: Rectangle {
- property bool checked: false
- property bool enabled: true
- signal toggled(bool value)
-
- width: 40
- height: 22
- radius: 11
- color: checked ? theme.accent : theme.surfaceElevated
- opacity: enabled ? 1.0 : 0.4
-
- Rectangle {
- width: 16
- height: 16
- radius: 8
- color: theme.textPrimary
- y: 3
- x: parent.checked ? parent.width - width - 3 : 3
- Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutQuad } }
- }
- MouseArea {
- anchors.fill: parent
- cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: if (parent.enabled) parent.toggled(!parent.checked)
- }
- }
-
- // --- QbzSelect (primitives/QbzSelect.slint, standard size) ------------
- // 34px bordered control (elevated, hover -> surface-hover) + a Popup
- // list (surface-main r8 border-muted, 32px rows, capped at 360px with
- // scroll, optional 42px filter box, 24px group-header rows, "BP" badge
- // / speaker glyph on device options). `options` entries are either
- // plain strings or {label, bp, group} objects (the device list).
- component QbzSelect: Rectangle {
- property var options: []
- property int currentIndex: 0
- property int menuWidth: 240
- property int popupWidth: 0
- property bool enabled: true
- property bool searchable: false
- signal selected(int index)
-
- id: selectRoot
- width: menuWidth
- height: 34
- radius: theme.radiusSm
- border.width: 1
- border.color: theme.borderSubtle
- color: selArea.containsMouse && enabled ? theme.surfaceHover : theme.surfaceElevated
- opacity: enabled ? 1.0 : 0.4
-
- readonly property int listWidth: Math.max(popupWidth, menuWidth)
- readonly property int rowHeight: 32
- readonly property int headerHeight: 24
- readonly property int searchHeight: searchable ? 42 : 0
- readonly property int maxListHeight: 360
- property string filter: ""
-
- function optLabel(i) {
- const o = options[i]
- return (typeof o === "string") ? o : (o && o.label !== undefined ? o.label : "")
- }
- function optHasBadges() {
- return options.length > 0 && typeof options[0] !== "string"
- }
- function optBp(i) {
- const o = options[i]
- return o && o.bp === true
- }
- function optGroup(i) {
- const o = options[i]
- return (o && o.group !== undefined) ? o.group : ""
- }
-
- Row {
- anchors.fill: parent
- anchors.leftMargin: 12
- anchors.rightMargin: 10
- spacing: 8
- Text {
- width: parent.width - 16 - 8 - badgeSlot.width - (badgeSlot.visible ? 8 : 0)
- height: parent.height
- text: selectRoot.currentIndex >= 0 && selectRoot.currentIndex < selectRoot.options.length
- ? selectRoot.optLabel(selectRoot.currentIndex) : ""
- color: theme.textPrimary
- font.pixelSize: theme.fontBody
- verticalAlignment: Text.AlignVCenter
- elide: Text.ElideRight
- }
- // Trailing BP badge / speaker glyph for the CURRENT device option.
- Item {
- id: badgeSlot
- visible: selectRoot.optHasBadges()
- width: visible ? 20 : 0
- height: parent.height
- Text {
- visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length
- && selectRoot.optBp(selectRoot.currentIndex)
- anchors.centerIn: parent
- text: "BP"
- color: theme.accent
- font.pixelSize: theme.fontLegal
- font.weight: theme.weightSemibold
- font.letterSpacing: 0.5
- }
- QbzIcon {
- visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length
- && !selectRoot.optBp(selectRoot.currentIndex)
- name: "volume-2"
- width: 14
- height: 14
- anchors.centerIn: parent
- tintName: "muted"
- }
- }
- QbzIcon {
- name: "chevron-down"
- width: 16
- height: 16
- anchors.verticalCenter: parent.verticalCenter
- tintName: "muted"
- }
- }
- MouseArea {
- id: selArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: selectRoot.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: {
- if (selectRoot.enabled) {
- selectRoot.filter = ""
- popup.open()
- }
- }
- }
-
- Popup {
- id: popup
- parent: selectRoot
- // Right-anchored: a list wider than the control grows leftward.
- x: selectRoot.width - selectRoot.listWidth
- y: selectRoot.height + 4
- width: selectRoot.listWidth
- height: selectRoot.searchHeight + Math.min(listContent.contentHeight, selectRoot.maxListHeight) + 10
- padding: 0
- closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
-
- background: Rectangle {
- color: theme.surfaceMain
- radius: theme.radiusSm
- border.width: 1
- border.color: theme.borderMuted
- }
- contentItem: Item {
- implicitWidth: selectRoot.listWidth
- implicitHeight: popup.height
-
- // Filter box (searchable lists only).
- Rectangle {
- id: searchBox
- visible: selectRoot.searchable
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.top: parent.top
- height: selectRoot.searchHeight
- color: "transparent"
- Row {
- anchors.fill: parent
- anchors.leftMargin: 12
- anchors.rightMargin: 12
- anchors.bottomMargin: 6
- spacing: 8
- QbzIcon {
- name: "search"
- width: 14
- height: 14
- anchors.verticalCenter: parent.verticalCenter
- tintName: "muted"
- }
- Item {
- width: parent.width - 14 - 8
- height: parent.height
- TextInput {
- id: searchInput
- anchors.fill: parent
- color: theme.textPrimary
- font.pixelSize: theme.fontBody
- verticalAlignment: Text.AlignVCenter
- clip: true
- text: selectRoot.filter
- onTextChanged: selectRoot.filter = text
- }
- Text {
- visible: searchInput.text === ""
- anchors.fill: parent
- text: QbzBridge.tr("Search…")
- color: theme.textMuted
- font.pixelSize: theme.fontBody
- verticalAlignment: Text.AlignVCenter
- }
- }
- }
- }
-
- ListView {
- id: listContent
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.top: searchBox.bottom
- anchors.bottom: parent.bottom
- anchors.topMargin: 5
- anchors.bottomMargin: 5
- clip: true
- boundsBehavior: Flickable.StopAtBounds
- model: selectRoot.options
-
- delegate: Column {
- id: optRow
- required property int index
- required property var modelData
- width: listContent.width
-
- readonly property string label: selectRoot.optLabel(index)
- readonly property bool shown: selectRoot.filter === ""
- || label.toLowerCase().indexOf(selectRoot.filter.toLowerCase()) >= 0
-
- // Group-header row (ALSA device sections).
- Rectangle {
- visible: optRow.shown && selectRoot.optGroup(optRow.index) !== ""
- width: parent.width
- height: visible ? selectRoot.headerHeight : 0
- color: "transparent"
- Text {
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.leftMargin: 12
- anchors.rightMargin: 12
- anchors.top: parent.top
- anchors.topMargin: 4
- height: parent.height - 4
- text: selectRoot.optGroup(optRow.index)
- color: theme.textMuted
- font.pixelSize: theme.fontLegal
- font.weight: theme.weightSemibold
- font.letterSpacing: 0.5
- verticalAlignment: Text.AlignVCenter
- elide: Text.ElideRight
- }
- }
- Rectangle {
- visible: optRow.shown
- width: parent.width
- height: visible ? selectRoot.rowHeight : 0
- color: optArea.containsMouse ? theme.surfaceHover : "transparent"
- Row {
- anchors.fill: parent
- anchors.leftMargin: 12
- anchors.rightMargin: 12
- spacing: 8
- Text {
- width: parent.width - rowBadge.width - (rowBadge.visible ? 8 : 0)
- height: parent.height
- text: optRow.label
- color: optRow.index === selectRoot.currentIndex
- ? theme.accent : theme.textSecondary
- font.pixelSize: theme.fontBody
- verticalAlignment: Text.AlignVCenter
- elide: Text.ElideRight
- }
- Item {
- id: rowBadge
- visible: selectRoot.optHasBadges()
- width: visible ? 20 : 0
- height: parent.height
- Text {
- visible: selectRoot.optBp(optRow.index)
- anchors.centerIn: parent
- text: "BP"
- color: theme.accent
- font.pixelSize: theme.fontLegal
- font.weight: theme.weightSemibold
- font.letterSpacing: 0.5
- }
- QbzIcon {
- visible: !selectRoot.optBp(optRow.index)
- name: "volume-2"
- width: 14
- height: 14
- anchors.centerIn: parent
- tintName: "muted"
- }
- }
- }
- MouseArea {
- id: optArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: {
- popup.close()
- selectRoot.selected(optRow.index)
- }
- }
- }
- }
- }
- }
- }
- }
-
- // --- SettingRow (settings/SettingRow.slint) ---------------------------
- // 52px (64 with a description); label 15 medium + description 12 muted
- // on the left (opacity .45 when disabled), the control flush right.
- component SettingRow: Item {
- property string label: ""
- property string description: ""
- property bool rowEnabled: true
- default property alias control: controlHost.data
-
- width: parent ? parent.width : 0
- height: description === "" ? 52 : 64
-
- Column {
- anchors.left: parent.left
- anchors.right: controlHost.left
- anchors.rightMargin: 24
- anchors.verticalCenter: parent.verticalCenter
- spacing: 3
- opacity: rowEnabled ? 1.0 : 0.45
- Text {
- width: parent.width
- text: label
- color: theme.textPrimary
- font.pixelSize: theme.fontBody
- font.weight: theme.weightMedium
- elide: Text.ElideRight
- }
- Text {
- visible: description !== ""
- width: parent.width
- text: description
- color: theme.textMuted
- font.pixelSize: 12
- wrapMode: Text.WordWrap
- }
- }
- Item {
- id: controlHost
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- width: childrenRect.width
- height: childrenRect.height
- }
- }
-
- // --- GroupHeader / Divider --------------------------------------------
- component GroupHeader: Text {
- color: theme.textMuted
- font.pixelSize: 11
- font.letterSpacing: 1.5
- font.weight: theme.weightSemibold
- }
- component Divider: Rectangle {
- width: parent ? parent.width : 0
- height: 1
- color: theme.borderSubtle
- }
- component Spacer12: Item { width: 1; height: 12 }
-
- // --- QbzSlider (primitives/QbzSlider.slint) ---------------------------
- // 200x22, 4px r2 track, accent fill, 16px thumb; integer steps. Like the
- // Slint original the thumb follows the pointer during a drag (local
- // dragValue) and commits each step via changed(int).
- component QbzSlider: Item {
- property int minimum: 0
- property int maximum: 10
- property int value: 0
- signal changed(int newValue)
-
- width: 200
- height: 22
-
- readonly property int thumbSize: 16
- readonly property real travel: width - thumbSize
- readonly property real fraction: maximum > minimum
- ? Math.max(0, Math.min(1, (value - minimum) / (maximum - minimum))) : 0
- property bool dragging: false
- property real dragFraction: fraction
- readonly property real shownFraction: dragging ? dragFraction : fraction
- onFractionChanged: if (!dragging) dragFraction = fraction
-
- function commit(frac) {
- const v = Math.round(minimum + Math.max(0, Math.min(1, frac)) * (maximum - minimum))
- if (v !== value) changed(v)
- }
-
- Rectangle { // track
- x: 0
- y: Math.round((parent.height - height) / 2)
- width: parent.width
- height: 4
- radius: 2
- color: theme.surfaceElevated
- }
- Rectangle { // accent fill
- x: 0
- y: Math.round((parent.height - height) / 2)
- width: parent.thumbSize / 2 + parent.shownFraction * parent.travel
- height: 4
- radius: 2
- color: theme.accent
- }
- Rectangle { // thumb
- width: parent.thumbSize
- height: parent.thumbSize
- radius: parent.thumbSize / 2
- x: parent.shownFraction * parent.travel
- anchors.verticalCenter: parent.verticalCenter
- color: theme.textPrimary
- }
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onPressed: {
- parent.dragging = true
- parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel))
- parent.commit(parent.dragFraction)
- }
- onPositionChanged: {
- if (pressed) {
- parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel))
- parent.commit(parent.dragFraction)
- }
- }
- onReleased: parent.dragging = false
- }
- }
-
- // --- QbzLineEdit (std-widgets LineEdit, settings styling) -------------
- // 240px / 34px elevated r8 bordered input; commits on Enter AND on
- // focus loss (the Tauri onchange semantics — PlaybackSettings.slint).
- component QbzLineEdit: Rectangle {
- property string text: ""
- property string placeholder: ""
- signal committed(string value)
-
- width: 240
- height: 34
- radius: theme.radiusSm
- border.width: 1
- border.color: theme.borderSubtle
- color: theme.surfaceElevated
-
- TextInput {
- id: input
- anchors.fill: parent
- anchors.leftMargin: 12
- anchors.rightMargin: 12
- color: theme.textPrimary
- font.pixelSize: theme.fontBody
- verticalAlignment: Text.AlignVCenter
- clip: true
- text: parent.text
- onAccepted: parent.committed(text)
- onActiveFocusChanged: if (!activeFocus) parent.committed(text)
- // External republish (e.g. Reset) re-seeds the field while it is
- // not being edited.
- Binding {
- target: input
- property: "text"
- value: input.parent.text
- when: !input.activeFocus
- }
- }
- Text {
- visible: input.text === ""
- anchors.fill: parent
- anchors.leftMargin: 12
- anchors.rightMargin: 12
- text: placeholder
- color: theme.textMuted
- font.pixelSize: theme.fontBody
- verticalAlignment: Text.AlignVCenter
- elide: Text.ElideRight
- }
- }
-
// ============================ the view ================================
Column {
anchors.fill: parent
@@ -629,10 +141,57 @@ Item {
active: root.section === 1
onClicked: root.section = 1
}
- // POC-NOTE: Appearance / Offline / Local Library /
- // Blacklist / Integrations / Developer / Flatpak / Snap /
- // "Share logs" sub-nav entries are omitted (no backing
- // glue in the POC).
+ SubNavItem {
+ name: "layers"
+ label: QbzBridge.tr("Appearance")
+ active: root.section === 2
+ onClicked: root.section = 2
+ }
+ SubNavItem {
+ name: "cloud-download"
+ label: QbzBridge.tr("Offline")
+ active: root.section === 3
+ onClicked: root.section = 3
+ }
+ SubNavItem {
+ name: "hard-drive"
+ label: QbzBridge.tr("Local Library")
+ active: root.section === 4
+ onClicked: root.section = 4
+ }
+ SubNavItem {
+ name: "blind-eye"
+ label: QbzBridge.tr("Blacklist")
+ active: root.section === 5
+ onClicked: root.section = 5
+ }
+ SubNavItem {
+ name: "refresh-cw"
+ label: QbzBridge.tr("Integrations")
+ active: root.section === 6
+ onClicked: root.section = 6
+ }
+ SubNavItem {
+ name: "bug"
+ label: QbzBridge.tr("Developer")
+ active: root.section === 7
+ onClicked: root.section = 7
+ }
+ // POC-NOTE: the conditional Flatpak/Snap sub-nav entry
+ // (SandboxState.install-method) is omitted — the POC is
+ // not a sandboxed install.
+
+ // "Share logs" footer (SettingsView.slint:202-215),
+ // pinned to the subnav bottom.
+ Item { width: 1; height: parent.height - 8 * 42 - 38 - 4 }
+ SubNavItem {
+ name: "cloud-upload"
+ label: QbzBridge.tr("Share logs")
+ active: false
+ // POC-NOTE: LogViewerState (copy + upload viewer) is
+ // not ported — the row is inert.
+ onClicked: { }
+ }
}
}
@@ -687,9 +246,9 @@ Item {
// skipped — the device-cap probe glue is not
// ported.
- Spacer12 { }
- Divider { }
- Spacer12 { }
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
GroupHeader { text: QbzBridge.tr("OUTPUT") }
SettingRow {
@@ -779,9 +338,9 @@ Item {
}
}
- Spacer12 { }
- Divider { }
- Spacer12 { }
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
GroupHeader { text: QbzBridge.tr("BIT-PERFECT") }
SettingRow {
@@ -834,9 +393,9 @@ Item {
// guided DAC setup) is skipped — DacWizardActions
// is not ported.
- Spacer12 { }
- Divider { }
- Spacer12 { }
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
GroupHeader { text: QbzBridge.tr("STARTUP") }
SettingRow {
@@ -922,9 +481,9 @@ Item {
}
}
- Spacer12 { }
- Divider { }
- Spacer12 { }
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
GroupHeader { text: QbzBridge.tr("SESSION") }
SettingRow {
@@ -965,9 +524,9 @@ Item {
}
}
- Spacer12 { }
- Divider { }
- Spacer12 { }
+ SettingsSpacer { }
+ SettingsDivider { }
+ SettingsSpacer { }
GroupHeader { text: QbzBridge.tr("STREAMING") }
SettingRow {
@@ -1021,6 +580,45 @@ Item {
}
}
}
+
+ // ================== APPEARANCE (phase 19) ==========
+ AppearanceSettings {
+ visible: root.section === 2
+ width: parent.width
+ doc: root.doc
+ }
+
+ // ================== INTEGRATIONS (phase 19) ========
+ IntegrationsSettings {
+ visible: root.section === 6
+ width: parent.width
+ doc: root.doc
+ }
+
+ // ============ placeholder sections (phase 19) ======
+ // Offline (3) / Local Library (4) / Blacklist (5) /
+ // Developer (7): the subnav rows render the EXACT
+ // section header; the panels land in a later phase
+ // (POC-NOTE).
+ Column {
+ visible: root.section === 3 || root.section === 4
+ || root.section === 5 || root.section === 7
+ width: parent.width
+ spacing: 4
+ GroupHeader {
+ text: root.section === 3 ? QbzBridge.tr("OFFLINE")
+ : root.section === 4 ? QbzBridge.tr("LOCAL LIBRARY")
+ : root.section === 5 ? QbzBridge.tr("BLACKLIST")
+ : QbzBridge.tr("DEVELOPER")
+ }
+ Text {
+ width: parent.width
+ text: QbzBridge.tr("This section is not ported to the Qt frontend yet.")
+ color: theme.textMuted
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+ }
}
}
diff --git a/crates/qbz-qt/qml/assets/icons/accent/bug.svg b/crates/qbz-qt/qml/assets/icons/accent/bug.svg
new file mode 100644
index 000000000..569ac2bf5
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/bug.svg
@@ -0,0 +1,13 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/accent/cloud-upload.svg
new file mode 100644
index 000000000..8d78b0dc6
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/cloud-upload.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/layers.svg b/crates/qbz-qt/qml/assets/icons/accent/layers.svg
new file mode 100644
index 000000000..23099081e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/layers.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/moon.svg b/crates/qbz-qt/qml/assets/icons/accent/moon.svg
new file mode 100644
index 000000000..8a01e822a
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/moon.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/accent/rotate-ccw.svg
new file mode 100644
index 000000000..397678549
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/rotate-ccw.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/accent/sun-moon.svg
new file mode 100644
index 000000000..73781c42f
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/sun-moon.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/sun.svg b/crates/qbz-qt/qml/assets/icons/accent/sun.svg
new file mode 100644
index 000000000..cfc614b37
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/sun.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/bug.svg b/crates/qbz-qt/qml/assets/icons/black/bug.svg
new file mode 100644
index 000000000..c94d68413
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/bug.svg
@@ -0,0 +1,13 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/black/cloud-upload.svg
new file mode 100644
index 000000000..8d4488a68
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/cloud-upload.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/layers.svg b/crates/qbz-qt/qml/assets/icons/black/layers.svg
new file mode 100644
index 000000000..154ca2d7f
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/layers.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/moon.svg b/crates/qbz-qt/qml/assets/icons/black/moon.svg
new file mode 100644
index 000000000..bab1e0aa6
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/moon.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/black/rotate-ccw.svg
new file mode 100644
index 000000000..9497e0a4f
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/rotate-ccw.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/black/sun-moon.svg
new file mode 100644
index 000000000..dbf7ac64b
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/sun-moon.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/sun.svg b/crates/qbz-qt/qml/assets/icons/black/sun.svg
new file mode 100644
index 000000000..9c25a8a62
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/sun.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/bug.svg b/crates/qbz-qt/qml/assets/icons/favorite/bug.svg
new file mode 100644
index 000000000..d9e30b854
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/bug.svg
@@ -0,0 +1,13 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/favorite/cloud-upload.svg
new file mode 100644
index 000000000..4c117fd2b
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/cloud-upload.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/layers.svg b/crates/qbz-qt/qml/assets/icons/favorite/layers.svg
new file mode 100644
index 000000000..6cc9846d3
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/layers.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/moon.svg b/crates/qbz-qt/qml/assets/icons/favorite/moon.svg
new file mode 100644
index 000000000..fdf796e56
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/moon.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/favorite/rotate-ccw.svg
new file mode 100644
index 000000000..d971b4a28
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/rotate-ccw.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/favorite/sun-moon.svg
new file mode 100644
index 000000000..45afea114
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/sun-moon.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/sun.svg b/crates/qbz-qt/qml/assets/icons/favorite/sun.svg
new file mode 100644
index 000000000..66f99ac0c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/sun.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/bug.svg b/crates/qbz-qt/qml/assets/icons/muted/bug.svg
new file mode 100644
index 000000000..fa20f8ac6
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/bug.svg
@@ -0,0 +1,13 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/muted/cloud-upload.svg
new file mode 100644
index 000000000..af1b9c416
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/cloud-upload.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/layers.svg b/crates/qbz-qt/qml/assets/icons/muted/layers.svg
new file mode 100644
index 000000000..68c853b50
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/layers.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/moon.svg b/crates/qbz-qt/qml/assets/icons/muted/moon.svg
new file mode 100644
index 000000000..ff1abdecd
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/moon.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/muted/rotate-ccw.svg
new file mode 100644
index 000000000..7ef25ddaf
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/rotate-ccw.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/muted/sun-moon.svg
new file mode 100644
index 000000000..731f18a34
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/sun-moon.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/sun.svg b/crates/qbz-qt/qml/assets/icons/muted/sun.svg
new file mode 100644
index 000000000..fcfe13407
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/sun.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/bug.svg b/crates/qbz-qt/qml/assets/icons/primary/bug.svg
new file mode 100644
index 000000000..db2b46047
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/bug.svg
@@ -0,0 +1,13 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/primary/cloud-upload.svg
new file mode 100644
index 000000000..f8bf93a12
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/cloud-upload.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/layers.svg b/crates/qbz-qt/qml/assets/icons/primary/layers.svg
new file mode 100644
index 000000000..9fffdaf51
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/layers.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/moon.svg b/crates/qbz-qt/qml/assets/icons/primary/moon.svg
new file mode 100644
index 000000000..86d6c007a
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/moon.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/primary/rotate-ccw.svg
new file mode 100644
index 000000000..17530e155
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/rotate-ccw.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/primary/sun-moon.svg
new file mode 100644
index 000000000..2321024e1
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/sun-moon.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/sun.svg b/crates/qbz-qt/qml/assets/icons/primary/sun.svg
new file mode 100644
index 000000000..02ccd7310
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/sun.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/bug.svg b/crates/qbz-qt/qml/assets/icons/secondary/bug.svg
new file mode 100644
index 000000000..113399614
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/bug.svg
@@ -0,0 +1,13 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/secondary/cloud-upload.svg
new file mode 100644
index 000000000..a68510866
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/cloud-upload.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/layers.svg b/crates/qbz-qt/qml/assets/icons/secondary/layers.svg
new file mode 100644
index 000000000..2955a1f69
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/layers.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/moon.svg b/crates/qbz-qt/qml/assets/icons/secondary/moon.svg
new file mode 100644
index 000000000..6b065abfb
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/moon.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/secondary/rotate-ccw.svg
new file mode 100644
index 000000000..9a3ae82d5
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/rotate-ccw.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/secondary/sun-moon.svg
new file mode 100644
index 000000000..7f9c580a2
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/sun-moon.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/sun.svg b/crates/qbz-qt/qml/assets/icons/secondary/sun.svg
new file mode 100644
index 000000000..7b61b1604
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/sun.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/bug.svg b/crates/qbz-qt/qml/assets/icons/warning/bug.svg
new file mode 100644
index 000000000..2fe8bccd6
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/bug.svg
@@ -0,0 +1,13 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/warning/cloud-upload.svg
new file mode 100644
index 000000000..0d040afe5
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/cloud-upload.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/layers.svg b/crates/qbz-qt/qml/assets/icons/warning/layers.svg
new file mode 100644
index 000000000..ca364e992
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/layers.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/moon.svg b/crates/qbz-qt/qml/assets/icons/warning/moon.svg
new file mode 100644
index 000000000..dd26ba6b2
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/moon.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/warning/rotate-ccw.svg
new file mode 100644
index 000000000..c01456ecd
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/rotate-ccw.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/warning/sun-moon.svg
new file mode 100644
index 000000000..b296f8dd2
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/sun-moon.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/sun.svg b/crates/qbz-qt/qml/assets/icons/warning/sun.svg
new file mode 100644
index 000000000..eb6074103
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/sun.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs
index 21dae2c41..2511bf306 100644
--- a/crates/qbz-qt/src/bridge.rs
+++ b/crates/qbz-qt/src/bridge.rs
@@ -136,6 +136,19 @@ pub mod qbz_bridge {
// SongCard layers icon.
#[qproperty(bool, show_context_icon)]
+ // --- Theme (phase 19) --------------------------------------------
+ // ONE JSON token document (theme_qt.rs ThemeTokens: 30 colors + 24
+ // alpha tiers + ambient derivations + isDark) — QbzTheme.qml binds
+ // to it, so a theme switch repaints the whole app live (the Slint
+ // theme::push_colors equivalent).
+ #[qproperty(QString, theme_json)]
+ // The persisted ui_prefs theme slug ("oled", "auto", "custom", ...).
+ #[qproperty(QString, theme_slug)]
+ // The dropdown catalog (36 registry themes + Auto/Custom rows).
+ #[qproperty(QString, theme_list_json)]
+ // Dropdown filter: 0 All / 1 Dark / 2 Light (ui_prefs theme_filter).
+ #[qproperty(i32, theme_filter)]
+
// --- Lyrics panel (phase 9) ----------------------------------------
#[qproperty(bool, lyrics_open)]
// One JSON document (lyrics_qt.rs LyricsDoc: status/lines/synced/
@@ -453,6 +466,21 @@ pub mod qbz_bridge {
#[qinvokable]
fn refresh_devices(self: Pin<&mut QbzBridge>);
+ // --- Theme (phase 19) ---------------------------------------------
+ /// Appearance > Theme row: persist the picked slug + republish
+ /// `themeJson` (live switch).
+ #[qinvokable]
+ fn theme_set(self: Pin<&mut QbzBridge>, slug: QString);
+ /// Appearance > theme filter cycle button (0 All / 1 Dark / 2 Light).
+ #[qinvokable]
+ fn theme_set_filter(self: Pin<&mut QbzBridge>, index: i32);
+
+ // --- Integrations (phase 19) ---------------------------------------
+ /// Non-toggle integration actions (integrations_qt.rs): Last.fm
+ /// connect/open-auth-url/finish/disconnect, ListenBrainz disconnect.
+ #[qinvokable]
+ fn integrations_action(self: Pin<&mut QbzBridge>, action: QString);
+
/// App-menu chrome toggle: flip the persisted `use_system_title_bar`
/// pref (applies on the next launch — the window flags are fixed at
/// creation, 1:1 Slint). Updates `systemTitleBarPref` only.
@@ -631,6 +659,10 @@ pub struct QbzBridgeRust {
np_artist_id: QString,
npb_mode: i32,
show_context_icon: bool,
+ theme_json: QString,
+ theme_slug: QString,
+ theme_list_json: QString,
+ theme_filter: i32,
queue_json: QString,
lyrics_open: bool,
lyrics_json: QString,
@@ -723,6 +755,10 @@ impl Default for QbzBridgeRust {
np_artist_id: QString::default(),
npb_mode: crate::settings_qt::npb_mode_index(),
show_context_icon: crate::settings_qt::show_context_icon(),
+ theme_json: QString::from(crate::theme_qt::theme_json().as_str()),
+ theme_slug: QString::from(crate::theme_qt::current_slug().as_str()),
+ theme_list_json: QString::from(crate::theme_qt::theme_list_json().as_str()),
+ theme_filter: crate::theme_qt::theme_filter(),
queue_json: QString::from("{}"),
lyrics_open: false,
lyrics_json: QString::from("{}"),
@@ -1011,6 +1047,18 @@ impl qbz_bridge::QbzBridge {
crate::npb_set_mode(mode);
}
+ pub fn theme_set(self: Pin<&mut Self>, slug: QString) {
+ crate::theme_set(slug.to_string());
+ }
+
+ pub fn theme_set_filter(self: Pin<&mut Self>, index: i32) {
+ crate::theme_set_filter(index);
+ }
+
+ pub fn integrations_action(self: Pin<&mut Self>, action: QString) {
+ crate::integrations_action(action.to_string());
+ }
+
pub fn settings_bool(self: Pin<&mut Self>, key: QString, value: bool) {
crate::settings_bool(key.to_string(), value);
}
diff --git a/crates/qbz-qt/src/integrations_qt.rs b/crates/qbz-qt/src/integrations_qt.rs
new file mode 100644
index 000000000..19694df24
--- /dev/null
+++ b/crates/qbz-qt/src/integrations_qt.rs
@@ -0,0 +1,275 @@
+//! Integrations settings controller (phase 19) — the Slint
+//! `crates/qbz/src/scrobble.rs` + `discord_rpc.rs` + the settings.rs
+//! integration rows, ported onto the SAME stores:
+//! `scrobbler_settings.db` (`qbz_app::settings::scrobblers`, per-user),
+//! `discover_prefs.db` (`qbz_app::settings::discover_prefs`, per-user) and
+//! the shared `ui_prefs.json` (`musicbrainz_enabled`, `discord_rpc_enabled`).
+//!
+//! POC-NOTEs:
+//! - No live scrobbling: `scrobble::start` / `on_track_changed` (the
+//! now-playing/scrobble firing + offline queues) is NOT ported — the
+//! toggles + credentials persist to the SAME DB the Slint scrobbler
+//! reads, so they take effect in the Slint app / a future port.
+//! - Discord: the enable pref persists AND flips the live `DiscordRpc`
+//! flag, but presence updates (`DiscordRpc::update` on track change)
+//! are not wired (no rich-presence feed in the POC).
+//! - ListenBrainz disconnect does NOT clear the shared
+//! `cache/listenbrainz_v2.db` credentials row (scrobble.rs:334-342 —
+//! that cache feeds the scrobbler runtime, which the POC does not run).
+
+use std::sync::{Arc, Mutex, OnceLock};
+
+use qbz_app::settings::discover_prefs::DiscoverPrefsStore;
+use qbz_app::settings::scrobblers::{ScrobblerSettings, ScrobblerSettingsState};
+use qbz_app::shell::AppRuntime;
+use qbz_core::LoggingAdapter;
+use qbz_integrations::discord::DiscordRpc;
+use qbz_integrations::lastfm::LastFmClient;
+use qbz_integrations::listenbrainz::ListenBrainzClient;
+
+use crate::settings_qt::{pref_bool, save_pref};
+
+// ---------------------------------------------------------------------------
+// Stores (SAME files as the Slint app)
+// ---------------------------------------------------------------------------
+
+static SCROBBLE: OnceLock = OnceLock::new();
+
+fn scrobble() -> &'static ScrobblerSettingsState {
+ SCROBBLE.get_or_init(|| {
+ let state = ScrobblerSettingsState::new_empty();
+ if let Some(dir) = crate::sidebar_qt::user_dir() {
+ if let Err(e) = state.init_at(&dir) {
+ log::warn!("[qbz-qt] scrobbler settings store unavailable: {e}");
+ }
+ }
+ state
+ })
+}
+
+static DISCOVER: OnceLock>> = OnceLock::new();
+
+fn with_discover(f: impl FnOnce(&DiscoverPrefsStore) -> T) -> Option {
+ let cell = DISCOVER.get_or_init(|| {
+ let store = crate::sidebar_qt::user_dir()
+ .and_then(|dir| DiscoverPrefsStore::new_at(&dir).ok());
+ if store.is_none() {
+ log::warn!("[qbz-qt] discover prefs store unavailable");
+ }
+ Mutex::new(store)
+ });
+ let guard = cell.lock().ok()?;
+ guard.as_ref().map(f)
+}
+
+static DISCORD: OnceLock = OnceLock::new();
+
+fn discord() -> &'static DiscordRpc {
+ DISCORD.get_or_init(DiscordRpc::new)
+}
+
+// ---------------------------------------------------------------------------
+// Transient UI state (ScrobbleState busy/auth-url/status + the Last.fm
+// pending request token — process memory only, like LASTFM_PENDING_TOKEN).
+// ---------------------------------------------------------------------------
+
+#[derive(Default)]
+pub struct IntegrationUi {
+ pub lastfm_busy: bool,
+ pub listenbrainz_busy: bool,
+ pub lastfm_auth_url: String,
+ pub status_text: String,
+ /// 0 none / 1 info / 2 ok / 3 error (ScrobbleState.status-kind).
+ pub status_kind: i32,
+ pending_lastfm_token: String,
+}
+
+static UI: OnceLock> = OnceLock::new();
+
+fn ui_state() -> &'static Mutex {
+ UI.get_or_init(|| Mutex::new(IntegrationUi::default()))
+}
+
+pub fn ui_snapshot() -> IntegrationUi {
+ let g = ui_state().lock().unwrap();
+ IntegrationUi {
+ lastfm_busy: g.lastfm_busy,
+ listenbrainz_busy: g.listenbrainz_busy,
+ lastfm_auth_url: g.lastfm_auth_url.clone(),
+ status_text: g.status_text.clone(),
+ status_kind: g.status_kind,
+ pending_lastfm_token: String::new(),
+ }
+}
+
+fn set_status(text: &str, kind: i32) {
+ let mut g = ui_state().lock().unwrap();
+ g.status_text = text.to_string();
+ g.status_kind = kind;
+}
+
+// ---------------------------------------------------------------------------
+// Snapshot fields (folded into SettingsDoc by settings_qt::publish_snapshot)
+// ---------------------------------------------------------------------------
+
+pub fn scrobble_settings() -> ScrobblerSettings {
+ scrobble().get_settings().unwrap_or_default()
+}
+
+pub fn show_recommendations() -> bool {
+ with_discover(|s| s.load().show_recommendations).unwrap_or(true)
+}
+
+pub fn discord_enabled() -> bool {
+ pref_bool("discord_rpc_enabled", false)
+}
+
+// ---------------------------------------------------------------------------
+// Toggle handlers (settings_qt.rs bool arms delegate here)
+// ---------------------------------------------------------------------------
+
+pub fn set_show_recommendations(value: bool) -> Result<(), String> {
+ with_discover(|s| {
+ let mut prefs = s.load();
+ prefs.show_recommendations = value;
+ s.save(&prefs)
+ })
+ .unwrap_or_else(|| Err("discover prefs store not open".to_string()))
+}
+
+pub fn set_scrobble_enabled(value: bool) -> Result<(), String> {
+ scrobble().set_enabled(value)
+}
+
+pub fn set_scrobble_collapsed(value: bool) -> Result<(), String> {
+ scrobble().set_ui_collapsed(value)
+}
+
+pub fn set_lastfm_enabled(value: bool) -> Result<(), String> {
+ scrobble().set_lastfm_enabled(value)
+}
+
+pub fn set_listenbrainz_enabled(value: bool) -> Result<(), String> {
+ scrobble().set_listenbrainz_enabled(value)
+}
+
+pub fn set_discord_enabled(value: bool) -> Result<(), String> {
+ save_pref("discord_rpc_enabled", serde_json::json!(value));
+ discord().set_enabled(value);
+ log::info!("[qbz-qt] discord_rpc_enabled -> {value}");
+ Ok(())
+}
+
+pub fn set_musicbrainz_enabled(value: bool) -> Result<(), String> {
+ save_pref("musicbrainz_enabled", serde_json::json!(value));
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Connection flows (ScrobbleActions)
+// ---------------------------------------------------------------------------
+
+/// ListenBrainz paste-token flow (scrobble.rs:315): validate against
+/// /validate-token, persist token + username, force-enable on success.
+pub async fn listenbrainz_set_token(token: &str) {
+ let token = token.trim().to_string();
+ if token.is_empty() {
+ return;
+ }
+ ui_state().lock().unwrap().listenbrainz_busy = true;
+ crate::settings_qt::publish_snapshot().await;
+
+ let client = ListenBrainzClient::new();
+ match client.set_token(&token).await {
+ Ok(info) => {
+ if let Err(e) = scrobble().set_listenbrainz_token(&token, &info.user_name) {
+ log::error!("[qbz-qt] persist listenbrainz token failed: {e}");
+ }
+ // First-connect force-enable (scrobble.rs).
+ let _ = scrobble().set_listenbrainz_enabled(true);
+ set_status(&format!("Signed in as {}.", info.user_name), 2);
+ }
+ Err(e) => set_status(&format!("ListenBrainz: {e}"), 3),
+ }
+ ui_state().lock().unwrap().listenbrainz_busy = false;
+ crate::settings_qt::publish_snapshot().await;
+}
+
+/// integrations_action dispatch (non-toggle rows).
+pub async fn handle_action(_runtime: &Arc>, action: &str) {
+ match action {
+ // Last.fm two-step browser auth (scrobble.rs:190): request token ->
+ // authorize URL in the browser -> Finish exchanges for a session.
+ "lastfm-connect" => {
+ ui_state().lock().unwrap().lastfm_busy = true;
+ crate::settings_qt::publish_snapshot().await;
+ let client = LastFmClient::new();
+ match client.get_token().await {
+ Ok((token, url)) => {
+ {
+ let mut g = ui_state().lock().unwrap();
+ g.pending_lastfm_token = token;
+ g.lastfm_auth_url = url.clone();
+ }
+ set_status("Authorize QBZ in your browser, then click Finish.", 1);
+ if let Err(e) = open::that(&url) {
+ log::warn!("[qbz-qt] open Last.fm authorize page failed: {e}");
+ }
+ }
+ Err(e) => set_status(&format!("Last.fm: {e}"), 3),
+ }
+ ui_state().lock().unwrap().lastfm_busy = false;
+ crate::settings_qt::publish_snapshot().await;
+ }
+ "lastfm-open-auth-url" => {
+ let url = ui_state().lock().unwrap().lastfm_auth_url.clone();
+ if !url.is_empty() {
+ if let Err(e) = open::that(&url) {
+ log::warn!("[qbz-qt] open Last.fm authorize page failed: {e}");
+ }
+ }
+ }
+ "lastfm-finish" => {
+ let token = ui_state().lock().unwrap().pending_lastfm_token.clone();
+ if token.is_empty() {
+ return;
+ }
+ ui_state().lock().unwrap().lastfm_busy = true;
+ crate::settings_qt::publish_snapshot().await;
+ let mut client = LastFmClient::new();
+ match client.get_session(&token).await {
+ Ok(session) => {
+ if let Err(e) = scrobble().set_lastfm_session(&session.key, &session.name) {
+ log::error!("[qbz-qt] persist lastfm session failed: {e}");
+ }
+ // First-connect force-enable (scrobble.rs).
+ let _ = scrobble().set_lastfm_enabled(true);
+ {
+ let mut g = ui_state().lock().unwrap();
+ g.pending_lastfm_token.clear();
+ g.lastfm_auth_url.clear();
+ }
+ set_status(&format!("Signed in as {}.", session.name), 2);
+ }
+ Err(e) => set_status(&format!("Last.fm: {e}"), 3),
+ }
+ ui_state().lock().unwrap().lastfm_busy = false;
+ crate::settings_qt::publish_snapshot().await;
+ }
+ "lastfm-disconnect" => {
+ if let Err(e) = scrobble().disconnect_lastfm() {
+ log::error!("[qbz-qt] lastfm disconnect failed: {e}");
+ }
+ set_status("", 0);
+ crate::settings_qt::publish_snapshot().await;
+ }
+ "listenbrainz-disconnect" => {
+ if let Err(e) = scrobble().disconnect_listenbrainz() {
+ log::error!("[qbz-qt] listenbrainz disconnect failed: {e}");
+ }
+ set_status("", 0);
+ crate::settings_qt::publish_snapshot().await;
+ }
+ other => log::warn!("[qbz-qt] unknown integrations action: {other}"),
+ }
+}
diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs
index 6b824c176..18882b841 100644
--- a/crates/qbz-qt/src/main.rs
+++ b/crates/qbz-qt/src/main.rs
@@ -25,6 +25,8 @@ mod queue_qt;
mod search_qt;
mod settings_qt;
mod sidebar_qt;
+mod theme_qt;
+mod integrations_qt;
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
@@ -992,6 +994,27 @@ pub(crate) fn refresh_devices() {
spawn(async move { settings_qt::refresh_devices(&runtime).await });
}
+/// Appearance > Theme row: persist the slug + republish the token document
+/// (live switch — QbzTheme.qml rebinds every consumer).
+pub(crate) fn theme_set(slug: String) {
+ theme_qt::set_theme(&slug);
+ theme_qt::publish_theme();
+}
+
+/// Appearance > theme filter cycle (0 All / 1 Dark / 2 Light).
+pub(crate) fn theme_set_filter(index: i32) {
+ let index = index.clamp(0, 2);
+ theme_qt::set_theme_filter(index);
+ ui(move |mut b| b.as_mut().set_theme_filter(index));
+}
+
+/// Integrations panel non-toggle actions (Last.fm connect flow, LB/LFM
+/// disconnects).
+pub(crate) fn integrations_action(action: String) {
+ let runtime = app();
+ spawn(async move { integrations_qt::handle_action(&runtime, &action).await });
+}
+
/// App-menu chrome toggle: persist the flipped pref and update the menu
/// state. The APPLIED mode (`systemTitleBar`) is deliberately untouched —
/// it changes on the next launch (the window flags are read once at
diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs
index e0bc5f6ac..e7bebebfd 100644
--- a/crates/qbz-qt/src/settings_qt.rs
+++ b/crates/qbz-qt/src/settings_qt.rs
@@ -284,6 +284,107 @@ pub fn show_context_icon() -> bool {
with_playback(|s| s.get_preferences().map(|p| p.show_context_icon)).unwrap_or(false)
}
+// ---------------------------------------------------------------------------
+// Appearance + Integrations prefs (phase 19) — generic ui_prefs.json
+// accessors (SAME additive patch discipline) + the per-user stores the
+// Appearance/Integrations panels read (tray, My-QBZ branding).
+// ---------------------------------------------------------------------------
+
+pub fn pref_bool(key: &str, default: bool) -> bool {
+ let Some(path) = prefs_path() else {
+ return default;
+ };
+ std::fs::read_to_string(path)
+ .ok()
+ .and_then(|text| serde_json::from_str::(&text).ok())
+ .and_then(|v| v.get(key).and_then(|q| q.as_bool()))
+ .unwrap_or(default)
+}
+
+pub fn pref_str(key: &str, default: &str) -> String {
+ let Some(path) = prefs_path() else {
+ return default.to_string();
+ };
+ std::fs::read_to_string(path)
+ .ok()
+ .and_then(|text| serde_json::from_str::(&text).ok())
+ .and_then(|v| v.get(key).and_then(|q| q.as_str().map(str::to_string)))
+ .unwrap_or_else(|| default.to_string())
+}
+
+pub fn save_pref(key: &str, value: serde_json::Value) {
+ let Some(path) = prefs_path() else {
+ return;
+ };
+ let mut doc: serde_json::Value = std::fs::read_to_string(&path)
+ .ok()
+ .and_then(|text| serde_json::from_str(&text).ok())
+ .unwrap_or_else(|| serde_json::json!({}));
+ if let Some(obj) = doc.as_object_mut() {
+ obj.insert(key.to_string(), value);
+ if let Ok(text) = serde_json::to_string_pretty(&doc) {
+ let _ = std::fs::write(&path, text);
+ }
+ }
+}
+
+/// The Appearance > app_background index: 0 off / 1 ambient / 2 blurred
+/// (the POC renders blurred AS ambient — ambient_qt.rs POC-NOTE).
+pub fn app_background_index() -> i32 {
+ match pref_str("app_background", "off").as_str() {
+ "ambient" => 1,
+ "blurred" => 2,
+ _ => 0,
+ }
+}
+
+// Tray store (qbz_app::settings::tray — per-user tray_settings.db, the SAME
+// file the Slint tray_settings.rs glue writes).
+static TRAY: OnceLock = OnceLock::new();
+
+fn tray() -> &'static qbz_app::settings::tray::TraySettingsState {
+ TRAY.get_or_init(|| {
+ let state = qbz_app::settings::tray::TraySettingsState::new_empty();
+ if let Some(dir) = crate::sidebar_qt::user_dir() {
+ if let Err(e) = state.init_at(&dir) {
+ log::warn!("[qbz-qt] tray settings store unavailable: {e}");
+ }
+ }
+ state
+ })
+}
+
+// My-QBZ branding (myqbz_prefs.rs — per-user myqbz_branding.json; the label
+// row in LIBRARY & VISUALS).
+fn myqbz_label() -> String {
+ crate::sidebar_qt::user_dir()
+ .map(|d| d.join("myqbz_branding.json"))
+ .and_then(|p| std::fs::read_to_string(p).ok())
+ .and_then(|text| serde_json::from_str::(&text).ok())
+ .and_then(|v| v.get("label").and_then(|q| q.as_str().map(str::to_string)))
+ .filter(|s| !s.trim().is_empty())
+ .unwrap_or_else(|| "My QBZ".to_string())
+}
+
+fn save_myqbz_label(label: &str) {
+ let Some(path) = crate::sidebar_qt::user_dir().map(|d| d.join("myqbz_branding.json")) else {
+ return;
+ };
+ let mut doc: serde_json::Value = std::fs::read_to_string(&path)
+ .ok()
+ .and_then(|text| serde_json::from_str(&text).ok())
+ .unwrap_or_else(|| serde_json::json!({}));
+ if let Some(obj) = doc.as_object_mut() {
+ obj.insert(
+ "label".to_string(),
+ serde_json::Value::String(label.trim().to_string()),
+ );
+ if let Ok(text) = serde_json::to_string_pretty(&doc) {
+ let _ = std::fs::write(&path, text);
+ }
+ }
+}
+
pub const STREAMING_QUALITY_KEYS: &[&str] = &["mp3", "cd", "hires", "hires_plus"];
pub const STREAMING_QUALITY_LABELS: &[&str] = &["MP3", "CD Quality", "Hi-Res", "Hi-Res+"];
@@ -303,6 +404,54 @@ const RETRY_BEHAVIOR_LABELS: &[&str] = &["Ask me", "Always try lowest quality",
const RETRY_BEHAVIOR_VALUES: &[&str] = &["ask", "always_fallback", "always_skip"];
const QCONNECT_STARTUP_LABELS: &[&str] = &["Remember state", "On by default", "Off by default"];
const QCONNECT_STARTUP_VALUES: &[&str] = &["remember_last", "on", "off"];
+// Appearance option tables (AppearanceSettings.slint / ui_prefs.rs).
+const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art"];
+const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred"];
+const LANGUAGE_LABELS: &[&str] = &[
+ "Auto", "English", "Español", "Français", "Deutsch", "Português", "Русский", "日本語",
+ "Nederlands",
+];
+const LANGUAGE_VALUES: &[&str] = &["auto", "en", "es", "fr", "de", "pt", "ru", "ja", "nl"];
+const UI_SCALE_LABELS: &[&str] = &["Extra small", "Small", "Default", "Large", "Extra large"];
+const UI_SCALE_VALUES: &[&str] = &["xs", "small", "default", "large", "xl"];
+const IMMERSIVE_SEARCH_LABELS: &[&str] =
+ &["Disabled", "Replace current queue", "Play next", "Add to queue"];
+const IMMERSIVE_SEARCH_VALUES: &[&str] = &["disabled", "replace", "next", "queue"];
+const IMMERSIVE_VIEW_LABELS: &[&str] =
+ &["Remember last", "Album Reactive", "Static", "Coverflow", "Spectrum", "Lyrics", "Queue"];
+const IMMERSIVE_VIEW_VALUES: &[&str] =
+ &["remember", "reactive", "static", "coverflow", "spectrum", "lyrics", "queue"];
+const MINI_VIEW_LABELS: &[&str] =
+ &["Remember last used", "Micro", "Compact", "Artwork", "Queue", "Lyrics"];
+const MINI_VIEW_VALUES: &[&str] = &["remember", "micro", "compact", "artwork", "queue", "lyrics"];
+const STARTUP_PAGE_LABELS: &[&str] = &["Home", "Where you left off"];
+const STARTUP_PAGE_VALUES: &[&str] = &["home", "remember"];
+const WC_POSITION_LABELS: &[&str] = &["Left", "Right"];
+const WC_POSITION_VALUES: &[&str] = &["left", "right"];
+const RENDERER_LABELS: &[&str] =
+ &["Auto (recommended)", "GPU", "GPU (compatibility)", "Software"];
+const RENDERER_VALUES: &[&str] = &["auto", "wgpu", "gl", "software"];
+const TRAY_ICON_LABELS: &[&str] = &["Auto", "Mono light", "Mono dark", "Color"];
+const TRAY_ICON_VALUES: &[&str] = &["auto", "mono-light", "mono-dark", "color"];
+
+fn index_of(values: &[&str], key: &str, default: usize) -> i32 {
+ values.iter().position(|v| *v == key).unwrap_or(default) as i32
+}
+
+/// Preferred-GPU dropdown (AppearanceSettings.slint): "Auto (recommended)"
+/// + detected adapter names built in Rust. POC-NOTE: no adapter enumeration
+/// in the POC — the list is Auto + the currently persisted adapter (so the
+/// owner's real value shows selected); picking it again is a no-op.
+fn gpu_power_choice() -> (Vec, i32) {
+ let persisted = pref_str("gpu_power", "auto");
+ let mut opts = vec![qbz_i18n::t("Auto (recommended)")];
+ if persisted != "auto" && !persisted.is_empty() {
+ opts.push(persisted);
+ (opts, 1)
+ } else {
+ (opts, 0)
+ }
+}
#[derive(Clone, Default, Serialize)]
pub struct DeviceOption {
@@ -386,6 +535,125 @@ pub struct SettingsDoc {
pub qconnect_device_name: String,
#[serde(rename = "qconnectDeviceNameDefault")]
pub qconnect_device_name_default: String,
+ // Appearance (phase 19; the ui_prefs defaults mirror ui_prefs.rs)
+ #[serde(rename = "albumHeaderGradient")]
+ pub album_header_gradient: bool,
+ #[serde(rename = "appBackgroundModes")]
+ pub app_background_modes: Vec,
+ #[serde(rename = "appBackgroundIndex")]
+ pub app_background_index: i32,
+ #[serde(rename = "autoThemeSourceIndex")]
+ pub auto_theme_source_index: i32,
+ #[serde(rename = "intelligentSearch")]
+ pub intelligent_search: bool,
+ pub languages: Vec,
+ #[serde(rename = "languageIndex")]
+ pub language_index: i32,
+ #[serde(rename = "uiScales")]
+ pub ui_scales: Vec,
+ #[serde(rename = "uiScaleIndex")]
+ pub ui_scale_index: i32,
+ #[serde(rename = "immersiveSearchActions")]
+ pub immersive_search_actions: Vec,
+ #[serde(rename = "immersiveSearchActionIndex")]
+ pub immersive_search_action_index: i32,
+ #[serde(rename = "immersiveDefaultViews")]
+ pub immersive_default_views: Vec,
+ #[serde(rename = "immersiveDefaultViewIndex")]
+ pub immersive_default_view_index: i32,
+ #[serde(rename = "navInSidebar")]
+ pub nav_in_sidebar: bool,
+ #[serde(rename = "navHeaderCompact")]
+ pub nav_header_compact: bool,
+ #[serde(rename = "myQbzLabel")]
+ pub my_qbz_label: String,
+ #[serde(rename = "sidebarPlaylistCollage")]
+ pub sidebar_playlist_collage: bool,
+ #[serde(rename = "localLibraryTrackArtwork")]
+ pub local_library_track_artwork: bool,
+ #[serde(rename = "playIndicatorAnimation")]
+ pub play_indicator_animation: bool,
+ #[serde(rename = "invertSwipeNavigation")]
+ pub invert_swipe_navigation: bool,
+ #[serde(rename = "inAppToasts")]
+ pub in_app_toasts: bool,
+ #[serde(rename = "systemNotifications")]
+ pub system_notifications: bool,
+ #[serde(rename = "windowTitleShow")]
+ pub window_title_show: bool,
+ #[serde(rename = "useSystemTitleBar")]
+ pub use_system_title_bar: bool,
+ #[serde(rename = "hideTitleBar")]
+ pub hide_title_bar: bool,
+ #[serde(rename = "wcPositions")]
+ pub wc_positions: Vec,
+ #[serde(rename = "wcPositionIndex")]
+ pub wc_position_index: i32,
+ #[serde(rename = "showWindowControls")]
+ pub show_window_controls: bool,
+ #[serde(rename = "showVolumeSteppers")]
+ pub show_volume_steppers: bool,
+ #[serde(rename = "miniDefaultViews")]
+ pub mini_default_views: Vec,
+ #[serde(rename = "miniDefaultViewIndex")]
+ pub mini_default_view_index: i32,
+ #[serde(rename = "startupPages")]
+ pub startup_pages: Vec,
+ #[serde(rename = "startupPageIndex")]
+ pub startup_page_index: i32,
+ #[serde(rename = "showPurchases")]
+ pub show_purchases: bool,
+ #[serde(rename = "navTbPurchases")]
+ pub nav_tb_purchases: bool,
+ pub renderers: Vec,
+ #[serde(rename = "rendererIndex")]
+ pub renderer_index: i32,
+ #[serde(rename = "gpuPowers")]
+ pub gpu_powers: Vec,
+ #[serde(rename = "gpuPowerIndex")]
+ pub gpu_power_index: i32,
+ // Appearance > SYSTEM TRAY (tray_settings.db)
+ #[serde(rename = "trayEnable")]
+ pub tray_enable: bool,
+ #[serde(rename = "trayCloseToTray")]
+ pub tray_close_to_tray: bool,
+ #[serde(rename = "trayIconThemes")]
+ pub tray_icon_themes: Vec,
+ #[serde(rename = "trayIconThemeIndex")]
+ pub tray_icon_theme_index: i32,
+ // Integrations (phase 19)
+ #[serde(rename = "showRecommendations")]
+ pub show_recommendations: bool,
+ #[serde(rename = "musicbrainzEnabled")]
+ pub musicbrainz_enabled: bool,
+ #[serde(rename = "scrobbleEnabled")]
+ pub scrobble_enabled: bool,
+ #[serde(rename = "scrobbleUiCollapsed")]
+ pub scrobble_ui_collapsed: bool,
+ #[serde(rename = "lastfmEnabled")]
+ pub lastfm_enabled: bool,
+ #[serde(rename = "lastfmAuthed")]
+ pub lastfm_authed: bool,
+ #[serde(rename = "lastfmUsername")]
+ pub lastfm_username: String,
+ #[serde(rename = "lastfmAuthUrl")]
+ pub lastfm_auth_url: String,
+ #[serde(rename = "lastfmBusy")]
+ pub lastfm_busy: bool,
+ #[serde(rename = "listenbrainzEnabled")]
+ pub listenbrainz_enabled: bool,
+ #[serde(rename = "listenbrainzAuthed")]
+ pub listenbrainz_authed: bool,
+ #[serde(rename = "listenbrainzUsername")]
+ pub listenbrainz_username: String,
+ #[serde(rename = "listenbrainzBusy")]
+ pub listenbrainz_busy: bool,
+ #[serde(rename = "integrationsStatusText")]
+ pub integrations_status_text: String,
+ #[serde(rename = "integrationsStatusKind")]
+ pub integrations_status_kind: i32,
+ #[serde(rename = "discordEnabled")]
+ pub discord_enabled: bool,
}
/// Index -> value maps the select handlers resolve against.
@@ -527,6 +795,8 @@ pub async fn publish_snapshot() {
.position(|v| *v == audio_settings.quality_fallback_behavior)
.unwrap_or(0);
let qconnect_startup = qconnect_load_startup_mode();
+ let scrobble_snap = crate::integrations_qt::scrobble_settings();
+ let integ_ui = crate::integrations_qt::ui_snapshot();
let qconnect_startup_index = QCONNECT_STARTUP_VALUES
.iter()
.position(|v| *v == qconnect_startup)
@@ -595,6 +865,111 @@ pub async fn publish_snapshot() {
qconnect_startup_index: qconnect_startup_index as i32,
qconnect_device_name: qconnect_load_device_name().unwrap_or_default(),
qconnect_device_name_default: qconnect_default_name(),
+ album_header_gradient: pref_bool("album_header_gradient", true),
+ app_background_modes: APP_BACKGROUND_LABELS
+ .iter()
+ .map(|l| qbz_i18n::t(l))
+ .collect(),
+ app_background_index: APP_BACKGROUND_VALUES
+ .iter()
+ .position(|v| *v == pref_str("app_background", "off"))
+ .unwrap_or(0) as i32,
+ auto_theme_source_index: index_of(
+ &["system", "wallpaper", "image"],
+ &pref_str("auto_theme_source", "system"),
+ 0,
+ ),
+ intelligent_search: pref_bool("intelligent_search", true),
+ languages: LANGUAGE_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
+ language_index: index_of(LANGUAGE_VALUES, &pref_str("language", "auto"), 0),
+ ui_scales: UI_SCALE_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
+ ui_scale_index: index_of(UI_SCALE_VALUES, &pref_str("ui_scale", "default"), 2),
+ immersive_search_actions: IMMERSIVE_SEARCH_LABELS
+ .iter()
+ .map(|l| qbz_i18n::t(l))
+ .collect(),
+ immersive_search_action_index: index_of(
+ IMMERSIVE_SEARCH_VALUES,
+ &pref_str("immersive_search_action", "replace"),
+ 1,
+ ),
+ immersive_default_views: IMMERSIVE_VIEW_LABELS
+ .iter()
+ .map(|l| qbz_i18n::t(l))
+ .collect(),
+ immersive_default_view_index: index_of(
+ IMMERSIVE_VIEW_VALUES,
+ &pref_str("immersive_default_view", "remember"),
+ 0,
+ ),
+ nav_in_sidebar: pref_bool("nav_in_sidebar", true),
+ nav_header_compact: pref_bool("nav_header_compact", true),
+ my_qbz_label: myqbz_label(),
+ sidebar_playlist_collage: pref_bool("sidebar_playlist_collage", true),
+ local_library_track_artwork: pref_bool("local_library_track_artwork", false),
+ play_indicator_animation: pref_bool("play_indicator_animation", false),
+ invert_swipe_navigation: pref_bool("invert_swipe_navigation", false),
+ in_app_toasts: pref_bool("in_app_toasts", true),
+ system_notifications: pref_bool("system_notifications", true),
+ window_title_show: pref_bool("window_title_show", false),
+ use_system_title_bar: use_system_title_bar(),
+ hide_title_bar: pref_bool("hide_title_bar", false),
+ wc_positions: WC_POSITION_LABELS
+ .iter()
+ .map(|l| qbz_i18n::t(l))
+ .collect(),
+ wc_position_index: index_of(WC_POSITION_VALUES, &pref_str("wc_position", "right"), 1),
+ show_window_controls: pref_bool("show_window_controls", true),
+ show_volume_steppers: pref_bool("show_volume_steppers", false),
+ mini_default_views: MINI_VIEW_LABELS
+ .iter()
+ .map(|l| qbz_i18n::t(l))
+ .collect(),
+ mini_default_view_index: index_of(
+ MINI_VIEW_VALUES,
+ &pref_str("mini_default_view", "remember"),
+ 0,
+ ),
+ startup_pages: STARTUP_PAGE_LABELS
+ .iter()
+ .map(|l| qbz_i18n::t(l))
+ .collect(),
+ startup_page_index: index_of(STARTUP_PAGE_VALUES, &pref_str("startup_page", "home"), 0),
+ show_purchases: pref_bool("show_purchases", false),
+ nav_tb_purchases: pref_bool("nav_tb_purchases", false),
+ renderers: RENDERER_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
+ renderer_index: index_of(RENDERER_VALUES, &pref_str("renderer", "auto"), 0),
+ gpu_powers: gpu_power_choice().0,
+ gpu_power_index: gpu_power_choice().1,
+ tray_enable: tray().get_settings().map(|t| t.enable_tray).unwrap_or(true),
+ tray_close_to_tray: tray()
+ .get_settings()
+ .map(|t| t.close_to_tray)
+ .unwrap_or(false),
+ tray_icon_themes: TRAY_ICON_LABELS
+ .iter()
+ .map(|l| qbz_i18n::t(l))
+ .collect(),
+ tray_icon_theme_index: tray()
+ .get_settings()
+ .map(|t| index_of(TRAY_ICON_VALUES, &t.tray_icon_theme, 0))
+ .unwrap_or(0),
+ show_recommendations: crate::integrations_qt::show_recommendations(),
+ musicbrainz_enabled: pref_bool("musicbrainz_enabled", true),
+ scrobble_enabled: scrobble_snap.enabled,
+ scrobble_ui_collapsed: scrobble_snap.ui_collapsed,
+ lastfm_enabled: scrobble_snap.lastfm_enabled,
+ lastfm_authed: scrobble_snap.lastfm_is_authed(),
+ lastfm_username: scrobble_snap.lastfm_username.clone(),
+ lastfm_auth_url: integ_ui.lastfm_auth_url.clone(),
+ lastfm_busy: integ_ui.lastfm_busy,
+ listenbrainz_enabled: scrobble_snap.listenbrainz_enabled,
+ listenbrainz_authed: scrobble_snap.listenbrainz_is_authed(),
+ listenbrainz_username: scrobble_snap.listenbrainz_username.clone(),
+ listenbrainz_busy: integ_ui.listenbrainz_busy,
+ integrations_status_text: integ_ui.status_text.clone(),
+ integrations_status_kind: integ_ui.status_kind,
+ discord_enabled: crate::integrations_qt::discord_enabled(),
}
})
.await
@@ -724,6 +1099,118 @@ pub async fn settings_bool(runtime: &Arc>, key: &str,
"resume-position" => {
with_playback(|s| s.set_resume_playback_position(value)).map(|_| Apply::None)
}
+ // --- Appearance (phase 19): plain ui_prefs bools (+ live bridge
+ // side-effects where the POC already has the consumer).
+ "album-header-gradient" => {
+ save_pref("album_header_gradient", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "intelligent-search" => {
+ save_pref("intelligent_search", serde_json::json!(value));
+ crate::ui(move |mut b| b.as_mut().set_intelligent_search(value));
+ Ok(Apply::None)
+ }
+ "nav-in-sidebar" => {
+ save_pref("nav_in_sidebar", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "nav-header-compact" => {
+ save_pref("nav_header_compact", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "sidebar-playlist-collage" => {
+ save_pref("sidebar_playlist_collage", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "local-library-track-artwork" => {
+ save_pref("local_library_track_artwork", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "play-indicator-animation" => {
+ save_pref("play_indicator_animation", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "invert-swipe-navigation" => {
+ save_pref("invert_swipe_navigation", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "in-app-toasts" => {
+ save_pref("in_app_toasts", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "system-notifications" => {
+ save_pref("system_notifications", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "window-title-show" => {
+ save_pref("window_title_show", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "use-system-title-bar" => {
+ save_pref("use_system_title_bar", serde_json::json!(value));
+ // Restart semantics (1:1 Slint): the APPLIED mode is untouched;
+ // only the menu/row state flips live.
+ log::info!("[qbz-qt] use_system_title_bar -> {value} (applies on next launch)");
+ crate::ui(move |mut b| b.as_mut().set_system_title_bar_pref(value));
+ Ok(Apply::None)
+ }
+ "hide-title-bar" => {
+ save_pref("hide_title_bar", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "show-window-controls" => {
+ save_pref("show_window_controls", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "show-volume-steppers" => {
+ save_pref("show_volume_steppers", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "show-purchases" => {
+ save_pref("show_purchases", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "nav-tb-purchases" => {
+ save_pref("nav_tb_purchases", serde_json::json!(value));
+ Ok(Apply::None)
+ }
+ "tray-enable" => tray()
+ .set_enable_tray(value)
+ .map_err(|e| e.to_string())
+ .map(|_| Apply::None),
+ "tray-close-to-tray" => tray()
+ .set_close_to_tray(value)
+ .map_err(|e| e.to_string())
+ .map(|_| Apply::None),
+ // --- Integrations (phase 19) --------------------------------------
+ "show-recommendations" => {
+ crate::integrations_qt::set_show_recommendations(value).map(|_| Apply::None)
+ }
+ "musicbrainz" => {
+ match crate::integrations_qt::set_musicbrainz_enabled(value) {
+ Ok(()) => {
+ // Live apply (main.rs:9019-9029 seeds core from this pref).
+ runtime.core().musicbrainz_set_enabled(value).await;
+ Ok(Apply::None)
+ }
+ Err(e) => Err(e),
+ }
+ }
+ "scrobble-enable" => {
+ crate::integrations_qt::set_scrobble_enabled(value).map(|_| Apply::None)
+ }
+ "scrobble-collapse" => {
+ crate::integrations_qt::set_scrobble_collapsed(value).map(|_| Apply::None)
+ }
+ "lastfm-enable" => {
+ crate::integrations_qt::set_lastfm_enabled(value).map(|_| Apply::None)
+ }
+ "listenbrainz-enable" => {
+ crate::integrations_qt::set_listenbrainz_enabled(value).map(|_| Apply::None)
+ }
+ "discord-rpc" => {
+ crate::integrations_qt::set_discord_enabled(value).map(|_| Apply::None)
+ }
other => {
log::warn!("[qbz-qt] unknown settings bool key: {other}");
return;
@@ -838,6 +1325,92 @@ pub async fn settings_select(runtime: &Arc>, key: &st
};
qconnect_persist_startup_mode(mode);
}
+ // --- Appearance (phase 19) ----------------------------------------
+ "app-background" => {
+ let Some(mode) = APP_BACKGROUND_VALUES.get(index) else {
+ return;
+ };
+ save_pref("app_background", serde_json::json!(mode));
+ // Live (pure QML layering; blurred renders AS ambient in the POC).
+ let ambient = if *mode == "off" { 0 } else { 1 };
+ crate::ui(move |mut b| b.as_mut().set_ambient_mode(ambient));
+ }
+ "language" => {
+ let Some(lang) = LANGUAGE_VALUES.get(index) else {
+ return;
+ };
+ save_pref("language", serde_json::json!(lang));
+ // POC-NOTE: live language switching is phase 20; the pref
+ // persists here and applies on the next launch.
+ log::info!("[qbz-qt] language -> {lang} (applies on next launch; live switch is phase 20)");
+ }
+ "ui-scale" => {
+ let Some(scale) = UI_SCALE_VALUES.get(index) else {
+ return;
+ };
+ save_pref("ui_scale", serde_json::json!(scale));
+ log::info!("[qbz-qt] ui_scale -> {scale} (restart to apply)");
+ }
+ "immersive-search-action" => {
+ let Some(v) = IMMERSIVE_SEARCH_VALUES.get(index) else {
+ return;
+ };
+ save_pref("immersive_search_action", serde_json::json!(v));
+ }
+ "immersive-default-view" => {
+ let Some(v) = IMMERSIVE_VIEW_VALUES.get(index) else {
+ return;
+ };
+ save_pref("immersive_default_view", serde_json::json!(v));
+ }
+ "wc-position" => {
+ let Some(v) = WC_POSITION_VALUES.get(index) else {
+ return;
+ };
+ save_pref("wc_position", serde_json::json!(v));
+ }
+ "mini-default-view" => {
+ let Some(v) = MINI_VIEW_VALUES.get(index) else {
+ return;
+ };
+ save_pref("mini_default_view", serde_json::json!(v));
+ }
+ "startup-page" => {
+ let Some(v) = STARTUP_PAGE_VALUES.get(index) else {
+ return;
+ };
+ save_pref("startup_page", serde_json::json!(v));
+ }
+ "renderer" => {
+ let Some(v) = RENDERER_VALUES.get(index) else {
+ return;
+ };
+ save_pref("renderer", serde_json::json!(v));
+ log::info!("[qbz-qt] renderer -> {v} (restart to apply)");
+ }
+ "gpu-power" => {
+ // Only "Auto" is actionable in the POC (no adapter enumeration —
+ // the persisted-name row is display-only).
+ if index == 0 {
+ save_pref("gpu_power", serde_json::json!("auto"));
+ log::info!("[qbz-qt] gpu_power -> auto (restart to apply)");
+ }
+ }
+ "tray-icon-theme" => {
+ let Some(v) = TRAY_ICON_VALUES.get(index) else {
+ return;
+ };
+ if let Err(e) = tray().set_tray_icon_theme(v) {
+ log::error!("[qbz-qt] persist tray icon theme failed: {e}");
+ }
+ }
+ "auto-theme-source" => {
+ const SOURCES: &[&str] = &["system", "wallpaper", "image"];
+ let Some(v) = SOURCES.get(index) else {
+ return;
+ };
+ save_pref("auto_theme_source", serde_json::json!(v));
+ }
other => log::warn!("[qbz-qt] unknown settings select key: {other}"),
}
publish_snapshot().await;
@@ -860,6 +1433,14 @@ pub async fn settings_string(key: &str, value: String) {
let stored = (!trimmed.is_empty()).then_some(trimmed.as_str());
qconnect_persist_device_name(stored);
}
+ if key == "myqbz-label" {
+ save_myqbz_label(&value);
+ }
+ if key == "listenbrainz-token" {
+ // Validates + persists + republishes itself (async flow).
+ crate::integrations_qt::listenbrainz_set_token(&value).await;
+ return;
+ }
publish_snapshot().await;
}
diff --git a/crates/qbz-qt/src/theme_qt.rs b/crates/qbz-qt/src/theme_qt.rs
new file mode 100644
index 000000000..a8f412eff
--- /dev/null
+++ b/crates/qbz-qt/src/theme_qt.rs
@@ -0,0 +1,313 @@
+//! Theme controller (phase 19) — the Slint `crates/qbz/src/theme.rs` port:
+//! the persisted `ui_prefs.theme` slug resolves through the SAME `qbz-theme`
+//! registry (`ThemeId`/`palette()`), and the materialized `ThemeColors` are
+//! serialized to ONE JSON document that QbzTheme.qml binds to — the Qt
+//! equivalent of `theme::push_colors` (live switch, no restart).
+//!
+//! Synthetic slugs (theme.rs): "auto" (AutoSource::System regeneration —
+//! falls back to OLED when headless generation fails) and "custom"
+//! (`/qbz/custom_theme.json`, the SAME file the Slint custom-theme
+//! editor writes; read-only apply here — POC-NOTE: no token editor). The
+//! "system" registry theme resolves via the OS palette in Slint; the POC
+//! maps it to the Dark palette (POC-NOTE).
+
+use cxx_qt_lib::QString;
+use qbz_theme::{Rgba, ThemeColors, ThemeId};
+use serde::Serialize;
+
+// ---------------------------------------------------------------------------
+// ui_prefs.json keys (theme + theme_filter) — additive patch like
+// settings_qt.rs so every other Slint key survives.
+// ---------------------------------------------------------------------------
+
+fn prefs_path() -> Option {
+ Some(dirs::data_dir()?.join("qbz").join("ui_prefs.json"))
+}
+
+fn read_pref(key: &str) -> Option {
+ let path = prefs_path()?;
+ std::fs::read_to_string(path)
+ .ok()
+ .and_then(|text| serde_json::from_str::(&text).ok())
+ .and_then(|v| v.get(key).cloned())
+}
+
+fn write_pref(key: &str, value: serde_json::Value) {
+ let Some(path) = prefs_path() else {
+ return;
+ };
+ let mut doc: serde_json::Value = std::fs::read_to_string(&path)
+ .ok()
+ .and_then(|text| serde_json::from_str(&text).ok())
+ .unwrap_or_else(|| serde_json::json!({}));
+ if let Some(obj) = doc.as_object_mut() {
+ obj.insert(key.to_string(), value);
+ if let Ok(text) = serde_json::to_string_pretty(&doc) {
+ let _ = std::fs::write(&path, text);
+ }
+ }
+}
+
+/// The persisted theme slug (ui_prefs.rs default: "oled").
+pub fn current_slug() -> String {
+ read_pref("theme")
+ .and_then(|v| v.as_str().map(str::to_string))
+ .unwrap_or_else(|| "oled".to_string())
+}
+
+/// The dropdown filter (theme.rs: 0 All / 1 Dark / 2 Light; default 0).
+pub fn theme_filter() -> i32 {
+ read_pref("theme_filter")
+ .and_then(|v| v.as_i64())
+ .unwrap_or(0) as i32
+}
+
+pub fn set_theme_filter(index: i32) {
+ write_pref("theme_filter", serde_json::json!(index.clamp(0, 2)));
+}
+
+/// Persist a new slug (additive); the caller republishes `themeJson`.
+pub fn set_theme(slug: &str) {
+ write_pref("theme", serde_json::Value::String(slug.to_string()));
+ log::info!("[qbz-qt] theme -> {slug}");
+}
+
+// ---------------------------------------------------------------------------
+// Slug -> ThemeColors (theme.rs apply_theme / auto / custom startup dispatch)
+// ---------------------------------------------------------------------------
+
+fn custom_theme_path() -> Option {
+ Some(dirs::data_dir()?.join("qbz").join("custom_theme.json"))
+}
+
+fn custom_colors() -> ThemeColors {
+ let base = custom_theme_path()
+ .and_then(|p| std::fs::read_to_string(p).ok())
+ .and_then(|text| serde_json::from_str::(&text).ok())
+ .unwrap_or_else(qbz_theme::CustomThemeBase::default_oled);
+ qbz_theme::theme_from_base(&base)
+}
+
+fn auto_colors() -> ThemeColors {
+ match qbz_theme::generate_auto_theme(&qbz_theme::AutoSource::System) {
+ Ok(c) => c,
+ Err(e) => {
+ // Headless / no DE portal: keep the row selectable but fall back
+ // to the default dark tokens (POC-NOTE).
+ log::warn!("[qbz-qt] auto theme generation failed ({e}); falling back to OLED");
+ qbz_theme::palette(qbz_theme::default_theme_id())
+ }
+ }
+}
+
+/// Resolve a slug to its materialized colors (unknown -> OLED, theme.rs
+/// `id_for_slug` fallback).
+pub fn colors_for_slug(slug: &str) -> ThemeColors {
+ match slug {
+ "auto" => auto_colors(),
+ "custom" => custom_colors(),
+ // POC-NOTE: "system" reads the OS palette in Slint; the POC maps it
+ // to the Dark registry palette.
+ "system" => qbz_theme::palette(ThemeId::Dark),
+ other => ThemeId::from_slug(other)
+ .map(qbz_theme::palette)
+ .unwrap_or_else(|| qbz_theme::palette(qbz_theme::default_theme_id())),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ThemeColors -> QML token JSON (theme::push_colors equivalent)
+// ---------------------------------------------------------------------------
+
+/// QML color literal: "#aarrggbb" (Qt ARGB — the notation QbzTheme.qml
+/// already uses).
+fn argb(c: Rgba) -> String {
+ format!("#{:02x}{:02x}{:02x}{:02x}", c.a, c.r, c.g, c.b)
+}
+
+fn with_alpha(c: Rgba, a: u8) -> String {
+ argb(Rgba {
+ r: c.r,
+ g: c.g,
+ b: c.b,
+ a,
+ })
+}
+
+/// The alpha-ramp percents (colors.rs:12-17) — indices into the 24 tiers.
+const ALPHA_PCTS: [u32; 24] = [
+ 4, 5, 6, 8, 10, 12, 15, 18, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95,
+];
+
+#[derive(Serialize)]
+struct ThemeTokens {
+ // 30 named colors (ThemeColors contract).
+ #[serde(rename = "surfaceMain")]
+ surface_main: String,
+ #[serde(rename = "surfaceCard")]
+ surface_card: String,
+ #[serde(rename = "surfaceElevated")]
+ surface_elevated: String,
+ #[serde(rename = "surfaceHover")]
+ surface_hover: String,
+ #[serde(rename = "bgHover")]
+ bg_hover: String,
+ #[serde(rename = "textPrimary")]
+ text_primary: String,
+ #[serde(rename = "textSecondary")]
+ text_secondary: String,
+ #[serde(rename = "textMuted")]
+ text_muted: String,
+ #[serde(rename = "textDisabled")]
+ text_disabled: String,
+ accent: String,
+ #[serde(rename = "accentHover")]
+ accent_hover: String,
+ #[serde(rename = "accentPressed")]
+ accent_pressed: String,
+ #[serde(rename = "accentText")]
+ accent_text: String,
+ danger: String,
+ #[serde(rename = "dangerBg")]
+ danger_bg: String,
+ #[serde(rename = "dangerBorder")]
+ danger_border: String,
+ #[serde(rename = "dangerHover")]
+ danger_hover: String,
+ warning: String,
+ #[serde(rename = "warningBg")]
+ warning_bg: String,
+ #[serde(rename = "warningBorder")]
+ warning_border: String,
+ #[serde(rename = "warningHover")]
+ warning_hover: String,
+ success: String,
+ #[serde(rename = "successBg")]
+ success_bg: String,
+ #[serde(rename = "successBorder")]
+ success_border: String,
+ #[serde(rename = "successHover")]
+ success_hover: String,
+ #[serde(rename = "borderSubtle")]
+ border_subtle: String,
+ #[serde(rename = "borderMuted")]
+ border_muted: String,
+ #[serde(rename = "borderStrong")]
+ border_strong: String,
+ #[serde(rename = "focusRing")]
+ focus_ring: String,
+ favorite: String,
+ #[serde(rename = "cardShadow")]
+ card_shadow: String,
+ // 24 alpha tiers (alpha4..alpha95, white-based dark / black-based light).
+ alpha: Vec,
+ // Ambient-layer derivations (phase-14 tokens, now theme-derived):
+ // chrome surface-card @50%, frosted panel surface-main @22%, thin bars
+ // surface-main @30%, hairline = alpha tier 10%.
+ #[serde(rename = "surfaceCardA50")]
+ surface_card_a50: String,
+ #[serde(rename = "surfaceMainA22")]
+ surface_main_a22: String,
+ #[serde(rename = "surfaceMainA30")]
+ surface_main_a30: String,
+ #[serde(rename = "frostBorder")]
+ frost_border: String,
+ // Flags (Theme.is-dark).
+ #[serde(rename = "isDark")]
+ is_dark: bool,
+}
+
+fn tokens_for(colors: &ThemeColors) -> ThemeTokens {
+ let is_dark = qbz_theme::relative_luminance(colors.surface_main) < 0.5;
+ ThemeTokens {
+ surface_main: argb(colors.surface_main),
+ surface_card: argb(colors.surface_card),
+ surface_elevated: argb(colors.surface_elevated),
+ surface_hover: argb(colors.surface_hover),
+ bg_hover: argb(colors.bg_hover),
+ text_primary: argb(colors.text_primary),
+ text_secondary: argb(colors.text_secondary),
+ text_muted: argb(colors.text_muted),
+ text_disabled: argb(colors.text_disabled),
+ accent: argb(colors.accent),
+ accent_hover: argb(colors.accent_hover),
+ accent_pressed: argb(colors.accent_pressed),
+ accent_text: argb(colors.accent_text),
+ danger: argb(colors.danger),
+ danger_bg: argb(colors.danger_bg),
+ danger_border: argb(colors.danger_border),
+ danger_hover: argb(colors.danger_hover),
+ warning: argb(colors.warning),
+ warning_bg: argb(colors.warning_bg),
+ warning_border: argb(colors.warning_border),
+ warning_hover: argb(colors.warning_hover),
+ success: argb(colors.success),
+ success_bg: argb(colors.success_bg),
+ success_border: argb(colors.success_border),
+ success_hover: argb(colors.success_hover),
+ border_subtle: argb(colors.border_subtle),
+ border_muted: argb(colors.border_muted),
+ border_strong: argb(colors.border_strong),
+ focus_ring: argb(colors.focus_ring),
+ favorite: argb(colors.favorite),
+ card_shadow: argb(colors.card_shadow),
+ alpha: colors.alpha.iter().map(|c| argb(*c)).collect(),
+ surface_card_a50: with_alpha(colors.surface_card, 0x80),
+ surface_main_a22: with_alpha(colors.surface_main, 0x38),
+ surface_main_a30: with_alpha(colors.surface_main, 0x4d),
+ frost_border: argb(colors.alpha[ALPHA_PCTS.iter().position(|p| *p == 10).unwrap_or(4)]),
+ is_dark,
+ }
+}
+
+/// The current theme as the QML token document.
+pub fn theme_json() -> String {
+ let colors = colors_for_slug(¤t_slug());
+ serde_json::to_string(&tokens_for(&colors)).unwrap_or_else(|_| "{}".into())
+}
+
+/// Re-resolve + republish after a slug change (the push_colors moment).
+pub fn publish_theme() {
+ let json = theme_json();
+ let slug = current_slug();
+ crate::ui(move |mut b| {
+ b.as_mut().set_theme_json(QString::from(json.as_str()));
+ b.as_mut().set_theme_slug(QString::from(slug.as_str()));
+ });
+}
+
+// ---------------------------------------------------------------------------
+// The dropdown catalog (theme.rs dropdown_labels + the 2 synthetic rows)
+// ---------------------------------------------------------------------------
+
+#[derive(Serialize)]
+struct ThemeEntry {
+ label: String,
+ slug: String,
+ #[serde(rename = "isLight")]
+ is_light: bool,
+}
+
+/// All 36 registry themes + "Auto (dynamic)" + "Custom" (theme.rs:128-142,
+/// 217-227 — the synthetics only show under the All filter; QML filters).
+pub fn theme_list_json() -> String {
+ let mut entries: Vec = qbz_theme::theme_list()
+ .into_iter()
+ .map(|e| ThemeEntry {
+ label: e.display_name.to_string(),
+ slug: e.slug.to_string(),
+ is_light: e.is_light,
+ })
+ .collect();
+ entries.push(ThemeEntry {
+ label: "Auto (dynamic)".to_string(),
+ slug: "auto".to_string(),
+ is_light: false,
+ });
+ entries.push(ThemeEntry {
+ label: "Custom".to_string(),
+ slug: "custom".to_string(),
+ is_light: false,
+ });
+ serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into())
+}