The last unported row of Settings > Audio. Read-only by design: a Hz dropdown is the documented root-cause trap, because it promises rates the app cannot request when Qobuz sells four discrete tiers. The value is Rust-composed from the probe and the row hides on an empty summary; the fallback caveat renders only when the probe fell back to the common set. No new msgids -- both strings already exist, translated, in all 8 catalogs. Six refresh triggers, one more than the reference: startup, the toggle, a backend switch, an output-device change, reset-to-defaults, and the device refresh/release button. That last one is Qt-only and the easiest to miss -- it is the hotplug case, where the hardware behind a "System default" selection changes without the selection changing. Ordering is persist -> refresh -> publish, and the refresh goes BEFORE apply_audio rather than merely before the handler's closing publish: apply_audio publishes on its own way out, an earlier serialize that would ship a stale summary. The symptom of getting this wrong is a row that is only right after reopening Settings. The boot trigger publishes the snapshot itself after awaiting the refresh. The boot snapshot is spawned separately and can serialize first, which left the row absent for a user who already had the toggle on until some unrelated mutation happened to republish. A cap change drops the tier-keyed audio cache, symmetric with the streaming -quality change: the cache is quality-blind, so bytes fetched under the old tier would keep serving and the feature would be inert on anything already played. The first refresh of a process is exempt -- see the CapChange classification, which exists because clearing there wiped the disk cache on every launch. cc
272 lines
12 KiB
QML
272 lines
12 KiB
QML
// Settings > Audio — the QML port of crates/qbz-ui/ui/settings/
|
|
// AudioSettings.slint. Group order, labels, descriptions, gating and control
|
|
// types are 1:1 with the Slint; every control rides the single settingsJson
|
|
// document (root.doc) and the settingsBool/Select invokables — never local
|
|
// truth. The audio path itself is PROTECTED: this panel only calls the
|
|
// settings keys settings_qt.rs already dispatches.
|
|
//
|
|
// "Detected device limit" + its fallback disclosure (#638 fix 3) ARE shipped
|
|
// (2026-08-17): the probe moved out of the Slint binary crate into
|
|
// `qbz-app::device_cap`, and both rows read the cap cache off the settings
|
|
// document. They are read-only by design — see the comment at the rows.
|
|
//
|
|
// The HiFi Wizard IS shipped (2026-08-11): the last row of OUTPUT opens
|
|
// settings/DacWizardModal.qml, mounted at the SettingsView root. Its logic
|
|
// lives in `qbz-dac-wizard-core`, the frontend-agnostic crate extracted for
|
|
// this port. Placement and backend gating diverge from the reference — see
|
|
// the row itself.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../controls"
|
|
import "../theme"
|
|
|
|
Column {
|
|
id: root
|
|
|
|
property var doc: ({})
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
spacing: 4
|
|
|
|
// ============================ STREAMING ==============================
|
|
GroupHeader { text: QbzSession.tr("STREAMING", QbzSession.trRev) }
|
|
SettingRow {
|
|
label: QbzSession.tr("Streaming quality", QbzSession.trRev)
|
|
description: QbzSession.tr("The quality tier QBZ requests for playback.", QbzSession.trRev)
|
|
QbzSelect {
|
|
menuWidth: 200
|
|
options: root.doc.streamingQualities || []
|
|
currentIndex: root.doc.streamingQualityIndex || 0
|
|
onSelected: function (i) { QbzBridge.settingsSelect("streaming-quality", i) }
|
|
}
|
|
}
|
|
SettingRow {
|
|
label: QbzSession.tr("Limit quality to device", QbzSession.trRev)
|
|
description: QbzSession.tr("Cap the requested streaming quality at your output device's limit. Applies to local playback only, never to casting.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.limitQualityToDevice === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("limit-quality-to-device", v) }
|
|
}
|
|
}
|
|
// Read-only detected line — deliberately NOT a control (#638 fix 3): a Hz
|
|
// dropdown was the documented root-cause trap (it promised rates the app
|
|
// cannot request; Qobuz sells four discrete tiers), and with real detection
|
|
// there is nothing left to pick. The value is Rust-composed from the probe
|
|
// ("192 kHz · Hi-Res+") and is empty while nothing is cached, which is what
|
|
// hides the row rather than showing a blank value.
|
|
SettingRow {
|
|
visible: root.doc.limitQualityToDevice === true
|
|
&& (root.doc.deviceCapSummary || "") !== ""
|
|
label: QbzSession.tr("Detected device limit", QbzSession.trRev)
|
|
Text {
|
|
text: root.doc.deviceCapSummary || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontBody
|
|
font.weight: theme.weightMedium
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
// Fallback disclosure (#638 Decision B): plain informative copy — no
|
|
// warning icon, no error styling. The cap still applies on the assumed
|
|
// common set; it just is not measured truth for this hardware.
|
|
Text {
|
|
visible: root.doc.limitQualityToDevice === true
|
|
&& (root.doc.deviceCapSummary || "") !== ""
|
|
&& root.doc.deviceCapDetected === false
|
|
width: parent.width
|
|
text: QbzSession.tr("Your device's exact limits could not be read on this system, so a common set is assumed. The cap still applies, but may not match your hardware exactly.", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
SettingsDivider { }
|
|
SettingsSpacer { }
|
|
|
|
// ============================= OUTPUT ================================
|
|
GroupHeader { text: QbzSession.tr("OUTPUT", QbzSession.trRev) }
|
|
SettingRow {
|
|
label: QbzSession.tr("Audio backend", QbzSession.trRev)
|
|
description: QbzSession.tr("The audio stack QBZ routes playback through.", QbzSession.trRev)
|
|
QbzSelect {
|
|
menuWidth: 220
|
|
options: root.doc.backends || []
|
|
currentIndex: root.doc.backendIndex || 0
|
|
onSelected: function (i) { QbzBridge.settingsSelect("backend", i) }
|
|
}
|
|
}
|
|
// JACK routes through the JACK graph (which resamples to the graph rate),
|
|
// so it can never be bit-perfect — say so instead of letting the
|
|
// bit-perfect toggles below imply otherwise.
|
|
WarningBanner {
|
|
visible: root.doc.backendIsJack === true
|
|
variant: "warning"
|
|
title: QbzSession.tr("JACK is not bit-perfect", QbzSession.trRev)
|
|
body: QbzSession.tr("The JACK backend routes audio through the JACK graph, which resamples to the graph's sample rate. For bit-perfect playback use ALSA (direct) or PipeWire with passthrough.", QbzSession.trRev)
|
|
}
|
|
SettingRow {
|
|
label: QbzSession.tr("Output device", QbzSession.trRev)
|
|
description: QbzSession.tr("The DAC or sound device that receives audio.", QbzSession.trRev)
|
|
Row {
|
|
spacing: 8
|
|
// Refresh / release: frees a device QBZ holds exclusively (ALSA
|
|
// direct) and re-enumerates, so a freed or hot-plugged DAC shows
|
|
// up without an app restart.
|
|
SettingsButton {
|
|
iconName: "refresh-cw"
|
|
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
|
|
label: QbzSession.tr("ALSA plugin", QbzSession.trRev)
|
|
description: QbzSession.tr("How ALSA opens the device — hw is bit-perfect, plughw converts.", QbzSession.trRev)
|
|
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
|
|
label: QbzSession.tr("Hardware volume control", QbzSession.trRev)
|
|
description: QbzSession.tr("Use the ALSA mixer for volume instead of software gain.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.alsaHardwareVolume === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("alsa-hardware-volume", v) }
|
|
}
|
|
}
|
|
SettingRow {
|
|
visible: root.doc.backendIsAlsa === true
|
|
label: QbzSession.tr("DSD playback", QbzSession.trRev)
|
|
description: QbzSession.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.", QbzSession.trRev)
|
|
QbzSelect {
|
|
menuWidth: 280
|
|
options: root.doc.dsdModes || []
|
|
currentIndex: root.doc.dsdModeIndex || 0
|
|
onSelected: function (i) { QbzBridge.settingsSelect("dsd-mode", i) }
|
|
}
|
|
}
|
|
// HiFi Wizard — guided bit-perfect DAC setup. The LAST row of OUTPUT,
|
|
// under "Output device", and shown for EVERY backend.
|
|
//
|
|
// TWO DIVERGENCES FROM THE REFERENCE, both owner rulings (2026-08-11):
|
|
// it lives in OUTPUT, not at the end of BIT-PERFECT; and it is NOT gated
|
|
// on `backend-is-pipewire` the way `AudioSettings.slint:282` gates it.
|
|
// The reference hides it off PipeWire because the config it generates is
|
|
// PipeWire/WirePlumber — but that is exactly who needs it: someone on ALSA
|
|
// direct who wants to set their DAC up cannot reach the wizard that would
|
|
// walk them through it. Do not "restore parity" by putting the gate back.
|
|
//
|
|
// Opening it resets the wizard and kicks the audio-stack probe off the UI
|
|
// thread, so there is nothing to arm here.
|
|
SettingRow {
|
|
label: QbzSession.tr("HiFi Wizard", QbzSession.trRev)
|
|
description: QbzSession.tr("Auto-detect your DACs and set up bit-perfect playback, step by step.", QbzSession.trRev)
|
|
SettingsButton {
|
|
iconName: "gandalf"
|
|
text: QbzSession.tr("Open Wizard", QbzSession.trRev)
|
|
onClicked: QbzDacWizard.open()
|
|
}
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
SettingsDivider { }
|
|
SettingsSpacer { }
|
|
|
|
// =========================== BIT-PERFECT =============================
|
|
GroupHeader { text: QbzSession.tr("BIT-PERFECT", QbzSession.trRev) }
|
|
SettingRow {
|
|
label: QbzSession.tr("Exclusive mode", QbzSession.trRev)
|
|
description: QbzSession.tr("Lock the device so no other app can resample it.", QbzSession.trRev)
|
|
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: QbzSession.tr("Reserve DAC while running", QbzSession.trRev)
|
|
description: QbzSession.tr("Hold the device reserved so other apps can't grab it.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.reserveDac === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("reserve-dac", v) }
|
|
}
|
|
}
|
|
SettingRow {
|
|
label: QbzSession.tr("DAC passthrough", QbzSession.trRev)
|
|
description: QbzSession.tr("Send the bitstream untouched to the DAC.", QbzSession.trRev)
|
|
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
|
|
label: QbzSession.tr("Force bit-perfect", QbzSession.trRev)
|
|
description: QbzSession.tr("Pin the PipeWire quantum and rate for the active track.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.pwForceBitperfect === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("pw-force-bitperfect", v) }
|
|
}
|
|
}
|
|
SettingRow {
|
|
label: QbzSession.tr("Allow quality fallback", QbzSession.trRev)
|
|
description: QbzSession.tr("Drop to a lower tier when the requested one is unavailable.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.allowQualityFallback === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("allow-quality-fallback", v) }
|
|
}
|
|
}
|
|
|
|
SettingsSpacer { }
|
|
SettingsDivider { }
|
|
SettingsSpacer { }
|
|
|
|
// ============================= STARTUP ===============================
|
|
GroupHeader { text: QbzSession.tr("STARTUP", QbzSession.trRev) }
|
|
SettingRow {
|
|
label: QbzSession.tr("Sync audio settings on startup", QbzSession.trRev)
|
|
description: QbzSession.tr("Reload saved audio settings into the player when QBZ launches.", QbzSession.trRev)
|
|
QbzToggle {
|
|
checked: root.doc.syncAudioOnStartup === true
|
|
onToggled: function (v) { QbzBridge.settingsBool("sync-audio-on-startup", v) }
|
|
}
|
|
}
|
|
SettingRow {
|
|
visible: root.doc.backendIsPipewire === true
|
|
label: QbzSession.tr("Lock output device", QbzSession.trRev)
|
|
description: QbzSession.tr("Keep external routing intact — skip switching the default sink.", QbzSession.trRev)
|
|
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.
|
|
SettingsButton {
|
|
text: QbzSession.tr("Reset to defaults", QbzSession.trRev)
|
|
onClicked: QbzBridge.settingsReset()
|
|
}
|
|
}
|