Add the chained genre, artist, and album browser with persistent layout and tab ordering. Reuse the shared album semantics for grouped versions and disc-aware details, enforce active source filters, and bound foreground detail work so chained searches stay responsive. cx
425 lines
19 KiB
QML
425 lines
19 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)
|
|
//
|
|
// 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).
|
|
//
|
|
// 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 {
|
|
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 int section: QbzBridge.settingsSection
|
|
|
|
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 {
|
|
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: QbzSession.tr("Settings", QbzSession.trRev)
|
|
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 {
|
|
id: subNav
|
|
width: 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"
|
|
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: 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()
|
|
}
|
|
}
|
|
|
|
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: "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: "bug"
|
|
label: QbzSession.tr("Developer", QbzSession.trRev)
|
|
active: root.section === 7
|
|
onClicked: QbzBridge.settingsSetSection(7)
|
|
}
|
|
// 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 - 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
|
|
|
|
// ── 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: 0
|
|
sourceComponent: Component {
|
|
AudioSettings {
|
|
width: parent.width
|
|
doc: root.doc
|
|
}
|
|
}
|
|
}
|
|
Panel {
|
|
panelIndex: 1
|
|
sourceComponent: Component {
|
|
PlaybackSettings {
|
|
width: parent.width
|
|
doc: root.doc
|
|
}
|
|
}
|
|
}
|
|
Panel {
|
|
panelIndex: 2
|
|
sourceComponent: Component {
|
|
AppearanceSettings {
|
|
width: parent.width
|
|
doc: root.doc
|
|
}
|
|
}
|
|
}
|
|
Panel {
|
|
panelIndex: 3
|
|
sourceComponent: Component {
|
|
OfflineSettings {
|
|
width: parent.width
|
|
doc: root.doc
|
|
confirmHost: confirmHost
|
|
}
|
|
}
|
|
}
|
|
Panel {
|
|
panelIndex: 4
|
|
sourceComponent: Component {
|
|
LocalLibrarySettings {
|
|
width: parent.width
|
|
doc: root.doc
|
|
confirmHost: confirmHost
|
|
tabOrderModal: localTabsConfigModal
|
|
}
|
|
}
|
|
}
|
|
Panel {
|
|
panelIndex: 5
|
|
sourceComponent: Component {
|
|
BlacklistSettings {
|
|
width: parent.width
|
|
doc: root.doc
|
|
}
|
|
}
|
|
}
|
|
Panel {
|
|
panelIndex: 6
|
|
sourceComponent: Component {
|
|
IntegrationsSettings {
|
|
width: parent.width
|
|
doc: root.doc
|
|
}
|
|
}
|
|
}
|
|
Panel {
|
|
panelIndex: 7
|
|
sourceComponent: Component {
|
|
DeveloperSettings {
|
|
width: parent.width
|
|
doc: root.doc
|
|
}
|
|
}
|
|
}
|
|
Panel {
|
|
panelIndex: 8
|
|
active: root.section === 8 && root.sandboxed
|
|
sourceComponent: Component {
|
|
SandboxSettings {
|
|
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 { id: confirmHost }
|
|
|
|
// App-wide Local Library order. Mounted at the view root so its scrim
|
|
// covers the sub-navigation and the scrolled panel alike.
|
|
LocalTabsConfigModal {
|
|
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 { 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 { }
|
|
}
|