Files
qbz/crates/qbz-qt/qml/settings/SettingsView.qml
T

600 lines
28 KiB
QML

// Settings — the QML port of crates/qbz-ui/ui/settings/SettingsView.slint:
// a 92px title header, a 232px left sub-navigation, and the active panel in
// a touch-draggable Flickable with a ListScrollbar replica.
//
// This file is the COMPOSITION ROOT only. Every section is its own file in
// this directory, 1:1 with the Slint's own per-section .slint files:
//
// 0 Audio -> AudioSettings.qml
// 1 Playback -> PlaybackSettings.qml
// 2 Appearance -> AppearanceSettings.qml
// 3 Offline -> OfflineSettings.qml
// 4 Local Library -> LocalLibrarySettings.qml (+ PlexSettings.qml)
// 5 Blacklist -> BlacklistSettings.qml
// 6 Integrations -> IntegrationsSettings.qml
// 7 Developer -> DeveloperSettings.qml
// 8 Flatpak/Snap -> SandboxSettings.qml (only on a sandboxed install)
// 9 Import/Export -> ImportExportSettings.qml (shown between
// Integrations and Developer; index 9 keeps the
// persisted section numbers of 0-8 stable)
//
// 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).
//
// KEYBOARD FOCUS CONTRACT (for every new setting): Qt Quick has no HTML-like
// numeric tabindex; its tab index is the declaration order of enabled,
// visible items with `activeFocusOnTab`. Therefore settings and their controls
// MUST be declared in the same top-to-bottom order in which they are drawn and
// MUST use the shared QbzToggle/QbzSelect/QbzSlider/QbzLineEdit/SettingsButton/
// QbzCheckbox controls. Do not put a bare MouseArea on a new setting action:
// the shared controls provide the focus ring and Enter/Space activation, and
// accept Space so it cannot bubble into AppShell's global play/pause hotkey.
// If a custom interactive item is genuinely necessary, it inherits this same
// contract. This comment is intentionally at the composition root so both
// contributors and coding agents encounter it when adding a settings row.
//
// The NavButtons row inside the 92px header is a 0px placeholder — nav
// history lives in the global HeaderBar in this port, like every other view.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
property bool kioskHost: false
id: root
QbzTheme { id: theme }
// The parsed settingsJson document ({} until the first publish lands).
property var doc: ({})
// Active sub-section (display order — see the header comment).
//
// A BINDING onto bridge state, never local truth. The content Loader
// unmounts this whole view when the user navigates away, so a plain
// `property int section: 0` was lost on every round trip — the Blacklist
// panel's "Manage" chevron opens the manager VIEW, and coming Back landed
// on Audio. See `bridge.rs`'s note on `settings_section`.
//
// Consequently every sub-nav row calls `QbzBridge.settingsSetSection(n)`
// and NOTHING assigns `root.section` directly: in QML an imperative
// assignment destroys the binding it lands on, so a single surviving
// `root.section = n` would work once and then re-introduce the bug one
// navigation later, in a way that looks fixed under casual testing.
readonly property var kioskSections: [
{ label: QbzSession.tr("Audio", QbzSession.trRev), section: 0 },
{ label: QbzSession.tr("Playback", QbzSession.trRev), section: 1 },
{ label: QbzSession.tr("Appearance", QbzSession.trRev), section: 2 },
{ label: QbzSession.tr("Navigation", QbzSession.trRev), section: 10 },
{ label: QbzSession.tr("Offline", QbzSession.trRev), section: 3 },
{ label: QbzSession.tr("Local Library", QbzSession.trRev), section: 4 },
{ label: QbzSession.tr("Blacklist", QbzSession.trRev), section: 5 },
{ label: QbzSession.tr("Integrations", QbzSession.trRev), section: 6 },
{ label: QbzSession.tr("Import / Export", QbzSession.trRev), section: 9 },
{ label: QbzSession.tr("Updates", QbzSession.trRev), section: 12 },
{ label: QbzSession.tr("Developer", QbzSession.trRev), section: 7 }
].concat(QbzOrbit.enabled ? [{ label: "Orbit", section: 11 }] : []).concat(root.sandboxed ? [{ label: (doc.dev || ({})).installMethod === "snap"
? QbzSession.tr("Snap", QbzSession.trRev) : QbzSession.tr("Flatpak", QbzSession.trRev), section: 8 }] : [])
readonly property int section: QbzBridge.settingsSection === 11 && !QbzOrbit.enabled ? 0 : QbzBridge.settingsSection
readonly property bool migrationRunning:
(doc.importExport || ({})).migrationRunning === true
readonly property bool sandboxed: {
const m = (doc.dev || ({})).installMethod || ""
return m === "flatpak" || m === "snap"
}
function reload() {
try {
root.doc = JSON.parse(QbzBridge.settingsJson)
} catch (e) {
root.doc = ({})
}
}
Component.onCompleted: reload()
Connections {
target: QbzBridge
function onSettingsJsonChanged() { root.reload() }
}
// ============================ the view ================================
Column {
id: settingsContent
anchors.fill: parent
spacing: 0
// A migration may be hidden, but its settings tree remains inert until
// Rust finishes. Global navigation is outside this view and stays live.
enabled: !root.migrationRunning
// --- Header (92px; NavButtons is a 0px placeholder in this port) --
Item {
width: parent.width
height: root.kioskHost ? 64 : 92
Text {
x: root.kioskHost ? 16 : 32
// padding-top 11 + 12px gap below the (0px) NavButtons row.
y: root.kioskHost ? (64 - height) / 2 : 23
text: QbzSession.tr("Settings", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: root.kioskHost ? (theme.fontTitle) * 1.2 : (theme.fontTitle)
font.weight: theme.weightBold
}
// The kiosk section selector. The "Share logs" icon button that
// used to sit to its right is gone: it called QbzShell.logOpen(),
// whose viewer modal is mounted by the desktop AppShell only, so
// on the kiosk it was a dead control (owner report 2026-09-07).
QbzSelect {
visible: root.kioskHost
kioskHost: true
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
menuWidth: Math.max(180, Math.min(300, parent.width - 270))
searchable: options.length > 8
options: root.kioskSections.map(function (s) { return s.label })
currentIndex: root.kioskSections.findIndex(function (s) { return s.section === root.section })
onSelected: function (i) {
QbzBridge.settingsSetSection(root.kioskSections[i].section)
flick.contentY = 0
}
}
}
// --- Sub-nav + active panel ---------------------------------------
Row {
width: parent.width
height: parent.height - (root.kioskHost ? 64 : 92)
// Left sub-navigation (232px).
Item {
id: subNav
visible: !root.kioskHost
width: root.kioskHost ? 0 : 232
height: parent.height
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"
activeFocusOnTab: visible && enabled
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.name: label
Accessible.onPressAction: clicked()
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
clicked()
event.accepted = true
}
}
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
// Theme tokens on both arms — the row sits on
// surface-elevated / surface-hover, so the active
// glyph is "textPrimary" and NOT the fixed-white
// "primary" bake (white-on-white on light themes).
tintName: parent.parent.active ? "textPrimary" : "secondary"
}
Text {
height: parent.height
text: parent.parent.label
color: parent.parent.active ? theme.textPrimary : theme.textSecondary
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (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
onPressed: parent.forceActiveFocus()
onClicked: parent.clicked()
}
}
Column {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 24
anchors.rightMargin: 12
anchors.topMargin: 4
spacing: 4
SubNavItem {
name: "volume-2"
label: QbzSession.tr("Audio", QbzSession.trRev)
active: root.section === 0
onClicked: QbzBridge.settingsSetSection(0)
}
SubNavItem {
name: "play-fill"
label: QbzSession.tr("Playback", QbzSession.trRev)
active: root.section === 1
onClicked: QbzBridge.settingsSetSection(1)
}
SubNavItem {
name: "layers"
label: QbzSession.tr("Appearance", QbzSession.trRev)
active: root.section === 2
onClicked: QbzBridge.settingsSetSection(2)
}
SubNavItem {
name: "compass"
label: QbzSession.tr("Navigation", QbzSession.trRev)
active: root.section === 10
onClicked: QbzBridge.settingsSetSection(10)
}
SubNavItem {
name: "cloud-download"
label: QbzSession.tr("Offline", QbzSession.trRev)
active: root.section === 3
onClicked: QbzBridge.settingsSetSection(3)
}
SubNavItem {
name: "hard-drive"
label: QbzSession.tr("Local Library", QbzSession.trRev)
active: root.section === 4
onClicked: QbzBridge.settingsSetSection(4)
}
SubNavItem {
name: "blind-eye"
label: QbzSession.tr("Blacklist", QbzSession.trRev)
active: root.section === 5
onClicked: QbzBridge.settingsSetSection(5)
}
SubNavItem {
name: "refresh-cw"
label: QbzSession.tr("Integrations", QbzSession.trRev)
active: root.section === 6
onClicked: QbzBridge.settingsSetSection(6)
}
SubNavItem {
name: "import"
label: QbzSession.tr("Import / Export", QbzSession.trRev)
active: root.section === 9
onClicked: QbzBridge.settingsSetSection(9)
}
SubNavItem {
name: "refresh-cw"
label: QbzSession.tr("Updates", QbzSession.trRev)
active: root.section === 12
onClicked: QbzBridge.settingsSetSection(12)
}
SubNavItem {
name: "bug"
label: QbzSession.tr("Developer", QbzSession.trRev)
active: root.section === 7
onClicked: QbzBridge.settingsSetSection(7)
}
SubNavItem {
visible: QbzOrbit.enabled
name: "compass"
label: "Orbit"
active: root.section === 11
onClicked: QbzBridge.settingsSetSection(11)
}
// Sandboxed installs only (Flatpak / Snap permissions).
SubNavItem {
visible: root.sandboxed
name: "info"
label: (root.doc.dev || ({})).installMethod === "snap"
? QbzSession.tr("Snap", QbzSession.trRev)
: QbzSession.tr("Flatpak", QbzSession.trRev)
active: root.section === 8
onClicked: QbzBridge.settingsSetSection(8)
}
}
// Always-visible log affordance, pinned to the bottom of the
// sub-nav (people filing issues could not find it under
// Developer). Opens the on-disk log — see DeveloperSettings.
Column {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 24
anchors.rightMargin: 12
anchors.bottomMargin: 16
SubNavItem {
name: "cloud-upload"
label: QbzSession.tr("Share logs", QbzSession.trRev)
active: false
// Opens the VIEWER (filter / search / copy / bundle /
// upload), 1:1 with the reference. It used to hand the
// raw file to the desktop's file manager, which is
// what "Open log file" in Developer still does.
onClicked: QbzShell.logOpen()
}
}
}
// Active panel — a raw Flickable (touch-drag scroll on the Pi
// kiosk, per the Slint comment) + a ListScrollbar replica.
Item {
width: parent.width - subNav.width
height: parent.height
Flickable {
id: flick
anchors.fill: parent
contentWidth: width
contentHeight: panelCol.height + 60
clip: true
boundsBehavior: Flickable.StopAtBounds
// Tab can move into a control below the viewport. Reveal
// that control as focus changes so keyboard navigation is
// never technically correct but visually lost offscreen.
function revealFocusItem(item) {
if (!item)
return
var ancestor = item
while (ancestor && ancestor !== panelCol)
ancestor = ancestor.parent
if (ancestor !== panelCol)
return
const point = item.mapToItem(flick.contentItem, 0, 0)
const margin = 12
if (point.y < flick.contentY + margin)
flick.contentY = Math.max(0, point.y - margin)
else if (point.y + item.height > flick.contentY + flick.height - margin)
flick.contentY = Math.min(
Math.max(0, flick.contentHeight - flick.height),
point.y + item.height - flick.height + margin)
}
Connections {
target: root.Window.window
function onActiveFocusItemChanged() {
const win = root.Window.window
Qt.callLater(function () {
flick.revealFocusItem(win ? win.activeFocusItem : null)
})
}
}
Column {
id: panelCol
x: root.kioskHost ? 16 : 20
y: 4
width: flick.width - (root.kioskHost ? 32 : 60) // 20 left + 40 right padding
spacing: 4
// ── ONE PANEL IS BUILT, NOT NINE ──────────────
//
// These used to be nine siblings gated only by
// `visible:`, which in QML hides an item and does NOT
// stop it from being instantiated — the same defect
// 2026-08-17 found in HomeView's four Discover tabs.
// So every mount of Settings built the 30 KB
// Appearance panel, the 19 KB Plex panel and the rest,
// to show one of them. Measured 2026-08-21: Settings
// was the slowest route in the app at `built=326ms`.
//
// A Loader with `active:` is the fix that shipped for
// the tabs and it is the fix here. `visible: active`
// keeps the Column from reserving a slot for the eight
// that are not mounted. NOT `asynchronous: true` — see
// shell/ContentRouter.qml for why that was reverted.
//
// The two overlay modals below (LibFolderEditModal,
// DacWizardModal) are still built unconditionally and
// are the next item of the same kind; they own their
// own open state, so gating them is a separate change.
component Panel: Loader {
/// Deliberately NOT called `section`: the view root
/// already has one, and `root.section === section`
/// inside an inline component is a name-resolution
/// question nobody should have to answer.
property int panelIndex: -1
active: root.section === panelIndex
visible: active
width: parent ? parent.width : 0
}
Panel {
panelIndex: 12
sourceComponent: Component {
UpdatesSettings { width: parent.width; kioskHost: root.kioskHost }
}
}
OrbitSettingsPanel {
width: parent.width
selected: root.section === 11
kioskHost: root.kioskHost
}
Panel {
panelIndex: 0
sourceComponent: Component {
AudioSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
}
}
}
Panel {
panelIndex: 1
sourceComponent: Component {
PlaybackSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
}
}
}
Panel {
panelIndex: 2
sourceComponent: Component {
AppearanceSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
}
}
}
Panel {
panelIndex: 10
sourceComponent: Component {
NavigationSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
}
}
}
Panel {
panelIndex: 3
sourceComponent: Component {
OfflineSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
confirmHost: settingsConfirmHost
}
}
}
Panel {
panelIndex: 4
sourceComponent: Component {
LocalLibrarySettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
confirmHost: settingsConfirmHost
tabOrderModal: localTabsConfigModal
}
}
}
Panel {
panelIndex: 5
sourceComponent: Component {
BlacklistSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
}
}
}
Panel {
panelIndex: 6
sourceComponent: Component {
IntegrationsSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
confirmHost: settingsConfirmHost
}
}
}
Panel {
panelIndex: 9
sourceComponent: Component {
ImportExportSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
migrationSetupModal: migrationSetupOverlay
}
}
}
Panel {
panelIndex: 7
sourceComponent: Component {
DeveloperSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
}
}
}
Panel {
panelIndex: 8
active: root.section === 8 && root.sandboxed
sourceComponent: Component {
SandboxSettings { kioskHost: root.kioskHost;
width: parent.width
doc: root.doc
}
}
}
}
}
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: flick; scope: "settings" }
QbzScrollBar {
target: flick
anchors.right: parent.right
anchors.rightMargin: 2
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
// Danger-zone confirmations for the whole view — declared LAST so
// declaration order alone puts it over every panel and the sub-nav.
// Panels that need it are handed the reference (see LocalLibrarySettings
// / PlexSettings `confirmHost`).
SettingsConfirmHost { kioskHost: root.kioskHost; id: settingsConfirmHost }
// App-wide Local Library order. Mounted at the view root so its scrim
// covers the sub-navigation and the scrolled panel alike.
LocalTabsConfigModal { kioskHost: root.kioskHost;
id: localTabsConfigModal
anchors.fill: parent
}
// The per-folder settings modal. Same reasoning as the confirm host: it
// must overlay the whole view, so it cannot live inside the scrolled
// panel that opens it. The reference mounts its counterpart at the
// AppShell root for exactly the same reason (LibFolderEditModal.slint:5-8).
LibFolderEditModal { kioskHost: root.kioskHost; doc: root.doc }
// The HiFi Wizard, opened from Settings > Audio. Mounted here for the same
// reason as the two above — a modal inside the scrolled panel would be
// sized by the content and ride the scroll — and it reads its OWN document
// (QbzDacWizard), not the settings one, so it takes no `doc`.
//
// It fills the view rather than the window: Settings is the full content
// area, and the reference is an overlay inside the app shell too, not a
// separate window (DacWizardModal.slint:8-9).
DacWizardModal { kioskHost: root.kioskHost; }
MigrationSetupModal { kioskHost: root.kioskHost;
id: migrationSetupOverlay
anchors.fill: parent
doc: root.doc
confirmHost: settingsConfirmHost
}
// Mounted last so a long account migration has one unmistakable progress
// surface over every Settings section. Closing it never cancels the task.
MigrationProgressModal { kioskHost: root.kioskHost;
anchors.fill: parent
doc: root.doc
}
}