From d57cec974f02bd95ee10d5fd6f0da198522ab5ca Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Mon, 27 Jul 2026 19:12:06 -0600
Subject: [PATCH] =?UTF-8?q?feat(qbz-qt):=20phase=2010=20=E2=80=94=20Settin?=
=?UTF-8?q?gs=20>=20Audio=20and=20Playback=201:1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
settings_qt.rs controller over the shared stores (AudioSettingsState,
PlaybackPreferencesState, ui_prefs.json, qconnect_settings.db) with the
upstream cascades (dac-passthrough->skip-sink-switch, streaming-only->
gapless, backend switch resets routing) and live apply through public
player APIs (reload_settings/reinit_device/clear_audio_cache). ALSA
device regrouping (Defaults/Bit-perfect/Plugin/Other + BP badges),
searchable QbzSelect, QbzToggle/QbzSlider/SettingRow replicas, exact
row order and gating. Persistence probe verified (gapless +
streaming-quality round-trip, original values restored).
km
---
crates/Cargo.lock | 2 +
crates/qbz-qt/Cargo.toml | 3 +
crates/qbz-qt/build.rs | 1 +
crates/qbz-qt/qml/AppShell.qml | 3 +-
crates/qbz-qt/qml/HeaderBar.qml | 16 +-
crates/qbz-qt/qml/SettingsView.qml | 1037 +++++++++++++++++
.../qml/assets/icons/accent/refresh-cw.svg | 6 +
.../qml/assets/icons/black/refresh-cw.svg | 6 +
.../qml/assets/icons/favorite/refresh-cw.svg | 6 +
.../qml/assets/icons/muted/refresh-cw.svg | 6 +
.../qml/assets/icons/primary/refresh-cw.svg | 6 +
.../qml/assets/icons/secondary/refresh-cw.svg | 6 +
.../qml/assets/icons/warning/refresh-cw.svg | 6 +
crates/qbz-qt/src/bridge.rs | 52 +
crates/qbz-qt/src/main.rs | 47 +
crates/qbz-qt/src/playback_qt.rs | 44 +-
crates/qbz-qt/src/settings_qt.rs | 815 +++++++++++++
17 files changed, 2045 insertions(+), 17 deletions(-)
create mode 100644 crates/qbz-qt/qml/SettingsView.qml
create mode 100644 crates/qbz-qt/qml/assets/icons/accent/refresh-cw.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/black/refresh-cw.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/refresh-cw.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/muted/refresh-cw.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/primary/refresh-cw.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/refresh-cw.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/warning/refresh-cw.svg
create mode 100644 crates/qbz-qt/src/settings_qt.rs
diff --git a/crates/Cargo.lock b/crates/Cargo.lock
index 6a50a6a3d..d71e1a972 100644
--- a/crates/Cargo.lock
+++ b/crates/Cargo.lock
@@ -6620,6 +6620,7 @@ dependencies = [
"log",
"open",
"qbz-app",
+ "qbz-audio",
"qbz-cache",
"qbz-core",
"qbz-credentials",
@@ -6633,6 +6634,7 @@ dependencies = [
"qbz-text-utils",
"rand 0.10.1",
"reqwest",
+ "rusqlite",
"serde",
"serde_json",
"tokio",
diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml
index d7bfdf431..3dccd6423 100644
--- a/crates/qbz-qt/Cargo.toml
+++ b/crates/qbz-qt/Cargo.toml
@@ -26,6 +26,7 @@ dirs = "5"
qbz-core = { path = "../qbz-core" }
qbz-app = { path = "../qbz-app" }
+qbz-audio = { path = "../qbz-audio" }
qbz-qobuz = { path = "../qbz-qobuz" }
qbz-models = { path = "../qbz-models" }
qbz-cache = { path = "../qbz-cache" }
@@ -37,5 +38,7 @@ qbz-credentials = { path = "../qbz-credentials" }
qbz-i18n = { path = "../qbz-i18n" }
qbz-log = { path = "../qbz-log" }
+rusqlite = { version = "0.31", features = ["bundled"] }
+
[build-dependencies]
cxx-qt-build = "0.7"
diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs
index 59ecb27c2..07532ba73 100644
--- a/crates/qbz-qt/build.rs
+++ b/crates/qbz-qt/build.rs
@@ -51,6 +51,7 @@ fn main() {
"qml/LyricsPanel.qml",
"qml/HomeView.qml",
"qml/LibraryView.qml",
+ "qml/SettingsView.qml",
"qml/QbzTheme.qml",
"qml/QbzIcon.qml",
"qml/QbzScrollBar.qml",
diff --git a/crates/qbz-qt/qml/AppShell.qml b/crates/qbz-qt/qml/AppShell.qml
index dbaaab0d8..aa99ee5c4 100644
--- a/crates/qbz-qt/qml/AppShell.qml
+++ b/crates/qbz-qt/qml/AppShell.qml
@@ -125,7 +125,8 @@ Rectangle {
source: QbzBridge.currentView === "home" ? "HomeView.qml"
: QbzBridge.currentView === "library" ? "LibraryView.qml"
: QbzBridge.currentView === "album" ? "AlbumView.qml"
- : QbzBridge.currentView === "artist" ? "ArtistView.qml" : ""
+ : QbzBridge.currentView === "artist" ? "ArtistView.qml"
+ : QbzBridge.currentView === "settings" ? "SettingsView.qml" : ""
}
}
}
diff --git a/crates/qbz-qt/qml/HeaderBar.qml b/crates/qbz-qt/qml/HeaderBar.qml
index 996edbda8..09ed6d6fb 100644
--- a/crates/qbz-qt/qml/HeaderBar.qml
+++ b/crates/qbz-qt/qml/HeaderBar.qml
@@ -435,10 +435,10 @@ Rectangle {
}
}
- // --- App menu (stub: user block + Log Out + Close) --------------------
- // POC-NOTE: the Slint menu also carries Settings / Open Music Link /
- // Keyboard Shortcuts / Documentation / Report an Issue / What's New /
- // About QBZ — omitted here (no views/dialogs to open yet).
+ // --- App menu (user block + Settings + Log Out + Close) ---------------
+ // POC-NOTE: the Slint menu also carries Open Music Link / Keyboard
+ // Shortcuts / Documentation / Report an Issue / What's New / About QBZ
+ // — omitted here (no views/dialogs to open yet).
Popup {
id: appMenu
x: root.width - 234 - theme.spacingMd
@@ -521,6 +521,14 @@ Rectangle {
}
}
+ AppMenuItem {
+ name: "settings-2"
+ label: QbzBridge.tr("Settings")
+ onClicked: {
+ appMenu.close()
+ QbzBridge.navigateTo("settings")
+ }
+ }
AppMenuItem {
name: "log-out"
label: QbzBridge.tr("Log Out")
diff --git a/crates/qbz-qt/qml/SettingsView.qml b/crates/qbz-qt/qml/SettingsView.qml
new file mode 100644
index 000000000..4cf680531
--- /dev/null
+++ b/crates/qbz-qt/qml/SettingsView.qml
@@ -0,0 +1,1037 @@
+// Settings view — the QML port of crates/qbz-ui/ui/settings/
+// SettingsView.slint + AudioSettings.slint + PlaybackSettings.slint:
+// a 92px title header, a 232px left sub-navigation, and the active panel
+// in a touch-draggable Flickable with a ListScrollbar replica.
+//
+// All state is ONE JSON document (QbzBridge.settingsJson, settings_qt.rs
+// SettingsDoc). Controls never keep local truth: they call the
+// settingsBool/Select/Slider/String invokables, Rust persists + applies +
+// republishes, and the rows re-render from the new document (the Slint
+// "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.
+// - 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
+// device-cap probe glue (device_cap_summary) is not ported.
+// - Audio > JACK "not bit-perfect" WarningBanner: skipped (POC-NOTE; the
+// backendIsJack flag IS published for a future port).
+// - Audio > HiFi Wizard button: skipped (DacWizardActions not ported).
+// - Settings export/import modal: not ported.
+
+import QtQuick
+import QtQuick.Controls
+import com.blitzfc.qbz
+
+Item {
+ id: root
+
+ QbzTheme { id: theme }
+
+ // The parsed settingsJson document ({}) until the first publish lands).
+ property var doc: ({})
+ // Active sub-section: 0 = Audio, 1 = Playback (SettingsState.section).
+ property int section: 0
+
+ function reload() {
+ try {
+ root.doc = JSON.parse(QbzBridge.settingsJson)
+ } catch (e) {
+ root.doc = ({})
+ }
+ }
+ Component.onCompleted: reload()
+ Connections {
+ target: QbzBridge
+ 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
+ spacing: 0
+
+ // --- Header (92px; NavButtons is a 0px placeholder in this port) --
+ Item {
+ width: parent.width
+ height: 92
+ Text {
+ x: 32
+ // padding-top 11 + 12px gap below the (0px) NavButtons row.
+ y: 23
+ text: QbzBridge.tr("Settings")
+ color: theme.textPrimary
+ font.pixelSize: theme.fontTitle
+ font.weight: theme.weightBold
+ }
+ }
+
+ // --- Sub-nav + active panel ---------------------------------------
+ Row {
+ width: parent.width
+ height: parent.height - 92
+
+ // Left sub-navigation (232px).
+ Item {
+ width: 232
+ height: parent.height
+ Column {
+ anchors.fill: parent
+ anchors.leftMargin: 24
+ anchors.rightMargin: 12
+ anchors.topMargin: 4
+ anchors.bottomMargin: 16
+ spacing: 4
+
+ component SubNavItem: Rectangle {
+ property string name: ""
+ property string label: ""
+ property bool active: false
+ signal clicked()
+
+ width: parent ? parent.width : 0
+ height: 38
+ radius: theme.radiusSm
+ color: active ? theme.surfaceElevated
+ : snArea.containsMouse ? theme.surfaceHover : "transparent"
+ Row {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ spacing: 10
+ QbzIcon {
+ name: parent.parent.name
+ width: 16
+ height: 16
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: parent.parent.active ? "primary" : "secondary"
+ }
+ Text {
+ height: parent.height
+ text: parent.parent.label
+ color: parent.parent.active ? theme.textPrimary : theme.textSecondary
+ font.pixelSize: theme.fontBody
+ font.weight: parent.parent.active ? theme.weightSemibold : theme.weightRegular
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+ MouseArea {
+ id: snArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: parent.clicked()
+ }
+ }
+
+ SubNavItem {
+ name: "volume-2"
+ label: QbzBridge.tr("Audio")
+ active: root.section === 0
+ onClicked: root.section = 0
+ }
+ SubNavItem {
+ name: "play-fill"
+ label: QbzBridge.tr("Playback")
+ 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).
+ }
+ }
+
+ // Active panel — a raw Flickable (touch-drag scroll on the Pi
+ // kiosk, per the Slint comment) + a ListScrollbar replica.
+ Item {
+ width: parent.width - 232
+ height: parent.height
+
+ Flickable {
+ id: flick
+ anchors.fill: parent
+ contentWidth: width
+ contentHeight: panelCol.height + 60
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+
+ Column {
+ id: panelCol
+ x: 20
+ y: 4
+ width: flick.width - 60 // 20 left + 40 right padding
+ spacing: 4
+
+ // ===================== AUDIO ======================
+ Column {
+ visible: root.section === 0
+ width: parent.width
+ spacing: 4
+
+ GroupHeader { text: QbzBridge.tr("STREAMING") }
+ SettingRow {
+ label: QbzBridge.tr("Streaming quality")
+ description: QbzBridge.tr("The quality tier QBZ requests for playback.")
+ QbzSelect {
+ menuWidth: 200
+ options: root.doc.streamingQualities || []
+ currentIndex: root.doc.streamingQualityIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("streaming-quality", i) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Limit quality to device")
+ description: QbzBridge.tr("Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting.")
+ QbzToggle {
+ checked: root.doc.limitQualityToDevice === true
+ onToggled: function (v) { QbzBridge.settingsBool("limit-quality-to-device", v) }
+ }
+ }
+ // POC-NOTE: the read-only "Detected device limit"
+ // row + fallback disclosure (#638 fix 3) are
+ // skipped — the device-cap probe glue is not
+ // ported.
+
+ Spacer12 { }
+ Divider { }
+ Spacer12 { }
+
+ GroupHeader { text: QbzBridge.tr("OUTPUT") }
+ SettingRow {
+ label: QbzBridge.tr("Audio backend")
+ description: QbzBridge.tr("The audio stack QBZ routes playback through.")
+ QbzSelect {
+ menuWidth: 220
+ options: root.doc.backends || []
+ currentIndex: root.doc.backendIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("backend", i) }
+ }
+ }
+ // POC-NOTE: the JACK "not bit-perfect"
+ // WarningBanner is skipped (backendIsJack is
+ // published for a future port).
+ SettingRow {
+ label: QbzBridge.tr("Output device")
+ description: QbzBridge.tr("The DAC or sound device that receives audio.")
+ Row {
+ spacing: 8
+ // Refresh / release: frees a device QBZ
+ // holds exclusively and re-enumerates.
+ Rectangle {
+ width: 34
+ height: 34
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderSubtle
+ color: relArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
+ QbzIcon {
+ name: "refresh-cw"
+ width: 16
+ height: 16
+ anchors.centerIn: parent
+ tintName: relArea.containsMouse ? "primary" : "muted"
+ }
+ MouseArea {
+ id: relArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzBridge.refreshDevices()
+ }
+ }
+ QbzSelect {
+ menuWidth: 300
+ popupWidth: 480
+ searchable: true
+ options: root.doc.devices || []
+ currentIndex: root.doc.deviceIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("device", i) }
+ }
+ }
+ }
+ SettingRow {
+ visible: root.doc.backendIsAlsa === true
+ height: visible ? (description === "" ? 52 : 64) : 0
+ label: QbzBridge.tr("ALSA plugin")
+ description: QbzBridge.tr("How ALSA opens the device — hw is bit-perfect, plughw converts.")
+ QbzSelect {
+ menuWidth: 220
+ options: root.doc.alsaPlugins || []
+ currentIndex: root.doc.alsaPluginIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("alsa-plugin", i) }
+ }
+ }
+ SettingRow {
+ visible: root.doc.backendIsAlsa === true && root.doc.alsaPluginIsHw === true
+ height: visible ? (description === "" ? 52 : 64) : 0
+ label: QbzBridge.tr("Hardware volume control")
+ description: QbzBridge.tr("Use the ALSA mixer for volume instead of software gain.")
+ QbzToggle {
+ checked: root.doc.alsaHardwareVolume === true
+ onToggled: function (v) { QbzBridge.settingsBool("alsa-hardware-volume", v) }
+ }
+ }
+ SettingRow {
+ visible: root.doc.backendIsAlsa === true
+ height: visible ? (description === "" ? 52 : 64) : 0
+ label: QbzBridge.tr("DSD playback")
+ description: QbzBridge.tr("How DSD tracks reach the DAC. WARNING: choose DoP or Native only if your DAC supports it — on any other DAC they play as loud noise. Volume is fixed and seeking is disabled in DoP/Native mode. Native additionally needs kernel support for the DAC.")
+ QbzSelect {
+ menuWidth: 280
+ options: root.doc.dsdModes || []
+ currentIndex: root.doc.dsdModeIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("dsd-mode", i) }
+ }
+ }
+
+ Spacer12 { }
+ Divider { }
+ Spacer12 { }
+
+ GroupHeader { text: QbzBridge.tr("BIT-PERFECT") }
+ SettingRow {
+ label: QbzBridge.tr("Exclusive mode")
+ description: QbzBridge.tr("Lock the device so no other app can resample it.")
+ rowEnabled: root.doc.backendIsAlsa === true
+ QbzToggle {
+ checked: root.doc.exclusiveMode === true
+ enabled: root.doc.backendIsAlsa === true
+ onToggled: function (v) { QbzBridge.settingsBool("exclusive-mode", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Reserve DAC while running")
+ description: QbzBridge.tr("Hold the device reserved so other apps can't grab it.")
+ QbzToggle {
+ checked: root.doc.reserveDac === true
+ onToggled: function (v) { QbzBridge.settingsBool("reserve-dac", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("DAC passthrough")
+ description: QbzBridge.tr("Send the bitstream untouched to the DAC.")
+ rowEnabled: root.doc.backendIsPipewire === true
+ QbzToggle {
+ checked: root.doc.dacPassthrough === true
+ enabled: root.doc.backendIsPipewire === true
+ onToggled: function (v) { QbzBridge.settingsBool("dac-passthrough", v) }
+ }
+ }
+ SettingRow {
+ visible: root.doc.dacPassthrough === true && root.doc.backendIsPipewire === true
+ height: visible ? (description === "" ? 52 : 64) : 0
+ label: QbzBridge.tr("Force bit-perfect")
+ description: QbzBridge.tr("Pin the PipeWire quantum and rate for the active track.")
+ QbzToggle {
+ checked: root.doc.pwForceBitperfect === true
+ onToggled: function (v) { QbzBridge.settingsBool("pw-force-bitperfect", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Allow quality fallback")
+ description: QbzBridge.tr("Drop to a lower tier when the requested one is unavailable.")
+ QbzToggle {
+ checked: root.doc.allowQualityFallback === true
+ onToggled: function (v) { QbzBridge.settingsBool("allow-quality-fallback", v) }
+ }
+ }
+ // POC-NOTE: the HiFi Wizard row (PipeWire-only
+ // guided DAC setup) is skipped — DacWizardActions
+ // is not ported.
+
+ Spacer12 { }
+ Divider { }
+ Spacer12 { }
+
+ GroupHeader { text: QbzBridge.tr("STARTUP") }
+ SettingRow {
+ label: QbzBridge.tr("Sync audio settings on startup")
+ description: QbzBridge.tr("Reload saved audio settings into the player when QBZ launches.")
+ QbzToggle {
+ checked: root.doc.syncAudioOnStartup === true
+ onToggled: function (v) { QbzBridge.settingsBool("sync-audio-on-startup", v) }
+ }
+ }
+ SettingRow {
+ visible: root.doc.backendIsPipewire === true
+ height: visible ? (description === "" ? 52 : 64) : 0
+ label: QbzBridge.tr("Lock output device")
+ description: QbzBridge.tr("Keep external routing intact — skip switching the default sink.")
+ rowEnabled: root.doc.dacPassthrough !== true
+ QbzToggle {
+ checked: root.doc.skipSinkSwitch === true
+ enabled: root.doc.dacPassthrough !== true
+ onToggled: function (v) { QbzBridge.settingsBool("skip-sink-switch", v) }
+ }
+ }
+
+ Item { width: 1; height: 20 }
+
+ // Reset — restores Audio + Playback defaults.
+ Rectangle {
+ width: Math.max(resetLabel.implicitWidth + 32, 200)
+ height: 36
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderSubtle
+ color: resetArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
+ Text {
+ id: resetLabel
+ anchors.centerIn: parent
+ text: QbzBridge.tr("Reset to defaults")
+ color: theme.textSecondary
+ font.pixelSize: theme.fontBody
+ font.weight: theme.weightMedium
+ }
+ MouseArea {
+ id: resetArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzBridge.settingsReset()
+ }
+ }
+ }
+
+ // ==================== PLAYBACK ====================
+ Column {
+ visible: root.section === 1
+ width: parent.width
+ spacing: 4
+
+ GroupHeader { text: QbzBridge.tr("PLAYBACK") }
+ SettingRow {
+ label: QbzBridge.tr("Continue playback after track ends")
+ description: QbzBridge.tr("Keep playing the rest of the album or playlist instead of stopping.")
+ QbzToggle {
+ checked: root.doc.continuePlayback === true
+ onToggled: function (v) { QbzBridge.settingsBool("continue-playback", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Show track playing context")
+ description: QbzBridge.tr("Display the context-stack icon in the player.")
+ QbzToggle {
+ checked: root.doc.showContextIcon === true
+ onToggled: function (v) { QbzBridge.settingsBool("show-context-icon", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Gapless playback")
+ description: QbzBridge.tr("Play consecutive same-format tracks without a gap.")
+ rowEnabled: root.doc.streamingOnly !== true
+ QbzToggle {
+ checked: root.doc.gapless === true
+ enabled: root.doc.streamingOnly !== true
+ onToggled: function (v) { QbzBridge.settingsBool("gapless", v) }
+ }
+ }
+
+ Spacer12 { }
+ Divider { }
+ Spacer12 { }
+
+ GroupHeader { text: QbzBridge.tr("SESSION") }
+ SettingRow {
+ label: QbzBridge.tr("Restore session on startup")
+ description: QbzBridge.tr("Restore the queue and current track on the next launch.")
+ QbzToggle {
+ checked: root.doc.persistSession === true
+ onToggled: function (v) { QbzBridge.settingsBool("persist-session", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Resume playback position")
+ description: QbzBridge.tr("Also seek back to where the saved track left off.")
+ rowEnabled: root.doc.persistSession === true
+ QbzToggle {
+ checked: root.doc.resumePosition === true
+ enabled: root.doc.persistSession === true
+ onToggled: function (v) { QbzBridge.settingsBool("resume-position", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Auto-connect Qobuz Connect on startup")
+ description: QbzBridge.tr("Choose whether Qobuz Connect activates automatically when QBZ launches.")
+ QbzSelect {
+ menuWidth: 240
+ options: root.doc.qconnectStartupModes || []
+ currentIndex: root.doc.qconnectStartupIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("qconnect-startup", i) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Qobuz Connect device name")
+ description: QbzBridge.tr("The name other Qobuz Connect apps see for this device. Applies on the next connection.")
+ QbzLineEdit {
+ text: root.doc.qconnectDeviceName || ""
+ placeholder: root.doc.qconnectDeviceNameDefault || ""
+ onCommitted: function (s) { QbzBridge.settingsString("qconnect-device-name", s) }
+ }
+ }
+
+ Spacer12 { }
+ Divider { }
+ Spacer12 { }
+
+ GroupHeader { text: QbzBridge.tr("STREAMING") }
+ SettingRow {
+ label: QbzBridge.tr("Stream uncached tracks")
+ description: QbzBridge.tr("Start uncached tracks via streaming instead of waiting for the full download.")
+ QbzToggle {
+ checked: root.doc.streamUncached === true
+ onToggled: function (v) { QbzBridge.settingsBool("stream-uncached", v) }
+ }
+ }
+ SettingRow {
+ visible: root.doc.streamUncached === true
+ height: visible ? (description === "" ? 52 : 64) : 0
+ label: QbzBridge.tr("Initial buffer size")
+ description: QbzBridge.tr("Seconds of audio buffered before streaming playback starts.")
+ Row {
+ spacing: 12
+ Item { width: 1; height: 1 }
+ QbzSlider {
+ minimum: 1
+ maximum: 10
+ value: root.doc.bufferSeconds || 1
+ anchors.verticalCenter: parent.verticalCenter
+ onChanged: function (v) { QbzBridge.settingsSlider("buffer-seconds", v) }
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: (root.doc.bufferSeconds || 1) + QbzBridge.tr("s")
+ color: theme.textSecondary
+ font.pixelSize: theme.fontBody
+ font.weight: theme.weightMedium
+ }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("Streaming only")
+ description: QbzBridge.tr("Skip writing tracks to the local cache while streaming.")
+ QbzToggle {
+ checked: root.doc.streamingOnly === true
+ onToggled: function (v) { QbzBridge.settingsBool("streaming-only", v) }
+ }
+ }
+ SettingRow {
+ label: QbzBridge.tr("When quality retries fail")
+ description: QbzBridge.tr("What to do when every quality tier for a track is unavailable.")
+ QbzSelect {
+ menuWidth: 240
+ options: root.doc.retryBehaviors || []
+ currentIndex: root.doc.retryBehaviorIndex || 0
+ onSelected: function (i) { QbzBridge.settingsSelect("retry-behavior", i) }
+ }
+ }
+ }
+ }
+ }
+
+ QbzScrollBar {
+ target: flick
+ anchors.right: parent.right
+ anchors.rightMargin: 2
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ }
+ }
+ }
+ }
+}
diff --git a/crates/qbz-qt/qml/assets/icons/accent/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/accent/refresh-cw.svg
new file mode 100644
index 000000000..15e97e61b
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/refresh-cw.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/black/refresh-cw.svg
new file mode 100644
index 000000000..094c7fd38
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/refresh-cw.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/favorite/refresh-cw.svg
new file mode 100644
index 000000000..1799f33ff
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/refresh-cw.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/muted/refresh-cw.svg
new file mode 100644
index 000000000..e63e89f1a
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/refresh-cw.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/primary/refresh-cw.svg
new file mode 100644
index 000000000..4f2dbfb7e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/refresh-cw.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/secondary/refresh-cw.svg
new file mode 100644
index 000000000..91c49c810
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/refresh-cw.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/refresh-cw.svg b/crates/qbz-qt/qml/assets/icons/warning/refresh-cw.svg
new file mode 100644
index 000000000..b6d9d7fb0
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/refresh-cw.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs
index 73cd173f7..c4a6437ea 100644
--- a/crates/qbz-qt/src/bridge.rs
+++ b/crates/qbz-qt/src/bridge.rs
@@ -141,6 +141,11 @@ pub mod qbz_bridge {
#[qproperty(QString, sidebar_sort_by)]
#[qproperty(bool, sidebar_sort_asc)]
+ // --- Settings (phase 10) -----------------------------------------
+ // One JSON document (settings_qt.rs SettingsDoc: audio + playback
+ // row states, select option lists, output-device groups).
+ #[qproperty(QString, settings_json)]
+
type QbzBridge = super::QbzBridgeRust;
/// Called once from Main.qml's Component.onCompleted: registers the
@@ -344,6 +349,27 @@ pub mod qbz_bridge {
/// Emitted with the next page of a releases bucket.
#[qsignal]
fn release_section_ready(self: Pin<&mut QbzBridge>, release_type: QString, cards_json: QString, has_more: bool);
+
+ // --- Settings (phase 10) ------------------------------------------
+ /// Toggle rows (settings_qt.rs key dispatch; persists + applies +
+ /// republishes `settingsJson`).
+ #[qinvokable]
+ fn settings_bool(self: Pin<&mut QbzBridge>, key: QString, value: bool);
+ /// Select rows: the picked OPTION INDEX within the row's list.
+ #[qinvokable]
+ fn settings_select(self: Pin<&mut QbzBridge>, key: QString, index: i32);
+ /// Slider rows (initial buffer size).
+ #[qinvokable]
+ fn settings_slider(self: Pin<&mut QbzBridge>, key: QString, value: i32);
+ /// Free-text rows (Qobuz Connect device name).
+ #[qinvokable]
+ fn settings_string(self: Pin<&mut QbzBridge>, key: QString, value: QString);
+ /// "Reset to defaults" (audio + playback stores, quality pref).
+ #[qinvokable]
+ fn settings_reset(self: Pin<&mut QbzBridge>);
+ /// Output-device refresh button: release a held device, re-enumerate.
+ #[qinvokable]
+ fn refresh_devices(self: Pin<&mut QbzBridge>);
}
impl cxx_qt::Threading for QbzBridge {}
@@ -414,6 +440,7 @@ pub struct QbzBridgeRust {
queue_json: QString,
lyrics_open: bool,
lyrics_json: QString,
+ settings_json: QString,
}
impl Default for QbzBridgeRust {
@@ -474,6 +501,7 @@ impl Default for QbzBridgeRust {
queue_json: QString::from("{}"),
lyrics_open: false,
lyrics_json: QString::from("{}"),
+ settings_json: QString::from("{}"),
}
}
}
@@ -718,4 +746,28 @@ impl qbz_bridge::QbzBridge {
pub fn load_release_section(self: Pin<&mut Self>, artist_id: QString, release_type: QString, offset: i32) {
crate::load_release_section(artist_id.to_string(), release_type.to_string(), offset);
}
+
+ pub fn settings_bool(self: Pin<&mut Self>, key: QString, value: bool) {
+ crate::settings_bool(key.to_string(), value);
+ }
+
+ pub fn settings_select(self: Pin<&mut Self>, key: QString, index: i32) {
+ crate::settings_select(key.to_string(), index);
+ }
+
+ pub fn settings_slider(self: Pin<&mut Self>, key: QString, value: i32) {
+ crate::settings_slider(key.to_string(), value);
+ }
+
+ pub fn settings_string(self: Pin<&mut Self>, key: QString, value: QString) {
+ crate::settings_string(key.to_string(), value.to_string());
+ }
+
+ pub fn settings_reset(self: Pin<&mut Self>) {
+ crate::settings_reset();
+ }
+
+ pub fn refresh_devices(self: Pin<&mut Self>) {
+ crate::refresh_devices();
+ }
}
diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs
index 1b875b8bb..3626aa423 100644
--- a/crates/qbz-qt/src/main.rs
+++ b/crates/qbz-qt/src/main.rs
@@ -20,6 +20,7 @@ mod artist_qt;
mod lyrics_qt;
mod playback_qt;
mod queue_qt;
+mod settings_qt;
mod sidebar_qt;
use std::pin::Pin;
@@ -130,6 +131,9 @@ fn enter_shell(session: auth_qt::SessionInfo) {
playback_qt::start_poll_loop(app());
// Phase 7: the sidebar playlist tree.
load_sidebar_once();
+ // Phase 10: seed the playback request tier from the persisted
+ // streaming-quality pref (Settings > Audio writes it live after this).
+ playback_qt::set_streaming_quality(&settings_qt::streaming_quality());
ui(move |mut b| {
b.as_mut().set_session_user_name(QString::from(session.display_name.as_str()));
b.as_mut().set_session_subscription(QString::from(session.subscription.as_str()));
@@ -676,6 +680,49 @@ pub(crate) fn navigate_to(view: &str) {
if view == "library" {
load_library_once();
}
+ if view == "settings" {
+ publish_settings();
+ }
+}
+
+// ============================ Settings (phase 10) =========================
+
+/// Publish the settings snapshot (settings_qt.rs SettingsDoc) onto
+/// `settingsJson`. Called on settings-view open and after every mutation
+/// (the handlers republish themselves).
+pub(crate) fn publish_settings() {
+ spawn(async { settings_qt::publish_snapshot().await });
+}
+
+pub(crate) fn settings_bool(key: String, value: bool) {
+ let runtime = app();
+ spawn(async move { settings_qt::settings_bool(&runtime, &key, value).await });
+}
+
+pub(crate) fn settings_select(key: String, index: i32) {
+ let runtime = app();
+ spawn(async move {
+ settings_qt::settings_select(&runtime, &key, index.max(0) as usize).await
+ });
+}
+
+pub(crate) fn settings_slider(key: String, value: i32) {
+ let runtime = app();
+ spawn(async move { settings_qt::settings_slider(&runtime, &key, value).await });
+}
+
+pub(crate) fn settings_string(key: String, value: String) {
+ spawn(async move { settings_qt::settings_string(&key, value).await });
+}
+
+pub(crate) fn settings_reset() {
+ let runtime = app();
+ spawn(async move { settings_qt::settings_reset(&runtime).await });
+}
+
+pub(crate) fn refresh_devices() {
+ let runtime = app();
+ spawn(async move { settings_qt::refresh_devices(&runtime).await });
}
fn load_library_once() {
diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs
index 0b60c102c..a5bbfb2c1 100644
--- a/crates/qbz-qt/src/playback_qt.rs
+++ b/crates/qbz-qt/src/playback_qt.rs
@@ -6,9 +6,9 @@
//! end-of-track advance).
//!
//! POC-NOTEs (deliberate cuts vs playback.rs, named for the effort report):
-//! - Streaming quality: the ui_prefs DEFAULT ("hires_plus" ->
-//! `Quality::UltraHiRes`). The ui_prefs store and the #638 device-cap
-//! clamp live in the Slint glue; there is no prefs UI in the POC.
+//! - Streaming quality: seeded from ui_prefs ("streaming_quality") and
+//! live-updated by Settings > Audio (settings_qt). The #638 device-cap
+//! clamp lives in the Slint glue and is NOT ported.
//! - Blacklist filtering of album tracks: skipped (store not open).
//! - Offline-cache tier (offline bytes), gapless prefetch (`play_next`),
//! prefetch warming, stop-after, infinite refill, session persist,
@@ -19,16 +19,36 @@
//! the core queue's next directly; a failed play logs and stops.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
-use std::sync::Arc;
+use std::sync::{Arc, RwLock};
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use cxx_qt_lib::QString;
use qbz_models::{Quality, QueueTrack, RepeatMode};
-/// The request tier for every play: the ui_prefs default ("hires_plus").
-/// See module docs (no prefs UI in the POC).
-const POC_QUALITY: Quality = Quality::UltraHiRes;
+/// The request tier for every play. Persisted in ui_prefs.json ("streaming_quality")
+/// and applied here from Settings > Audio (settings_qt). Default "hires_plus".
+static POC_QUALITY: RwLock = RwLock::new(Quality::UltraHiRes);
+
+/// Map a ui_prefs quality key to the request tier (settings.rs STREAMING_QUALITIES).
+fn quality_for_key(key: &str) -> Quality {
+ match key {
+ "mp3" => Quality::Mp3,
+ "cd" => Quality::Lossless,
+ "hires" => Quality::HiRes,
+ _ => Quality::UltraHiRes,
+ }
+}
+
+/// Settings > Audio > Streaming quality writes through here (also used at
+/// startup to seed from the persisted prefs).
+pub fn set_streaming_quality(key: &str) {
+ *POC_QUALITY.write().unwrap() = quality_for_key(key);
+}
+
+fn current_quality() -> Quality {
+ *POC_QUALITY.read().unwrap()
+}
// Mute bookkeeping (mirrors playback.rs MUTED / PREMUTE_VOLUME: there is no
// dedicated mute API on the core — mute is volume 0 with a stash).
@@ -152,7 +172,7 @@ pub async fn play_album_from(
log::info!("[qbz-qt] play_album: queue set ({count} tracks), playing track {first_id}");
runtime
.core()
- .play_track_resolved(first_id, POC_QUALITY, None, None, 0)
+ .play_track_resolved(first_id, current_quality(), None, None, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
log::info!("[qbz-qt] play_album: play_track_resolved started for {first_id}");
@@ -174,7 +194,7 @@ pub async fn play_album_from_track(
publish_queue(runtime).await;
runtime
.core()
- .play_track_resolved(first_id, POC_QUALITY, None, None, 0)
+ .play_track_resolved(first_id, current_quality(), None, None, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
@@ -202,7 +222,7 @@ pub async fn play_track_list(
publish_queue(runtime).await;
runtime
.core()
- .play_track_resolved(first_id, POC_QUALITY, None, None, 0)
+ .play_track_resolved(first_id, current_quality(), None, None, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
@@ -310,7 +330,7 @@ pub async fn play_single_track(
log::info!("[qbz-qt] play_single_track: playing {track_id}");
runtime
.core()
- .play_track_resolved(track_id, POC_QUALITY, None, None, 0)
+ .play_track_resolved(track_id, current_quality(), None, None, 0)
.await
.map_err(|e| format!("play_track {track_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
@@ -352,7 +372,7 @@ pub(crate) async fn play_queue_track_public(runtime: &Arc>, track_id: u64) {
if let Err(e) = runtime
.core()
- .play_track_resolved(track_id, POC_QUALITY, None, None, 0)
+ .play_track_resolved(track_id, current_quality(), None, None, 0)
.await
{
log::error!("[qbz-qt] playback: play_track {track_id} failed: {e}");
diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs
new file mode 100644
index 000000000..211d25229
--- /dev/null
+++ b/crates/qbz-qt/src/settings_qt.rs
@@ -0,0 +1,815 @@
+//! Settings > Audio + Playback controller — Slint-free port of
+//! `crates/qbz/src/settings.rs` onto the SAME public backend stores:
+//! `AudioSettingsStore` (get/set + `Player::reload_settings` /
+//! `Player::reinit_device` apply — the audio backend is PROTECTED: only
+//! these public calls), `PlaybackPreferencesStore`, the shared
+//! `ui_prefs.json` (streaming quality), and the QConnect key/value DB
+//! (rusqlite, same file as the Slint app).
+//!
+//! Also owns device enumeration (`BackendManager::create_backend(type)
+//! .enumerate_devices()` — public) with the Tauri ALSA section grouping,
+//! and the cross-setting cascades from settings.rs (dac-passthrough,
+//! streaming-only, backend switch).
+//!
+//! POC-NOTEs:
+//! - Detected device limit row (#638): the probe + its cache live in
+//! `crate::device_cap` (Slint glue) — not wired; the row stays hidden.
+//! - HiFi Wizard, JACK banner (no JACK in the POC build), settings
+//! export/import, the bit-perfect force-100 volume cascade: not wired.
+//! - qconnect startup/device-name persist to the SAME qconnect_settings.db
+//! (wired) but do NOT drive a live QConnect service (none in the POC) —
+//! they take effect on the next connection, like upstream.
+
+use std::sync::{Arc, Mutex, OnceLock};
+
+use cxx_qt_lib::QString;
+use qbz_app::settings::playback::{
+ AutoplayMode, PlaybackPreferencesState, PlaybackPreferencesStore,
+};
+use qbz_app::shell::AppRuntime;
+use qbz_audio::backend::{AlsaPlugin, AudioBackendType, BackendManager};
+use qbz_audio::settings::{AudioSettingsState, AudioSettingsStore};
+use qbz_core::LoggingAdapter;
+use serde::Serialize;
+
+// ---------------------------------------------------------------------------
+// Stores (shared files with the Slint app)
+// ---------------------------------------------------------------------------
+
+static AUDIO: OnceLock = OnceLock::new();
+static PLAYBACK: OnceLock = OnceLock::new();
+
+fn audio() -> &'static AudioSettingsState {
+ AUDIO.get_or_init(|| {
+ AudioSettingsState::new().unwrap_or_else(|e| {
+ log::warn!("[qbz-qt] audio settings store unavailable: {e}");
+ AudioSettingsState::new_empty()
+ })
+ })
+}
+
+fn playback() -> &'static PlaybackPreferencesState {
+ PLAYBACK.get_or_init(|| {
+ PlaybackPreferencesState::new().unwrap_or_else(|e| {
+ log::warn!("[qbz-qt] playback preferences store unavailable: {e}");
+ PlaybackPreferencesState::new_empty()
+ })
+ })
+}
+
+fn with_audio(f: impl FnOnce(&AudioSettingsStore) -> Result) -> Result {
+ let guard = audio().store.lock().map_err(|_| "audio store lock poisoned".to_string())?;
+ let store = guard.as_ref().ok_or_else(|| "audio settings store not open".to_string())?;
+ f(store)
+}
+
+fn with_playback(
+ f: impl FnOnce(&PlaybackPreferencesStore) -> Result,
+) -> Result {
+ let guard = playback()
+ .store
+ .lock()
+ .map_err(|_| "playback preferences lock poisoned".to_string())?;
+ let store = guard
+ .as_ref()
+ .ok_or_else(|| "playback preferences store not open".to_string())?;
+ f(store)
+}
+
+// ---------------------------------------------------------------------------
+// ui_prefs.json (streaming quality) — shared file, patched key-by-key so
+// every OTHER Slint key survives.
+// ---------------------------------------------------------------------------
+
+fn prefs_path() -> Option {
+ Some(dirs::data_dir()?.join("qbz").join("ui_prefs.json"))
+}
+
+pub fn streaming_quality() -> String {
+ let Some(path) = prefs_path() else {
+ return "hires_plus".to_string();
+ };
+ std::fs::read_to_string(path)
+ .ok()
+ .and_then(|text| serde_json::from_str::(&text).ok())
+ .and_then(|v| v.get("streaming_quality").and_then(|q| q.as_str().map(str::to_string)))
+ .unwrap_or_else(|| "hires_plus".to_string())
+}
+
+fn save_streaming_quality(key: &str) {
+ let Some(path) = prefs_path() else {
+ return;
+ };
+ let mut value: 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) = value.as_object_mut() {
+ obj.insert(
+ "streaming_quality".to_string(),
+ serde_json::Value::String(key.to_string()),
+ );
+ if let Ok(text) = serde_json::to_string_pretty(&value) {
+ 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+"];
+
+// ---------------------------------------------------------------------------
+// Snapshot
+// ---------------------------------------------------------------------------
+
+const DSD_MODE_LABELS: &[&str] = &[
+ "Convert to PCM (works everywhere)",
+ "DoP — DSD over PCM (bit-perfect)",
+ "Native DSD (kernel support required)",
+];
+const DSD_MODE_VALUES: &[&str] = &["convert", "dop", "native"];
+const ALSA_PLUGIN_LABELS: &[&str] = &["hw (Direct Hardware)", "plughw (Auto-convert)", "pcm (Most compatible)"];
+const ALSA_PLUGIN_VALUES: &[AlsaPlugin] = &[AlsaPlugin::Hw, AlsaPlugin::PlugHw, AlsaPlugin::Pcm];
+const RETRY_BEHAVIOR_LABELS: &[&str] = &["Ask me", "Always try lowest quality", "Always skip track"];
+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"];
+
+#[derive(Clone, Default, Serialize)]
+pub struct DeviceOption {
+ pub label: String,
+ pub bp: bool,
+ pub group: String,
+}
+
+#[derive(Default, Serialize)]
+pub struct SettingsDoc {
+ // Audio
+ #[serde(rename = "streamingQualities")]
+ pub streaming_qualities: Vec,
+ #[serde(rename = "streamingQualityIndex")]
+ pub streaming_quality_index: i32,
+ pub backends: Vec,
+ #[serde(rename = "backendIndex")]
+ pub backend_index: i32,
+ #[serde(rename = "backendIsAlsa")]
+ pub backend_is_alsa: bool,
+ #[serde(rename = "backendIsPipewire")]
+ pub backend_is_pipewire: bool,
+ #[serde(rename = "backendIsJack")]
+ pub backend_is_jack: bool,
+ pub devices: Vec,
+ #[serde(rename = "deviceIndex")]
+ pub device_index: i32,
+ #[serde(rename = "alsaPlugins")]
+ pub alsa_plugins: Vec,
+ #[serde(rename = "alsaPluginIndex")]
+ pub alsa_plugin_index: i32,
+ #[serde(rename = "alsaPluginIsHw")]
+ pub alsa_plugin_is_hw: bool,
+ #[serde(rename = "alsaHardwareVolume")]
+ pub alsa_hardware_volume: bool,
+ #[serde(rename = "dsdModes")]
+ pub dsd_modes: Vec,
+ #[serde(rename = "dsdModeIndex")]
+ pub dsd_mode_index: i32,
+ #[serde(rename = "limitQualityToDevice")]
+ pub limit_quality_to_device: bool,
+ #[serde(rename = "exclusiveMode")]
+ pub exclusive_mode: bool,
+ #[serde(rename = "reserveDac")]
+ pub reserve_dac: bool,
+ #[serde(rename = "dacPassthrough")]
+ pub dac_passthrough: bool,
+ #[serde(rename = "pwForceBitperfect")]
+ pub pw_force_bitperfect: bool,
+ #[serde(rename = "allowQualityFallback")]
+ pub allow_quality_fallback: bool,
+ #[serde(rename = "syncAudioOnStartup")]
+ pub sync_audio_on_startup: bool,
+ #[serde(rename = "skipSinkSwitch")]
+ pub skip_sink_switch: bool,
+ // Playback
+ #[serde(rename = "continuePlayback")]
+ pub continue_playback: bool,
+ #[serde(rename = "showContextIcon")]
+ pub show_context_icon: bool,
+ pub gapless: bool,
+ #[serde(rename = "persistSession")]
+ pub persist_session: bool,
+ #[serde(rename = "resumePosition")]
+ pub resume_position: bool,
+ #[serde(rename = "streamUncached")]
+ pub stream_uncached: bool,
+ #[serde(rename = "bufferSeconds")]
+ pub buffer_seconds: i32,
+ #[serde(rename = "streamingOnly")]
+ pub streaming_only: bool,
+ #[serde(rename = "retryBehaviors")]
+ pub retry_behaviors: Vec,
+ #[serde(rename = "retryBehaviorIndex")]
+ pub retry_behavior_index: i32,
+ #[serde(rename = "qconnectStartupModes")]
+ pub qconnect_startup_modes: Vec,
+ #[serde(rename = "qconnectStartupIndex")]
+ pub qconnect_startup_index: i32,
+ #[serde(rename = "qconnectDeviceName")]
+ pub qconnect_device_name: String,
+ #[serde(rename = "qconnectDeviceNameDefault")]
+ pub qconnect_device_name_default: String,
+}
+
+/// Index -> value maps the select handlers resolve against.
+static MAPS: Mutex<(Vec, Vec)> = Mutex::new((Vec::new(), Vec::new()));
+
+/// settings.rs `alsa_section` — Tauri dropdown sectioning for ALSA rows.
+fn alsa_section(id: &str, is_default: bool, label: &str) -> usize {
+ let id_l = id.to_ascii_lowercase();
+ if id.is_empty() || id_l == "default" || is_default {
+ 0 // Defaults
+ } else if id_l.starts_with("hw:")
+ || id_l.starts_with("iec958:")
+ || id_l.starts_with("front:card=")
+ || label.to_ascii_lowercase().contains("bit-perfect")
+ {
+ 1 // Bit-perfect (Hardware / Digital)
+ } else if id_l.starts_with("plughw:") {
+ 2 // Plugin Hardware
+ } else {
+ 3 // Other Outputs
+ }
+}
+
+fn device_is_bit_perfect(backend: AudioBackendType, device: &qbz_audio::AudioDevice) -> bool {
+ match backend {
+ AudioBackendType::Alsa => {
+ let label = device.description.as_deref().unwrap_or(&device.name);
+ alsa_section(&device.id, device.is_default, label) == 1
+ }
+ AudioBackendType::PipeWire => device.is_hardware,
+ _ => false,
+ }
+}
+
+/// Enumerate output devices for a backend (settings.rs `enumerate_devices`
+/// with the ALSA regrouping). Blocking — call off the async executor's
+/// fast path (runs inside spawn_blocking by the caller).
+fn enumerate_devices(backend: AudioBackendType) -> (Vec, Vec) {
+ let mut rows = vec![DeviceOption {
+ label: qbz_i18n::t("System default"),
+ bp: false,
+ group: String::new(),
+ }];
+ let mut ids = vec![String::new()];
+ match BackendManager::create_backend(backend).and_then(|b| b.enumerate_devices()) {
+ Ok(devices) => {
+ for d in devices {
+ let label = match d.description.as_deref() {
+ Some(desc) if !desc.is_empty() => desc.to_string(),
+ _ => d.name.clone(),
+ };
+ ids.push(d.id.clone());
+ rows.push(DeviceOption {
+ bp: device_is_bit_perfect(backend, &d),
+ label,
+ group: String::new(),
+ });
+ }
+ }
+ Err(e) => log::warn!("[qbz-qt] device enumeration failed: {e}"),
+ }
+
+ if backend == AudioBackendType::Alsa {
+ // Stable sort by section; the section header lands on each section's
+ // first row (settings.rs `group_alsa_devices`).
+ let section_labels = [
+ qbz_i18n::t("Defaults"),
+ qbz_i18n::t("Bit-perfect (Hardware / Digital)"),
+ qbz_i18n::t("Plugin Hardware"),
+ qbz_i18n::t("Other Outputs"),
+ ];
+ let mut indexed: Vec<(usize, DeviceOption, String)> = rows
+ .into_iter()
+ .zip(ids.iter().skip(1).map(|id| {
+ // ids[i] aligns with rows[i-1] after the synthetic lead;
+ // rebuilt below — simpler to recompute section per row id.
+ id.clone()
+ }))
+ .map(|(row, _)| row)
+ .enumerate()
+ .map(|(i, row)| {
+ let id = if i == 0 { String::new() } else { ids[i].clone() };
+ (alsa_section(&id, i == 0, &row.label), row, id)
+ })
+ .collect();
+ indexed.sort_by_key(|(section, _, _)| *section);
+ // Rebuild ids in the SAME order (they're the index map).
+ let mut out_rows = Vec::with_capacity(indexed.len());
+ let mut out_ids = Vec::with_capacity(indexed.len());
+ let mut prev: Option = None;
+ for (section, mut row, id) in indexed {
+ if prev != Some(section) {
+ prev = Some(section);
+ row.group = section_labels[section].clone();
+ }
+ out_rows.push(row);
+ out_ids.push(id);
+ }
+ (out_rows, out_ids)
+ } else {
+ (rows, ids)
+ }
+}
+
+fn backend_label(t: AudioBackendType) -> String {
+ match t {
+ AudioBackendType::PipeWire => "PipeWire".to_string(),
+ AudioBackendType::Alsa => "ALSA".to_string(),
+ AudioBackendType::Pulse => "PulseAudio".to_string(),
+ AudioBackendType::SystemDefault => qbz_i18n::t("System default"),
+ AudioBackendType::Jack => "JACK".to_string(),
+ }
+}
+
+/// Build + publish the full snapshot (settings.rs `build_snapshot`).
+pub async fn publish_snapshot() {
+ let audio_settings = with_audio(|s| s.get_settings()).unwrap_or_default();
+ let prefs = with_playback(|s| s.get_preferences()).unwrap_or_default();
+ let streaming_key = streaming_quality();
+
+ let doc = tokio::task::spawn_blocking(move || {
+ let backend_types = BackendManager::available_backends();
+ let current_backend = audio_settings.backend_type.unwrap_or_default();
+ let backend_index = backend_types
+ .iter()
+ .position(|t| *t == current_backend)
+ .unwrap_or(0);
+ let active_backend = backend_types
+ .get(backend_index)
+ .copied()
+ .unwrap_or_default();
+
+ let (devices, ids) = enumerate_devices(active_backend);
+ let device_index = match &audio_settings.output_device {
+ None => 0,
+ Some(id) => ids.iter().position(|d| d == id).unwrap_or(0),
+ };
+
+ let alsa_plugin = audio_settings.alsa_plugin.unwrap_or(AlsaPlugin::Hw);
+ let alsa_plugin_index = ALSA_PLUGIN_VALUES
+ .iter()
+ .position(|p| *p == alsa_plugin)
+ .unwrap_or(0);
+ let retry_behavior_index = RETRY_BEHAVIOR_VALUES
+ .iter()
+ .position(|v| *v == audio_settings.quality_fallback_behavior)
+ .unwrap_or(0);
+ let qconnect_startup = qconnect_load_startup_mode();
+ let qconnect_startup_index = QCONNECT_STARTUP_VALUES
+ .iter()
+ .position(|v| *v == qconnect_startup)
+ .unwrap_or(QCONNECT_STARTUP_VALUES.len() - 1);
+ let streaming_index = STREAMING_QUALITY_KEYS
+ .iter()
+ .position(|k| *k == streaming_key)
+ .unwrap_or(STREAMING_QUALITY_KEYS.len() - 1);
+
+ let mut maps = MAPS.lock().unwrap();
+ maps.0 = backend_types.clone();
+ maps.1 = ids;
+
+ SettingsDoc {
+ streaming_qualities: STREAMING_QUALITY_LABELS
+ .iter()
+ .map(|l| qbz_i18n::t(l))
+ .collect(),
+ streaming_quality_index: streaming_index as i32,
+ backends: std::iter::once(qbz_i18n::t("Auto"))
+ .chain(backend_types.iter().map(|t| backend_label(*t)))
+ .collect(),
+ backend_index: backend_index as i32 + 1,
+ backend_is_alsa: active_backend == AudioBackendType::Alsa,
+ backend_is_pipewire: active_backend == AudioBackendType::PipeWire,
+ backend_is_jack: active_backend == AudioBackendType::Jack,
+ devices,
+ device_index: device_index as i32,
+ alsa_plugins: ALSA_PLUGIN_LABELS
+ .iter()
+ .map(|l| qbz_i18n::t(l))
+ .collect(),
+ alsa_plugin_index: alsa_plugin_index as i32,
+ alsa_plugin_is_hw: alsa_plugin == AlsaPlugin::Hw,
+ alsa_hardware_volume: audio_settings.alsa_hardware_volume,
+ dsd_modes: DSD_MODE_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
+ dsd_mode_index: DSD_MODE_VALUES
+ .iter()
+ .position(|v| *v == audio_settings.dsd_mode)
+ .unwrap_or(0) as i32,
+ limit_quality_to_device: audio_settings.limit_quality_to_device,
+ exclusive_mode: audio_settings.exclusive_mode,
+ reserve_dac: audio_settings.reserve_dac_while_running,
+ dac_passthrough: audio_settings.dac_passthrough,
+ pw_force_bitperfect: audio_settings.pw_force_bitperfect,
+ allow_quality_fallback: audio_settings.allow_quality_fallback,
+ sync_audio_on_startup: audio_settings.sync_audio_on_startup,
+ skip_sink_switch: audio_settings.skip_sink_switch,
+ continue_playback: prefs.autoplay_mode == AutoplayMode::ContinueWithinSource,
+ show_context_icon: prefs.show_context_icon,
+ gapless: audio_settings.gapless_enabled,
+ persist_session: prefs.persist_session,
+ resume_position: prefs.resume_playback_position,
+ stream_uncached: audio_settings.stream_first_track,
+ buffer_seconds: audio_settings.stream_buffer_seconds as i32,
+ streaming_only: audio_settings.streaming_only,
+ retry_behaviors: RETRY_BEHAVIOR_LABELS
+ .iter()
+ .map(|l| qbz_i18n::t(l))
+ .collect(),
+ retry_behavior_index: retry_behavior_index as i32,
+ qconnect_startup_modes: QCONNECT_STARTUP_LABELS
+ .iter()
+ .map(|l| qbz_i18n::t(l))
+ .collect(),
+ 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(),
+ }
+ })
+ .await
+ .unwrap_or_default();
+
+ let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into());
+ crate::ui(move |mut b| {
+ b.as_mut().set_settings_json(QString::from(json.as_str()));
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Apply (settings.rs `apply_audio`) — the ONLY player touchpoints.
+// ---------------------------------------------------------------------------
+
+/// What a change requires of the live player (settings.rs `Apply`).
+enum Apply {
+ None,
+ Reload,
+ Reinit,
+}
+
+fn apply_audio(runtime: &Arc>, apply: Apply) {
+ let reinit = match apply {
+ Apply::None => return,
+ Apply::Reload => false,
+ Apply::Reinit => true,
+ };
+ let fresh = match with_audio(|s| s.get_settings()) {
+ Ok(s) => s,
+ Err(e) => {
+ log::error!("[qbz-qt] re-read audio settings failed: {e}");
+ return;
+ }
+ };
+ let player = runtime.core().player();
+ if let Err(e) = player.reload_settings(fresh.clone()) {
+ log::error!("[qbz-qt] player.reload_settings failed: {e}");
+ }
+ if reinit {
+ if let Err(e) = player.reinit_device(fresh.output_device.clone()) {
+ log::error!("[qbz-qt] player.reinit_device failed: {e}");
+ }
+ }
+ log::info!("[qbz-qt] audio settings applied to player (reinit={reinit})");
+}
+
+// ---------------------------------------------------------------------------
+// Handlers (settings.rs handle_bool / handle_select / handle_slider /
+// handle_string, including the cascades)
+// ---------------------------------------------------------------------------
+
+pub async fn settings_bool(runtime: &Arc>, key: &str, value: bool) {
+ // Cross-setting cascades (settings.rs) — force dependents off first.
+ let mut cascaded = false;
+ match key {
+ "dac-passthrough" if value => {
+ if with_audio(|s| s.set_skip_sink_switch(false)).is_ok() {
+ cascaded = true;
+ }
+ }
+ "dac-passthrough" => {
+ if with_audio(|s| s.set_pw_force_bitperfect(false)).is_ok() {
+ cascaded = true;
+ }
+ }
+ "streaming-only" if value => {
+ if with_audio(|s| s.set_gapless_enabled(false)).is_ok() {
+ cascaded = true;
+ }
+ }
+ _ => {}
+ }
+
+ let outcome: Result = match key {
+ "limit-quality-to-device" => {
+ with_audio(|s| s.set_limit_quality_to_device(value)).map(|_| Apply::Reload)
+ }
+ "alsa-hardware-volume" => {
+ with_audio(|s| s.set_alsa_hardware_volume(value)).map(|_| Apply::Reinit)
+ }
+ "exclusive-mode" => {
+ with_audio(|s| s.set_exclusive_mode(value)).map(|_| Apply::Reinit)
+ }
+ "reserve-dac" => {
+ with_audio(|s| s.set_reserve_dac_while_running(value)).map(|_| Apply::Reload)
+ }
+ "dac-passthrough" => {
+ with_audio(|s| s.set_dac_passthrough(value)).map(|_| Apply::Reinit)
+ }
+ "pw-force-bitperfect" => {
+ with_audio(|s| s.set_pw_force_bitperfect(value)).map(|_| Apply::Reload)
+ }
+ "allow-quality-fallback" => {
+ with_audio(|s| s.set_allow_quality_fallback(value)).map(|_| Apply::Reload)
+ }
+ "sync-audio-on-startup" => {
+ with_audio(|s| s.set_sync_audio_on_startup(value)).map(|_| Apply::Reload)
+ }
+ "skip-sink-switch" => {
+ with_audio(|s| s.set_skip_sink_switch(value)).map(|_| Apply::Reinit)
+ }
+ "gapless" => with_audio(|s| s.set_gapless_enabled(value)).map(|_| Apply::Reload),
+ "normalization" => {
+ with_audio(|s| s.set_normalization_enabled(value)).map(|_| Apply::Reload)
+ }
+ "stream-uncached" => {
+ with_audio(|s| s.set_stream_first_track(value)).map(|_| Apply::Reload)
+ }
+ "streaming-only" => {
+ with_audio(|s| s.set_streaming_only(value)).map(|_| Apply::Reload)
+ }
+ "continue-playback" => {
+ let mode = if value {
+ AutoplayMode::ContinueWithinSource
+ } else {
+ AutoplayMode::PlayTrackOnly
+ };
+ with_playback(|s| s.set_autoplay_mode(mode)).map(|_| Apply::None)
+ }
+ "show-context-icon" => {
+ with_playback(|s| s.set_show_context_icon(value)).map(|_| Apply::None)
+ }
+ "persist-session" => {
+ with_playback(|s| s.set_persist_session(value)).map(|_| Apply::None)
+ }
+ "resume-position" => {
+ with_playback(|s| s.set_resume_playback_position(value)).map(|_| Apply::None)
+ }
+ other => {
+ log::warn!("[qbz-qt] unknown settings bool key: {other}");
+ return;
+ }
+ };
+ match outcome {
+ Ok(apply) => {
+ let apply = if cascaded { Apply::Reinit } else { apply };
+ apply_audio(runtime, apply);
+ publish_snapshot().await;
+ }
+ Err(e) => log::error!("[qbz-qt] settings persist failed ({key}): {e}"),
+ }
+}
+
+pub async fn settings_select(runtime: &Arc>, key: &str, index: usize) {
+ match key {
+ "streaming-quality" => {
+ let Some(key) = STREAMING_QUALITY_KEYS.get(index) else {
+ return;
+ };
+ save_streaming_quality(key);
+ // Apply to the playback request tier + drop the tier-keyed cache
+ // (settings.rs: bytes fetched at the old tier must not keep
+ // serving).
+ crate::playback_qt::set_streaming_quality(key);
+ log::info!("[qbz-qt] streaming quality changed -> clearing audio cache");
+ runtime.core().player().clear_audio_cache();
+ }
+ "backend" => {
+ // Index 0 = "Auto" (resolve-and-set, #470): PipeWire when present,
+ // else System. Indices >= 1 map to the concrete backends list.
+ let backend = if index == 0 {
+ let types = MAPS.lock().unwrap().0.clone();
+ if types.iter().any(|t| *t == AudioBackendType::PipeWire) {
+ AudioBackendType::PipeWire
+ } else {
+ AudioBackendType::SystemDefault
+ }
+ } else {
+ let types = MAPS.lock().unwrap().0.clone();
+ match types.get(index - 1) {
+ Some(t) => *t,
+ None => return,
+ }
+ };
+ if let Err(e) = with_audio(|s| s.set_backend_type(Some(backend))) {
+ log::error!("[qbz-qt] persist backend failed: {e}");
+ return;
+ }
+ // Backend-switch cascade (settings.rs): routing-critical toggles
+ // that don't translate across stacks reset.
+ if backend != AudioBackendType::PipeWire {
+ let _ = with_audio(|s| s.set_dac_passthrough(false));
+ let _ = with_audio(|s| s.set_pw_force_bitperfect(false));
+ }
+ if backend != AudioBackendType::Alsa {
+ let _ = with_audio(|s| s.set_exclusive_mode(false));
+ }
+ let _ = with_audio(|s| s.set_gapless_enabled(false));
+ let _ = with_audio(|s| s.set_output_device(None));
+ apply_audio(runtime, Apply::Reinit);
+ }
+ "device" => {
+ let id = {
+ let ids = MAPS.lock().unwrap().1.clone();
+ ids.get(index).cloned()
+ };
+ let Some(id) = id else {
+ return;
+ };
+ let device_opt = if id.is_empty() { None } else { Some(id.as_str()) };
+ if let Err(e) = with_audio(|s| s.set_output_device(device_opt)) {
+ log::error!("[qbz-qt] persist output device failed: {e}");
+ return;
+ }
+ apply_audio(runtime, Apply::Reinit);
+ }
+ "dsd-mode" => {
+ let Some(mode) = DSD_MODE_VALUES.get(index) else {
+ return;
+ };
+ if let Err(e) = with_audio(|s| s.set_dsd_mode(mode)) {
+ log::error!("[qbz-qt] persist dsd mode failed: {e}");
+ return;
+ }
+ apply_audio(runtime, Apply::Reinit);
+ }
+ "alsa-plugin" => {
+ let Some(plugin) = ALSA_PLUGIN_VALUES.get(index).copied() else {
+ return;
+ };
+ if let Err(e) = with_audio(|s| s.set_alsa_plugin(Some(plugin))) {
+ log::error!("[qbz-qt] persist alsa plugin failed: {e}");
+ return;
+ }
+ apply_audio(runtime, Apply::Reinit);
+ }
+ "retry-behavior" => {
+ let Some(behavior) = RETRY_BEHAVIOR_VALUES.get(index) else {
+ return;
+ };
+ if let Err(e) = with_audio(|s| s.set_quality_fallback_behavior(behavior)) {
+ log::error!("[qbz-qt] persist retry behavior failed: {e}");
+ return;
+ }
+ apply_audio(runtime, Apply::Reload);
+ }
+ "qconnect-startup" => {
+ let Some(mode) = QCONNECT_STARTUP_VALUES.get(index) else {
+ return;
+ };
+ qconnect_persist_startup_mode(mode);
+ }
+ other => log::warn!("[qbz-qt] unknown settings select key: {other}"),
+ }
+ publish_snapshot().await;
+}
+
+pub async fn settings_slider(runtime: &Arc>, key: &str, value: i32) {
+ if key == "buffer-seconds" {
+ let seconds = value.clamp(1, 10) as u8;
+ match with_audio(|s| s.set_stream_buffer_seconds(seconds)) {
+ Ok(()) => apply_audio(runtime, Apply::Reload),
+ Err(e) => log::error!("[qbz-qt] persist buffer seconds failed: {e}"),
+ }
+ }
+ publish_snapshot().await;
+}
+
+pub async fn settings_string(key: &str, value: String) {
+ if key == "qconnect-device-name" {
+ let trimmed = value.trim().to_string();
+ let stored = (!trimmed.is_empty()).then_some(trimmed.as_str());
+ qconnect_persist_device_name(stored);
+ }
+ publish_snapshot().await;
+}
+
+/// "Reset to defaults" — restores Audio + Playback defaults
+/// (settings.rs handle_reset: store resets + apply + snapshot).
+pub async fn settings_reset(runtime: &Arc>) {
+ if let Err(e) = with_audio(|s| s.reset_all().map(|_| ())) {
+ log::error!("[qbz-qt] audio settings reset failed: {e}");
+ }
+ if let Err(e) = with_playback(|s| s.reset_all().map(|_| ())) {
+ log::error!("[qbz-qt] playback preferences reset failed: {e}");
+ }
+ save_streaming_quality("hires_plus");
+ crate::playback_qt::set_streaming_quality("hires_plus");
+ apply_audio(runtime, Apply::Reinit);
+ publish_snapshot().await;
+}
+
+/// The refresh/release button next to the output device (settings.rs:
+/// frees a held ALSA-exclusive device and re-enumerates).
+pub async fn refresh_devices(runtime: &Arc>) {
+ // Release whatever the player holds, then rebuild the snapshot.
+ let player = runtime.core().player();
+ if let Err(e) = player.release_device() {
+ log::warn!("[qbz-qt] release device failed: {e}");
+ }
+ publish_snapshot().await;
+}
+
+// ---------------------------------------------------------------------------
+// QConnect key/value DB (same file as the Slint app)
+// ---------------------------------------------------------------------------
+
+fn qconnect_conn() -> Option {
+ let db_path = qbz_app::qconnect_identity::qconnect_settings_db_path()?;
+ let conn = rusqlite::Connection::open(&db_path).ok()?;
+ conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
+ .ok()?;
+ conn.execute_batch(
+ "CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ )",
+ )
+ .ok()?;
+ Some(conn)
+}
+
+fn qconnect_load_startup_mode() -> String {
+ qconnect_conn()
+ .and_then(|conn| {
+ conn.query_row(
+ "SELECT value FROM settings WHERE key = 'startup_mode'",
+ [],
+ |row| row.get::<_, String>(0),
+ )
+ .ok()
+ })
+ .unwrap_or_else(|| "off".to_string())
+}
+
+fn qconnect_persist_startup_mode(mode: &str) {
+ if let Some(conn) = qconnect_conn() {
+ let _ = conn.execute(
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('startup_mode', ?1)",
+ rusqlite::params![mode],
+ );
+ }
+}
+
+fn qconnect_load_device_name() -> Option {
+ qconnect_conn().and_then(|conn| {
+ conn.query_row(
+ "SELECT value FROM settings WHERE key = 'device_name'",
+ [],
+ |row| row.get::<_, String>(0),
+ )
+ .ok()
+ .filter(|v: &String| !v.trim().is_empty())
+ })
+}
+
+fn qconnect_persist_device_name(name: Option<&str>) {
+ if let Some(conn) = qconnect_conn() {
+ match name {
+ Some(n) => {
+ let _ = conn.execute(
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('device_name', ?1)",
+ rusqlite::params![n],
+ );
+ }
+ None => {
+ let _ = conn.execute("DELETE FROM settings WHERE key = 'device_name'", []);
+ }
+ }
+ }
+}
+
+fn qconnect_default_name() -> String {
+ // qconnect_transport::resolve_qconnect_friendly_name(None): env var, else
+ // "Qbz - {hostname}".
+ std::env::var("QBZ_QCONNECT_DEVICE_NAME").unwrap_or_else(|_| {
+ let host = std::env::var("HOSTNAME")
+ .ok()
+ .filter(|s| !s.is_empty())
+ .or_else(|| {
+ std::fs::read_to_string("/etc/hostname")
+ .ok()
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ })
+ .unwrap_or_else(|| "device".to_string());
+ format!("Qbz - {host}")
+ })
+}