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}") + }) +}