diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index feb8b627a..2cf0da9a7 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -105,6 +105,17 @@ fn main() { "qml/views/PlaylistView.qml", "qml/views/SearchView.qml", "qml/views/SectionRail.qml", + "qml/controls/QbzCheckbox.qml", + "qml/controls/WarningBanner.qml", + "qml/settings/AudioSettings.qml", + "qml/settings/BlacklistSettings.qml", + "qml/settings/DeveloperSettings.qml", + "qml/settings/LibraryFolderTable.qml", + "qml/settings/LocalLibrarySettings.qml", + "qml/settings/OfflineSettings.qml", + "qml/settings/PlaybackSettings.qml", + "qml/settings/PlexSettings.qml", + "qml/settings/SandboxSettings.qml", "qml/controls/QbzSkeleton.qml", "qml/shell/FavToggle.qml", "qml/shell/InfoCreditCell.qml", diff --git a/crates/qbz-qt/qml/controls/QbzCheckbox.qml b/crates/qbz-qt/qml/controls/QbzCheckbox.qml new file mode 100644 index 000000000..1a34038d5 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzCheckbox.qml @@ -0,0 +1,40 @@ +// QbzCheckbox (primitives/QbzCheckbox.slint) — 18px square, r4, accent fill +// with a black check when on, 1.5px muted border when off. Emits toggled(); +// like the Slint original it never self-flips (the owner of the state does). +// +// Used by Settings > Local Library (folder selection) and the Plex library +// picker, i.e. every place the Slint uses its own QbzCheckbox. + +import QtQuick +import "../theme" + +Rectangle { + id: root + + property bool checked: false + signal toggled() + + QbzTheme { id: theme } + + width: 18 + height: 18 + radius: 4 + color: root.checked ? theme.accent : "transparent" + border.width: root.checked ? 0 : 2 + border.color: theme.textMuted + + QbzIcon { + anchors.centerIn: parent + visible: root.checked + name: "check" + tintName: "black" + width: 12 + height: 12 + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggled() + } +} diff --git a/crates/qbz-qt/qml/controls/WarningBanner.qml b/crates/qbz-qt/qml/controls/WarningBanner.qml new file mode 100644 index 000000000..92f336ee6 --- /dev/null +++ b/crates/qbz-qt/qml/controls/WarningBanner.qml @@ -0,0 +1,73 @@ +// WarningBanner (primitives/WarningBanner.slint) — a colored info / warning / +// error notice: 10%-alpha background, 30%-alpha border, a tone glyph, and a +// wrapping title + body (either may be empty). +// +// POC-NOTE (icon set): the Slint uses triangle-alert / circle-alert / +// circle-check-big glyphs; this port's icon set has none of them, so every +// variant uses `info` in the variant's tint. The color + the title still +// carry the tone. + +import QtQuick +import "../theme" + +Rectangle { + id: root + + property string title: "" + property string body: "" + // "info" | "warning" | "error" | "success" + property string variant: "warning" + + QbzTheme { id: theme } + + readonly property color tone: variant === "error" ? theme.danger + : variant === "info" ? theme.accent + : variant === "success" ? theme.success : theme.warning + readonly property string toneTint: variant === "error" ? "favorite" + : variant === "info" ? "accent" + : variant === "success" ? "accent" : "warning" + + width: parent ? parent.width : 0 + height: row.implicitHeight + 24 + radius: 8 + color: Qt.rgba(tone.r, tone.g, tone.b, 0.10) + border.width: 1 + border.color: Qt.rgba(tone.r, tone.g, tone.b, 0.30) + + Row { + id: row + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 10 + + QbzIcon { + name: "info" + tintName: root.toneTint + width: 16 + height: 16 + } + Column { + width: parent.width - 26 + spacing: 3 + Text { + visible: root.title !== "" + width: parent.width + text: root.title + color: root.tone + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + wrapMode: Text.WordWrap + } + Text { + visible: root.body !== "" + width: parent.width + text: root.body + color: theme.textSecondary + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + } + } +} diff --git a/crates/qbz-qt/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index f579c6fa8..0ac43f5f8 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.qml @@ -1,27 +1,24 @@ -// Settings > Appearance (phase 19) — the QML port of -// crates/qbz-ui/ui/settings/AppearanceSettings.slint. Row order, group -// headers and gating mirror the Slint 1:1; every control rides the single -// settingsJson document (root.doc) and the settingsBool/Select/String -// invokables — never local truth. +// Settings > Appearance — the QML port of crates/qbz-ui/ui/settings/ +// AppearanceSettings.slint. Group order, row order, labels, descriptions and +// gating are 1:1 with the Slint; every control rides the settingsJson +// document (root.doc) and the settingsBool/Select/String invokables — never +// local truth. // // The THEME row is live: QbzShell.themeSlug / themeListJson / themeFilter // (theme_qt.rs) and themeSet() repaints the whole app through QbzTheme.qml // (the Slint theme::push_colors equivalent). // -// POC-NOTEs (deliberate cuts): -// - "Auto (dynamic)" rows: the Source dropdown persists, Regenerate -// re-resolves via AutoSource::System; the "Select Image..." flow -// (file dialog + AutoSource::Image) is not ported. -// - "Custom" rows: the Slint token editor is not ported (owner: read-only -// apply of custom_theme.json) — an info row stands in. -// - The "system" registry theme maps to the Dark palette (Slint reads the -// OS palette). -// - Language persists but applies on next launch (live switch = phase 20). -// - UI scale / renderer / preferred GPU persist with restart semantics; -// the Slint restart toasts are logs here. -// - Preferred GPU: no adapter enumeration — Auto + the persisted adapter. -// - The commented-out Slint blocks (immersive background/panels, title -// template) stay out, 1:1 the owner's cut. +// Not shipped (no backend seam in this port): +// - The "Auto (dynamic)" custom-image picker ("Select Image..." + the +// "Detected: " line): there is no file-chooser seam here, so the +// button would do nothing. The Source dropdown still persists all three +// values (the shared ui_prefs key the Slint reads). +// - The Custom-theme editor (seed-from-current, the dark-polarity toggle and +// the base-token swatch grid): the port applies custom_theme.json read-only +// and has no ColorPicker primitive. +// - "Hide Dock icon when closed to menu bar" (macOS-only in the Slint too). +// - The commented-out Slint blocks (immersive background / panels / FPS, +// window-title template) stay out, 1:1 the owner's cut. import QtQuick import com.blitzfc.qbz @@ -74,32 +71,15 @@ Column { label: QbzSession.tr("Theme", QbzSession.trRev) Row { spacing: 8 - // Filter cycle (All -> Dark -> Light): sun-moon / moon / sun. - Rectangle { - width: 34 - height: 34 - radius: theme.radiusSm - border.width: 1 - border.color: theme.borderSubtle - color: fArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated - QbzIcon { - anchors.centerIn: parent - name: QbzShell.themeFilter === 1 ? "moon" - : QbzShell.themeFilter === 2 ? "sun" : "sun-moon" - width: 16 - height: 16 - tintName: "secondary" - } - MouseArea { - id: fArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: QbzShell.themeSetFilter((QbzShell.themeFilter + 1) % 3) - } + // Theme list filter cycle (All / Dark / Light). + SettingsButton { + iconName: QbzShell.themeFilter === 1 ? "moon" + : QbzShell.themeFilter === 2 ? "sun" : "sun-moon" + onClicked: QbzShell.themeSetFilter((QbzShell.themeFilter + 1) % 3) } QbzSelect { menuWidth: 220 + // 36 registered themes — a name filter (Slint parity). searchable: true options: root.filteredLabels currentIndex: root.currentThemeIndex @@ -120,6 +100,7 @@ Column { } SettingRow { label: QbzSession.tr("Dynamic background", QbzSession.trRev) + description: QbzSession.tr("Animated album-art background behind the whole app. High resource use — GPU accelerated.", QbzSession.trRev) QbzSelect { menuWidth: 200 options: root.doc.appBackgroundModes || [] @@ -129,18 +110,20 @@ Column { } SettingRow { label: QbzSession.tr("Intelligent Search", QbzSession.trRev) + description: QbzSession.tr("Smart search cache, ranking, and the search preview dropdown. On by default.", QbzSession.trRev) QbzToggle { checked: root.doc.intelligentSearch === true onToggled: function (v) { QbzBridge.settingsBool("intelligent-search", v) } } } - // Auto-theme rows (theme slug "auto" only). + // Auto-theme rows (the "auto" theme only). SettingRow { visible: QbzShell.themeSlug === "auto" label: QbzSession.tr("Source", QbzSession.trRev) + description: QbzSession.tr("Generate a color theme from your system wallpaper or a custom image", QbzSession.trRev) QbzSelect { menuWidth: 200 - options: [QbzSession.tr("System Colors", QbzSession.trRev), QbzSession.tr("Wallpaper Sync", QbzSession.trRev), QbzSession.tr("Custom Image", QbzSession.trRev)] + options: root.doc.autoThemeSources || [] currentIndex: root.doc.autoThemeSourceIndex || 0 onSelected: function (i) { QbzBridge.settingsSelect("auto-theme-source", i) } } @@ -148,18 +131,11 @@ Column { SettingRow { visible: QbzShell.themeSlug === "auto" label: QbzSession.tr("Regenerate", QbzSession.trRev) - description: QbzSession.tr("Rebuild the palette from the source (System colors in the POC).", QbzSession.trRev) SettingsButton { text: QbzSession.tr("Regenerate", QbzSession.trRev) onClicked: QbzShell.themeSet(QbzShell.themeSlug) } } - // Custom-theme rows (theme slug "custom" only) — no token editor. - SettingRow { - visible: QbzShell.themeSlug === "custom" - label: QbzSession.tr("Custom theme", QbzSession.trRev) - description: QbzSession.tr("Applied read-only from custom_theme.json — the token editor is not ported to the Qt frontend (POC).", QbzSession.trRev) - } SettingsSpacer { } SettingsDivider { } @@ -178,7 +154,7 @@ Column { } SettingRow { label: QbzSession.tr("Interface size", QbzSession.trRev) - description: QbzSession.tr("Restart to apply.", QbzSession.trRev) + description: QbzSession.tr("Scales the whole interface, like browser zoom. Small fits more content on screen, Large and Extra large improve readability (requires restart)", QbzSession.trRev) QbzSelect { menuWidth: 200 options: root.doc.uiScales || [] @@ -188,8 +164,9 @@ Column { } SettingRow { label: QbzSession.tr("Immersive search", QbzSession.trRev) + description: QbzSession.tr("What selecting a result in the Immersive search does. Disabled turns the in-immersive search off.", QbzSession.trRev) QbzSelect { - menuWidth: 220 + menuWidth: 200 options: root.doc.immersiveSearchActions || [] currentIndex: root.doc.immersiveSearchActionIndex || 0 onSelected: function (i) { QbzBridge.settingsSelect("immersive-search-action", i) } @@ -197,8 +174,9 @@ Column { } SettingRow { label: QbzSession.tr("Immersive default view", QbzSession.trRev) + description: QbzSession.tr("Which immersive view opens by default. 'Remember last' restores your last view.", QbzSession.trRev) QbzSelect { - menuWidth: 220 + menuWidth: 200 options: root.doc.immersiveDefaultViews || [] currentIndex: root.doc.immersiveDefaultViewIndex || 0 onSelected: function (i) { QbzBridge.settingsSelect("immersive-default-view", i) } @@ -213,6 +191,7 @@ Column { GroupHeader { text: QbzSession.tr("LIBRARY & VISUALS", QbzSession.trRev) } SettingRow { label: QbzSession.tr("Show navigation in sidebar", QbzSession.trRev) + description: QbzSession.tr("Move the Discover, Library, Local Library and My QBZ sections out of the header and into the sidebar.", QbzSession.trRev) QbzToggle { checked: root.doc.navInSidebar === true onToggled: function (v) { QbzBridge.settingsBool("nav-in-sidebar", v) } @@ -222,6 +201,7 @@ Column { // ADR-010: only mounted when navigation is NOT in the sidebar. visible: root.doc.navInSidebar !== true label: QbzSession.tr("Compact header navigation", QbzSession.trRev) + description: QbzSession.tr("Use the icon-only section navigation in the header even while the sidebar is open.", QbzSession.trRev) QbzToggle { checked: root.doc.navHeaderCompact === true onToggled: function (v) { QbzBridge.settingsBool("nav-header-compact", v) } @@ -229,21 +209,26 @@ Column { } SettingRow { label: QbzSession.tr("My QBZ", QbzSession.trRev) + description: QbzSession.tr("Rename the My QBZ hub. Leave the name blank (or hit reset) to restore the default.", QbzSession.trRev) Row { spacing: 8 + // Fixed icon preview (RENAME ONLY — no custom icon, owner DQ3). RoundedImage { width: 34 height: 34 + anchors.verticalCenter: parent.verticalCenter source: "../assets/qbz-logo.png" radius: theme.radiusSm } QbzLineEdit { width: 150 + anchors.verticalCenter: parent.verticalCenter text: root.doc.myQbzLabel || "" placeholder: QbzSession.tr("My QBZ", QbzSession.trRev) onCommitted: function (v) { QbzBridge.settingsString("myqbz-label", v) } } SettingsButton { + anchors.verticalCenter: parent.verticalCenter iconName: "rotate-ccw" onClicked: QbzBridge.settingsString("myqbz-label", "") } @@ -251,6 +236,7 @@ Column { } SettingRow { label: QbzSession.tr("Show playlist cover collages in sidebar", QbzSession.trRev) + description: QbzSession.tr("Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images.", QbzSession.trRev) QbzToggle { checked: root.doc.sidebarPlaylistCollage === true onToggled: function (v) { QbzBridge.settingsBool("sidebar-playlist-collage", v) } @@ -258,6 +244,7 @@ Column { } SettingRow { label: QbzSession.tr("Show album artwork in Local Library track list", QbzSession.trRev) + description: QbzSession.tr("Display the album cover thumbnail next to each track in the Tracks and Folders views. Off by default — large libraries pay a per-row image-decode cost.", QbzSession.trRev) QbzToggle { checked: root.doc.localLibraryTrackArtwork === true onToggled: function (v) { QbzBridge.settingsBool("local-library-track-artwork", v) } @@ -265,6 +252,7 @@ Column { } SettingRow { label: QbzSession.tr("Play indicator animation", QbzSession.trRev) + description: QbzSession.tr("Animate the now-playing row with equalizer bars. Off (default) shows a static pause icon with an accent edge mark — lighter on CPU.", QbzSession.trRev) QbzToggle { checked: root.doc.playIndicatorAnimation === true onToggled: function (v) { QbzBridge.settingsBool("play-indicator-animation", v) } @@ -272,6 +260,7 @@ Column { } SettingRow { label: QbzSession.tr("Invert swipe navigation direction", QbzSession.trRev) + description: QbzSession.tr("Swap the two-finger touchpad swipe: left goes back, right goes forward.", QbzSession.trRev) QbzToggle { checked: root.doc.invertSwipeNavigation === true onToggled: function (v) { QbzBridge.settingsBool("invert-swipe-navigation", v) } @@ -321,7 +310,7 @@ Column { GroupHeader { text: QbzSession.tr("TITLE BAR", QbzSession.trRev) } SettingRow { label: QbzSession.tr("Use system title bar", QbzSession.trRev) - description: QbzSession.tr("Restart to apply.", QbzSession.trRev) + description: QbzSession.tr("Keep your system's native window decorations. Turn off to use the QBZ header as the title bar, with its own window controls and drag support. Takes effect after restarting QBZ.", QbzSession.trRev) QbzToggle { checked: root.doc.useSystemTitleBar === true onToggled: function (v) { QbzBridge.settingsBool("use-system-title-bar", v) } @@ -329,6 +318,7 @@ Column { } SettingRow { label: QbzSession.tr("Hide title bar", QbzSession.trRev) + description: QbzSession.tr("Frameless window without window controls or header drag (for tiling window manager users)", QbzSession.trRev) rowEnabled: root.doc.useSystemTitleBar !== true QbzToggle { enabled: root.doc.useSystemTitleBar !== true @@ -345,6 +335,7 @@ Column { GroupHeader { text: QbzSession.tr("WINDOW CONTROLS", QbzSession.trRev) } SettingRow { label: QbzSession.tr("Window controls position", QbzSession.trRev) + description: QbzSession.tr("Place the window control buttons on the left or right side of the title bar", QbzSession.trRev) rowEnabled: !root.tbLocked QbzSelect { enabled: !root.tbLocked @@ -356,6 +347,7 @@ Column { } SettingRow { label: QbzSession.tr("Show window controls", QbzSession.trRev) + description: QbzSession.tr("Show minimize, maximize, and close buttons in the title bar. Disable if your window manager handles these.", QbzSession.trRev) rowEnabled: !root.tbLocked QbzToggle { enabled: !root.tbLocked @@ -372,6 +364,7 @@ Column { GroupHeader { text: QbzSession.tr("PLAYER VIEWS", QbzSession.trRev) } SettingRow { label: QbzSession.tr("Show volume +/- buttons", QbzSession.trRev) + description: QbzSession.tr("Add discrete plus and minus buttons next to the volume slider in the player bar.", QbzSession.trRev) QbzToggle { checked: root.doc.showVolumeSteppers === true onToggled: function (v) { QbzBridge.settingsBool("show-volume-steppers", v) } @@ -380,7 +373,7 @@ Column { SettingRow { label: QbzSession.tr("Mini player default view", QbzSession.trRev) QbzSelect { - menuWidth: 220 + menuWidth: 200 options: root.doc.miniDefaultViews || [] currentIndex: root.doc.miniDefaultViewIndex || 0 onSelected: function (i) { QbzBridge.settingsSelect("mini-default-view", i) } @@ -388,8 +381,9 @@ Column { } SettingRow { label: QbzSession.tr("Startup page", QbzSession.trRev) + description: QbzSession.tr("Choose which page to show when the app starts", QbzSession.trRev) QbzSelect { - menuWidth: 220 + menuWidth: 200 options: root.doc.startupPages || [] currentIndex: root.doc.startupPageIndex || 0 onSelected: function (i) { QbzBridge.settingsSelect("startup-page", i) } @@ -397,6 +391,7 @@ Column { } SettingRow { label: QbzSession.tr("Show Purchases", QbzSession.trRev) + description: QbzSession.tr("Show the Purchases section in the sidebar for browsing and downloading your purchased music", QbzSession.trRev) QbzToggle { checked: root.doc.showPurchases === true onToggled: function (v) { QbzBridge.settingsBool("show-purchases", v) } @@ -404,6 +399,7 @@ Column { } SettingRow { label: QbzSession.tr("Purchases in title bar", QbzSession.trRev) + description: QbzSession.tr("Place the Purchases entry in the custom title bar instead of the sidebar", QbzSession.trRev) rowEnabled: !root.tbLocked QbzToggle { enabled: !root.tbLocked @@ -420,6 +416,7 @@ Column { GroupHeader { text: QbzSession.tr("SYSTEM TRAY", QbzSession.trRev) } SettingRow { label: QbzSession.tr("Enable tray icon", QbzSession.trRev) + description: QbzSession.tr("Show icon in system tray (requires restart)", QbzSession.trRev) QbzToggle { checked: root.doc.trayEnable === true onToggled: function (v) { QbzBridge.settingsBool("tray-enable", v) } @@ -427,6 +424,7 @@ Column { } SettingRow { label: QbzSession.tr("Close to tray", QbzSession.trRev) + description: QbzSession.tr("Keep playing in the tray instead of quitting when you close the window", QbzSession.trRev) rowEnabled: root.doc.trayEnable === true QbzToggle { enabled: root.doc.trayEnable === true @@ -436,8 +434,9 @@ Column { } SettingRow { label: QbzSession.tr("Tray icon variant", QbzSession.trRev) + description: QbzSession.tr("Pick a mono glyph to match your panel (Plasma, GNOME's permanently dark top bar) or the full colour vinyl logo", QbzSession.trRev) QbzSelect { - menuWidth: 200 + menuWidth: 160 options: root.doc.trayIconThemes || [] currentIndex: root.doc.trayIconThemeIndex || 0 onSelected: function (i) { QbzBridge.settingsSelect("tray-icon-theme", i) } @@ -452,9 +451,9 @@ Column { GroupHeader { text: QbzSession.tr("RENDERER", QbzSession.trRev) } SettingRow { label: QbzSession.tr("Rendering backend", QbzSession.trRev) - description: QbzSession.tr("Restart to apply.", QbzSession.trRev) + description: QbzSession.tr("Auto picks the best renderer for your graphics hardware. Only change this if the app feels slow or renders incorrectly (requires restart)", QbzSession.trRev) QbzSelect { - menuWidth: 220 + menuWidth: 200 options: root.doc.renderers || [] currentIndex: root.doc.rendererIndex || 0 onSelected: function (i) { QbzBridge.settingsSelect("renderer", i) } @@ -462,7 +461,7 @@ Column { } SettingRow { label: QbzSession.tr("Preferred GPU", QbzSession.trRev) - description: QbzSession.tr("Restart to apply.", QbzSession.trRev) + description: QbzSession.tr("Which GPU renders the app. On a hybrid laptop, picking the discrete GPU moves the dynamic-background load off the integrated one (cooler, but more power). Auto is recommended (requires restart)", QbzSession.trRev) QbzSelect { menuWidth: 260 options: root.doc.gpuPowers || [] @@ -470,4 +469,6 @@ Column { onSelected: function (i) { QbzBridge.settingsSelect("gpu-power", i) } } } + + Item { width: 1; height: 40 } } diff --git a/crates/qbz-qt/qml/settings/AudioSettings.qml b/crates/qbz-qt/qml/settings/AudioSettings.qml new file mode 100644 index 000000000..19a69ac72 --- /dev/null +++ b/crates/qbz-qt/qml/settings/AudioSettings.qml @@ -0,0 +1,214 @@ +// 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. +// +// Not shipped (no backend seam in this port — see settings_qt.rs): +// - "Detected device limit" + its fallback disclosure (#638): the device-cap +// probe (`device_cap.rs`) is Slint glue that was never ported, so there is +// no measured value to show. +// - "HiFi Wizard": the guided PipeWire setup (DacWizardActions) is a whole +// modal flow, not a settings row. + +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) } + } + } + + 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) } + } + } + + 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() + } +} diff --git a/crates/qbz-qt/qml/settings/BlacklistSettings.qml b/crates/qbz-qt/qml/settings/BlacklistSettings.qml new file mode 100644 index 000000000..40edf170b --- /dev/null +++ b/crates/qbz-qt/qml/settings/BlacklistSettings.qml @@ -0,0 +1,67 @@ +// Settings > Blacklist — the QML port of crates/qbz-ui/ui/settings/ +// ContentFilteringSettings.slint: ONE row (blind-eye glyph + "Blacklist" + +// "{n} artists, {m} albums blocked", with a "(disabled)" suffix when the +// feature is off). There is no enable toggle here in the Slint either — the +// switch lives inside the Blacklist Manager. +// +// The counters are the REAL per-user store (artist_blacklist.db, read through +// the shared BlacklistService — settings_qt/devtools.rs). +// +// Delta vs the Slint: the "Manage" chevron is NOT shipped — this port has no +// Blacklist Manager view for it to open, and a button that opens nothing is +// exactly what this pass exists to remove. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Column { + id: root + + property var doc: ({}) + readonly property var dev: doc.dev || ({}) + + QbzTheme { id: theme } + + spacing: 4 + + Item { + width: parent.width + height: 64 + Row { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 12 + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: "blind-eye" + width: 18 + height: 18 + tintName: "secondary" + } + Column { + width: parent.width - 30 + spacing: 3 + Text { + width: parent.width + text: QbzSession.tr("Blacklist", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + } + Text { + width: parent.width + text: (root.dev.blacklistEnabled === false + ? QbzSession.tr("{} artists, {} albums blocked (disabled)", QbzSession.trRev) + : QbzSession.tr("{} artists, {} albums blocked", QbzSession.trRev)) + .replace("{}", root.dev.blacklistArtists || 0) + .replace("{}", root.dev.blacklistAlbums || 0) + color: theme.textMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } + } + } +} diff --git a/crates/qbz-qt/qml/settings/DeveloperSettings.qml b/crates/qbz-qt/qml/settings/DeveloperSettings.qml new file mode 100644 index 000000000..3101d1b68 --- /dev/null +++ b/crates/qbz-qt/qml/settings/DeveloperSettings.qml @@ -0,0 +1,67 @@ +// Settings > Developer — the QML port of crates/qbz-ui/ui/settings/ +// DeveloperSettings.slint. +// +// Shipped, in the Slint's order: LOGS · SETTINGS PORTABILITY. +// +// Deltas vs the Slint (no dead rows): +// - "QOBUZ CONNECT / Connect diagnostics" is absent: this port has no live +// Qobuz Connect service, so the diagnostics modal would have no session to +// describe. +// - LOGS opens the on-disk log FILE (qbz_log's file sink) instead of the +// in-app log viewer overlay (copy / redact / upload), which is not ported. +// - "Export settings…" writes the bundle immediately with auth EXCLUDED; the +// SettingsExportModal's include-auth gate (default OFF) is not ported, so +// the safe default is the only behaviour. +// - The inline DiagnosticsPanel (seven saved-vs-runtime tables) is not +// ported — it is a view of its own, not a settings row. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Column { + id: root + + property var doc: ({}) + readonly property var dev: doc.dev || ({}) + + QbzTheme { id: theme } + + spacing: 4 + + // ============================== LOGS ================================= + GroupHeader { text: QbzSession.tr("LOGS", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Application logs", QbzSession.trRev) + description: (root.dev.logPath || "") !== "" + ? root.dev.logPath + : QbzSession.tr("View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue.", QbzSession.trRev) + SettingsButton { + text: QbzSession.tr("Open log file", QbzSession.trRev) + enabled: (root.dev.logPath || "") !== "" + onClicked: QbzBridge.settingsString("open-log-file", "") + } + } + + SettingsSpacer { } + + // ====================== SETTINGS PORTABILITY ========================= + GroupHeader { text: QbzSession.tr("SETTINGS PORTABILITY", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Export settings…", QbzSession.trRev) + description: QbzSession.tr("Save a portable bundle of your settings to move to another machine or the qbzd daemon.", QbzSession.trRev) + SettingsButton { + text: QbzSession.tr("Export…", QbzSession.trRev) + onClicked: QbzBridge.settingsString("export-settings", "") + } + } + Text { + visible: (root.dev.status || "") !== "" + width: parent.width + text: root.dev.status || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } +} diff --git a/crates/qbz-qt/qml/settings/IntegrationsSettings.qml b/crates/qbz-qt/qml/settings/IntegrationsSettings.qml index ab7dfc66e..b0893e58a 100644 --- a/crates/qbz-qt/qml/settings/IntegrationsSettings.qml +++ b/crates/qbz-qt/qml/settings/IntegrationsSettings.qml @@ -50,8 +50,6 @@ Column { } } - SettingsSpacer { } - SettingsDivider { } SettingsSpacer { } // =========================== METADATA ================================ @@ -65,8 +63,6 @@ Column { } } - SettingsSpacer { } - SettingsDivider { } SettingsSpacer { } // ========================== SCROBBLERS =============================== @@ -254,8 +250,6 @@ Column { } } - SettingsSpacer { } - SettingsDivider { } SettingsSpacer { } // =========================== DISCORD ================================= diff --git a/crates/qbz-qt/qml/settings/LibraryFolderTable.qml b/crates/qbz-qt/qml/settings/LibraryFolderTable.qml new file mode 100644 index 000000000..5dda2996a --- /dev/null +++ b/crates/qbz-qt/qml/settings/LibraryFolderTable.qml @@ -0,0 +1,373 @@ +// Settings > Local Library > LIBRARY FOLDERS — the folder-management block of +// crates/qbz-ui/ui/settings/LocalLibrarySettings.slint (lines 256-421): the +// toolbar, the filter, the compact folder table (checkbox · folder · last +// scan · status · actions) and the determinate scan progress with its Stop. +// +// Split out of LocalLibrarySettings.qml so that panel stays a thin section +// composition. It owns its own pure-UI state (filter + selection), exactly +// like the Slint keeps it in LibraryFoldersState; everything else rides the +// settings document and the settingsString action keys +// (settings_qt/library.rs). + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Column { + id: root + + /// The `library` sub-document of the settings snapshot. + property var lib: ({}) + readonly property var folders: lib.folders || [] + + // Pure-UI state (the Slint keeps the same in LibraryFoldersState). + property string filter: "" + property string pendingPath: "" + property var selectedIds: [] + + QbzTheme { id: theme } + + spacing: 4 + + function shown() { + const q = filter.trim().toLowerCase() + if (q === "") return folders + return folders.filter(function (f) { + return (f.displayName || "").toLowerCase().indexOf(q) >= 0 + || (f.path || "").toLowerCase().indexOf(q) >= 0 + }) + } + function isSelected(id) { return selectedIds.indexOf(id) >= 0 } + function toggleSelected(id) { + const next = selectedIds.slice() + const at = next.indexOf(id) + if (at >= 0) next.splice(at, 1); else next.push(id) + selectedIds = next + } + function scanLabel(ts) { + if (!ts) return QbzSession.tr("Never", QbzSession.trRev) + return Qt.formatDateTime(new Date(ts * 1000), "yyyy-MM-dd hh:mm") + } + // Shared column geometry (header + rows MUST agree): + // checkbox 20 · folder (rest) · last-scan 120 · status 84 · actions 110. + readonly property int actionsW: 110 + function folderColW(rowWidth) { + return Math.max(60, rowWidth - 20 - 120 - 84 - actionsW - 4 * 12) + } + + Item { + width: parent.width + height: 34 + GroupHeader { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("LIBRARY FOLDERS", QbzSession.trRev) + } + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + // Scan every enabled folder. + SettingsButton { + iconName: "refresh-cw" + enabled: root.lib.scanning !== true + onClicked: QbzBridge.settingsString("library-scan", "") + } + // Remove the selected folders (their indexed tracks go with them). + SettingsButton { + iconName: "trash-2" + danger: true + enabled: root.selectedIds.length > 0 + onClicked: { + QbzBridge.settingsString("library-remove-folders", + JSON.stringify(root.selectedIds)) + root.selectedIds = [] + } + } + } + } + + SettingRow { + label: QbzSession.tr("Add folder", QbzSession.trRev) + description: QbzSession.tr("Type or paste the full path of a music folder.", QbzSession.trRev) + Row { + spacing: 8 + QbzLineEdit { + id: pathField + width: 240 + anchors.verticalCenter: parent.verticalCenter + placeholder: "/home/you/Music" + onEdited: function (v) { root.pendingPath = v } + onCommitted: function (v) { root.pendingPath = v } + } + SettingsButton { + iconName: "folder-plus" + text: QbzSession.tr("Add", QbzSession.trRev) + onClicked: { + QbzBridge.settingsString("library-add-folder", root.pendingPath) + root.pendingPath = "" + pathField.text = "" + } + } + } + } + Text { + visible: (root.lib.status || "") !== "" + width: parent.width + text: root.lib.status || "" + color: theme.danger + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + + // Filter. + Item { + width: parent.width + height: 38 + QbzLineEdit { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 220 + searchMode: true + sm: true + placeholder: QbzSession.tr("Filter folders...", QbzSession.trRev) + onEdited: function (q) { root.filter = q } + } + } + + // Empty state. + Text { + visible: root.folders.length === 0 + width: parent.width + text: root.filter === "" + ? QbzSession.tr("No folders yet. Add a folder to build your local library.", QbzSession.trRev) + : QbzSession.tr("No folders match your filter.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + + // Table header (columns MUST match the rows below). + Item { + visible: root.folders.length > 0 + width: parent.width + height: 26 + Row { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 12 + spacing: 12 + Item { width: 20; height: 1 } + Text { + width: root.folderColW(parent.width) + height: parent.height + text: QbzSession.tr("FOLDER", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.letterSpacing: 0.5 + verticalAlignment: Text.AlignVCenter + } + Text { + width: 120 + height: parent.height + text: QbzSession.tr("LAST SCAN", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.letterSpacing: 0.5 + verticalAlignment: Text.AlignVCenter + } + Text { + width: 84 + height: parent.height + text: QbzSession.tr("STATUS", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.letterSpacing: 0.5 + verticalAlignment: Text.AlignVCenter + } + Item { width: root.actionsW; height: 1 } + } + } + Rectangle { + visible: root.folders.length > 0 + width: parent.width + height: 1 + color: theme.borderSubtle + } + + Repeater { + model: root.shown() + delegate: Rectangle { + id: folderRow + required property var modelData + + width: root.width + height: 40 + radius: theme.radiusSm + color: root.isSelected(modelData.id) ? theme.surfaceElevated + : rowArea.containsMouse ? theme.surfaceHover : "transparent" + + // Declared FIRST so the per-row buttons (declared after) take + // their own clicks; the empty row area falls through to select. + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleSelected(folderRow.modelData.id) + } + + Row { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 12 + spacing: 12 + + QbzCheckbox { + width: 20 + anchors.verticalCenter: parent.verticalCenter + checked: root.isSelected(folderRow.modelData.id) + onToggled: root.toggleSelected(folderRow.modelData.id) + } + // FOLDER: type glyph + name. + Row { + width: root.folderColW(parent.width) + height: parent.height + spacing: 8 + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + // POC-NOTE: no `network` glyph in this icon set — a + // network folder uses `link`, a local one hard-drive. + name: folderRow.modelData.isNetwork ? "link" : "hard-drive" + width: 16 + height: 16 + tintName: folderRow.modelData.isNetwork + ? (folderRow.modelData.accessible ? "accent" : "favorite") + : "secondary" + } + Text { + width: parent.width - 24 + height: parent.height + text: folderRow.modelData.displayName || folderRow.modelData.path + color: folderRow.modelData.enabled ? theme.textPrimary : theme.textMuted + font.pixelSize: theme.fontBody + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + Text { + width: 120 + height: parent.height + text: root.scanLabel(folderRow.modelData.lastScan) + color: theme.textMuted + font.pixelSize: theme.fontLegal + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + // STATUS (read-only, like the Slint). + Text { + width: 84 + height: parent.height + text: !folderRow.modelData.enabled + ? QbzSession.tr("Disabled", QbzSession.trRev) + : (folderRow.modelData.isNetwork && !folderRow.modelData.accessible) + ? QbzSession.tr("Unavailable", QbzSession.trRev) + : QbzSession.tr("Active", QbzSession.trRev) + color: (folderRow.modelData.isNetwork && !folderRow.modelData.accessible + && folderRow.modelData.enabled) ? theme.danger : theme.textMuted + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + // ACTIONS: enable/disable · scan this folder · remove it. + // The eye button stands in for the folder EDIT modal's + // "Enabled" checkbox (LibFolderEditModal.slint) — the same + // backend field (`set_folder_enabled`), surfaced here because + // that modal is not ported and a disabled folder would + // otherwise be stuck disabled. + Row { + width: root.actionsW + height: parent.height + spacing: 4 + layoutDirection: Qt.RightToLeft + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + iconName: "trash-2" + onClicked: QbzBridge.settingsString("library-remove-folders", + JSON.stringify([folderRow.modelData.id])) + } + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + iconName: "refresh-cw" + enabled: folderRow.modelData.enabled && root.lib.scanning !== true + onClicked: QbzBridge.settingsString("library-scan", + String(folderRow.modelData.id)) + } + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + iconName: folderRow.modelData.enabled ? "eye" : "eye-off" + onClicked: QbzBridge.settingsString("library-folder-enabled", + String(folderRow.modelData.id)) + } + } + } + } + } + + // --------------------------- scan progress --------------------------- + Item { + visible: root.lib.scanning === true + width: parent.width + height: visible ? 76 : 0 + Column { + anchors.fill: parent + anchors.topMargin: 14 + spacing: 8 + Item { + width: parent.width + height: 30 + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Scanning", QbzSession.trRev) + ": " + + (root.lib.processed || 0) + " / " + (root.lib.total || 0) + color: theme.textSecondary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + } + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + danger: true + iconName: "x" + text: QbzSession.tr("Stop", QbzSession.trRev) + onClicked: QbzBridge.settingsString("library-scan-stop", "") + } + } + Rectangle { + width: parent.width + height: 6 + radius: 3 + color: theme.surfaceElevated + clip: true + Rectangle { + width: parent.width * ((root.lib.total || 0) > 0 + ? Math.min(1, (root.lib.processed || 0) / root.lib.total) : 0) + height: parent.height + radius: 3 + color: theme.accent + } + } + Text { + visible: (root.lib.file || "") !== "" + width: parent.width + text: root.lib.file || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + } +} diff --git a/crates/qbz-qt/qml/settings/LocalLibrarySettings.qml b/crates/qbz-qt/qml/settings/LocalLibrarySettings.qml new file mode 100644 index 000000000..a07efcb7b --- /dev/null +++ b/crates/qbz-qt/qml/settings/LocalLibrarySettings.qml @@ -0,0 +1,123 @@ +// Settings > Local Library — the QML port of crates/qbz-ui/ui/settings/ +// LocalLibrarySettings.slint (the folder half; the PLEX half is the sibling +// PlexSettings.qml, mounted at the bottom exactly where the Slint has it). +// +// Group order is the Slint's: LIBRARY FOLDERS (toolbar + filter + table + +// scan progress, in LibraryFolderTable.qml) · ALBUMS VIEW · MAINTENANCE · +// DANGER ZONE · PLEX (PlexSettings.qml). +// +// Wiring: the folder table + the scan ride the settings document +// (settings_qt/library.rs over `qbz_library`'s own scan engine); the album +// grouping row drives the SAME QbzLocal.setAlbumMode the Local Library +// header dropdown uses, so both stay one setting. +// +// Deltas vs the Slint (deliberate, no dead rows): +// - "Add folder" is a path field + button, not the native folder chooser +// (no picker seam in this port — settings_qt/library.rs explains). +// - No per-folder EDIT affordance: LibFolderEditModal (alias + network +// overrides) is not ported, so the pencil would open nothing. +// - "Local items in Library All" is NOT shipped: library_qt.rs documents the +// "all" scope as not wired, so the selector would persist a preference no +// consumer reads. +// - The folder table scrolls with the page instead of in a capped 360px +// inner scroller (a nested Flickable inside the settings Flickable is a +// scroll trap). + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Column { + id: root + + property var doc: ({}) + readonly property var lib: doc.library || ({}) + + QbzTheme { id: theme } + + spacing: 4 + + // Re-read the folder list every time the section is shown — the Slint's + // `LibraryManageActions.load()` in the panel's init (the panel is mounted + // by an `if section == 4` there, by visibility here). + onVisibleChanged: if (visible) QbzBridge.settingsString("refresh", "") + + // ========================= LIBRARY FOLDERS =========================== + // Toolbar + add-path row + filter + the folder table + scan progress. + LibraryFolderTable { + width: parent.width + lib: root.lib + } + + Item { width: 1; height: 22 } + + // ============================ ALBUMS VIEW ============================ + GroupHeader { text: QbzSession.tr("ALBUMS VIEW", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Album grouping", QbzSession.trRev) + description: QbzSession.tr("Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags.", QbzSession.trRev) + QbzSelect { + menuWidth: 210 + options: [ + QbzSession.tr("Albums by folder", QbzSession.trRev), + QbzSession.tr("Albums by metadata", QbzSession.trRev) + ] + // Same setting as the Albums-tab header dropdown (one store). + currentIndex: QbzLocal.localAlbumMode === "metadata" ? 1 : 0 + onSelected: function (i) { QbzLocal.setAlbumMode(i === 1 ? "metadata" : "folder") } + } + } + + Item { width: 1; height: 22 } + + // ============================ MAINTENANCE ============================ + GroupHeader { text: QbzSession.tr("MAINTENANCE", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Cleanup missing files", QbzSession.trRev) + description: QbzSession.tr("Remove tracks whose files no longer exist on disk.", QbzSession.trRev) + Column { + spacing: 4 + SettingsButton { + text: root.lib.cleaning === true + ? QbzSession.tr("Cleaning up...", QbzSession.trRev) + : QbzSession.tr("Cleanup", QbzSession.trRev) + enabled: root.lib.cleaning !== true + onClicked: QbzBridge.settingsString("library-cleanup-missing", "") + } + Text { + visible: (root.lib.cleanupStatus || "") !== "" + width: parent.width + horizontalAlignment: Text.AlignRight + text: root.lib.cleanupStatus || "" + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + } + } + + Item { width: 1; height: 22 } + + // ============================ DANGER ZONE ============================ + GroupHeader { text: QbzSession.tr("DANGER ZONE", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Clear library database", QbzSession.trRev) + description: QbzSession.tr("Remove all indexed tracks. Your audio files are not deleted.", QbzSession.trRev) + SettingsButton { + danger: true + text: root.lib.clearing === true + ? QbzSession.tr("Clearing...", QbzSession.trRev) + : QbzSession.tr("Clear all", QbzSession.trRev) + enabled: root.lib.clearing !== true + onClicked: QbzBridge.settingsString("library-clear", "") + } + } + + Item { width: 1; height: 28 } + + // ================================ PLEX =============================== + PlexSettings { + width: parent.width + doc: root.doc + } +} diff --git a/crates/qbz-qt/qml/settings/OfflineSettings.qml b/crates/qbz-qt/qml/settings/OfflineSettings.qml new file mode 100644 index 000000000..98ee59eb0 --- /dev/null +++ b/crates/qbz-qt/qml/settings/OfflineSettings.qml @@ -0,0 +1,79 @@ +// Settings > Offline — the QML port of crates/qbz-ui/ui/settings/ +// OfflineSettings.slint. +// +// OFFLINE MODE (the app running without Qobuz) is the shared engine: the +// status line reads the LIVE QbzSession properties the engine forwarder +// publishes (tri-state online / no connection / induced + captive portal), +// and the toggle rides settingsBool("offline-mode-enabled") into +// `OfflineModeEngine::set_induced` (which also takes the #279 stream-first +// snapshot). +// +// Not shipped (no backend seam in this port — see settings_qt/offline.rs): +// - "Check now": the connectivity actor is private to offline_fwd.rs, so a +// re-probe cannot be triggered from here. The status itself is live. +// - The whole OFFLINE CACHE group (Open manager / Open folder / Clear all): +// the port never brings up OfflineCacheState, so all three would be dead. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Column { + id: root + + property var doc: ({}) + readonly property var off: doc.offline || ({}) + + QbzTheme { id: theme } + + spacing: 4 + + // ========================== OFFLINE MODE ============================= + GroupHeader { text: QbzSession.tr("OFFLINE MODE", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Status", QbzSession.trRev) + description: QbzSession.captivePortal + ? QbzSession.tr("Captive portal detected — sign in to the network to get online.", QbzSession.trRev) + : "" + Text { + // Tri-state: online green / real-offline amber / induced accent. + text: QbzSession.offlineMode === 2 ? QbzSession.tr("Offline mode enabled", QbzSession.trRev) + : QbzSession.offlineMode === 1 ? QbzSession.tr("No connection", QbzSession.trRev) + : QbzSession.tr("Online", QbzSession.trRev) + color: QbzSession.offlineMode === 2 ? theme.accent + : QbzSession.offlineMode === 1 ? theme.warning : theme.success + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + } + } + SettingRow { + label: QbzSession.tr("Enable Offline Mode", QbzSession.trRev) + description: QbzSession.tr("Manually switch to offline mode even with internet.", QbzSession.trRev) + QbzToggle { + checked: root.off.modeEnabled === true + onToggled: function (v) { QbzBridge.settingsBool("offline-mode-enabled", v) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ============================= LYRICS ================================ + GroupHeader { text: QbzSession.tr("LYRICS", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Clear lyrics cache", QbzSession.trRev) + // "{} entries using {}" — the real per-user lyrics.db stats. + description: root.off.lyricsLoaded === true + ? QbzSession.tr("{} entries using {}", QbzSession.trRev) + .replace("{}", root.off.lyricsEntries) + .replace("{}", root.off.lyricsSize) + : "" + SettingsButton { + danger: true + text: QbzSession.tr("Clear", QbzSession.trRev) + onClicked: QbzBridge.settingsString("lyrics-cache-clear", "") + } + } +} diff --git a/crates/qbz-qt/qml/settings/PlaybackSettings.qml b/crates/qbz-qt/qml/settings/PlaybackSettings.qml new file mode 100644 index 000000000..735efa2bc --- /dev/null +++ b/crates/qbz-qt/qml/settings/PlaybackSettings.qml @@ -0,0 +1,149 @@ +// Settings > Playback — the QML port of crates/qbz-ui/ui/settings/ +// PlaybackSettings.slint. 1:1 group order, labels, descriptions and gating; +// every control rides the settingsJson document and the settings invokables. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Column { + id: root + + property var doc: ({}) + + QbzTheme { id: theme } + + spacing: 4 + + // ============================ PLAYBACK =============================== + GroupHeader { text: QbzSession.tr("PLAYBACK", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Continue playback after track ends", QbzSession.trRev) + description: QbzSession.tr("Keep playing the rest of the album or playlist instead of stopping.", QbzSession.trRev) + QbzToggle { + checked: root.doc.continuePlayback === true + onToggled: function (v) { QbzBridge.settingsBool("continue-playback", v) } + } + } + SettingRow { + label: QbzSession.tr("Show track playing context", QbzSession.trRev) + description: QbzSession.tr("Display the context-stack icon in the player.", QbzSession.trRev) + QbzToggle { + checked: root.doc.showContextIcon === true + onToggled: function (v) { QbzBridge.settingsBool("show-context-icon", v) } + } + } + SettingRow { + label: QbzSession.tr("Gapless playback", QbzSession.trRev) + description: QbzSession.tr("Play consecutive same-format tracks without a gap.", QbzSession.trRev) + // Disabled while Streaming only is on — gapless needs the local cache. + rowEnabled: root.doc.streamingOnly !== true + QbzToggle { + checked: root.doc.gapless === true + enabled: root.doc.streamingOnly !== true + onToggled: function (v) { QbzBridge.settingsBool("gapless", v) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ============================= SESSION =============================== + GroupHeader { text: QbzSession.tr("SESSION", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Restore session on startup", QbzSession.trRev) + description: QbzSession.tr("Restore the queue and current track on the next launch.", QbzSession.trRev) + QbzToggle { + checked: root.doc.persistSession === true + onToggled: function (v) { QbzBridge.settingsBool("persist-session", v) } + } + } + SettingRow { + label: QbzSession.tr("Resume playback position", QbzSession.trRev) + description: QbzSession.tr("Also seek back to where the saved track left off.", QbzSession.trRev) + 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: QbzSession.tr("Auto-connect Qobuz Connect on startup", QbzSession.trRev) + description: QbzSession.tr("Choose whether Qobuz Connect activates automatically when QBZ launches.", QbzSession.trRev) + QbzSelect { + menuWidth: 240 + options: root.doc.qconnectStartupModes || [] + currentIndex: root.doc.qconnectStartupIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("qconnect-startup", i) } + } + } + SettingRow { + label: QbzSession.tr("Qobuz Connect device name", QbzSession.trRev) + description: QbzSession.tr("The name other Qobuz Connect apps see for this device. Applies on the next connection.", QbzSession.trRev) + QbzLineEdit { + width: 240 + text: root.doc.qconnectDeviceName || "" + placeholder: root.doc.qconnectDeviceNameDefault || "" + onCommitted: function (s) { QbzBridge.settingsString("qconnect-device-name", s) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ============================ STREAMING ============================== + GroupHeader { text: QbzSession.tr("STREAMING", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Stream uncached tracks", QbzSession.trRev) + description: QbzSession.tr("Start uncached tracks via streaming instead of waiting for the full download.", QbzSession.trRev) + QbzToggle { + checked: root.doc.streamUncached === true + onToggled: function (v) { QbzBridge.settingsBool("stream-uncached", v) } + } + } + SettingRow { + visible: root.doc.streamUncached === true + label: QbzSession.tr("Initial buffer size", QbzSession.trRev) + description: QbzSession.tr("Seconds of audio buffered before streaming playback starts.", QbzSession.trRev) + Row { + spacing: 12 + QbzSlider { + width: 160 + anchors.verticalCenter: parent.verticalCenter + minimum: 1 + maximum: 10 + value: root.doc.bufferSeconds || 1 + onChanged: function (v) { QbzBridge.settingsSlider("buffer-seconds", v) } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: (root.doc.bufferSeconds || 1) + QbzSession.tr("s", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + } + } + } + SettingRow { + label: QbzSession.tr("Streaming only", QbzSession.trRev) + description: QbzSession.tr("Skip writing tracks to the local cache while streaming.", QbzSession.trRev) + QbzToggle { + checked: root.doc.streamingOnly === true + onToggled: function (v) { QbzBridge.settingsBool("streaming-only", v) } + } + } + SettingRow { + label: QbzSession.tr("When quality retries fail", QbzSession.trRev) + description: QbzSession.tr("What to do when every quality tier for a track is unavailable.", QbzSession.trRev) + QbzSelect { + menuWidth: 240 + options: root.doc.retryBehaviors || [] + currentIndex: root.doc.retryBehaviorIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("retry-behavior", i) } + } + } +} diff --git a/crates/qbz-qt/qml/settings/PlexSettings.qml b/crates/qbz-qt/qml/settings/PlexSettings.qml new file mode 100644 index 000000000..e4dd55b79 --- /dev/null +++ b/crates/qbz-qt/qml/settings/PlexSettings.qml @@ -0,0 +1,294 @@ +// Settings > Local Library > PLEX — the QML port of the PLEX half of +// crates/qbz-ui/ui/settings/LocalLibrarySettings.slint (lines 517-855): +// master toggle + collapse header, server address, token, connection +// actions, the music-library picker, the metadata-write toggle and the Plex +// danger zone. +// +// Wiring: the LIVE state (enabled / available / syncing / sections / error) +// is the QbzLocal bridge's own Plex properties — the SAME ones the Local +// Library browse view reads, so a change here reloads the browse union in +// one hop. The two fields the bridge does not publish (the persisted server +// url and whether a token is stored) ride the settings document +// (settings_qt/library.rs). The token itself NEVER leaves Rust. +// +// Delta vs the Slint: the PIN flow (Authorize / Generate code / Link code / +// Copy code / Open Plex sign-in, and the "Enter token manually" switch that +// chooses between them) is NOT ported — `local_plex.rs` implements the +// manual server-url + token path only, so that path is always shown and the +// PIN rows are absent instead of dead. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Column { + id: root + + property var doc: ({}) + readonly property var plex: (doc.library || ({})).plex || ({}) + + // In-progress field values (the fields commit on Enter / focus loss; the + // Connect button submits whatever is typed right now). + property string urlInput: "" + property string tokenInput: "" + + QbzTheme { id: theme } + + spacing: 4 + + readonly property var sections: { + try { + return JSON.parse(QbzLocal.plexSectionsJson) + } catch (e) { + return [] + } + } + + // Same lazy-load hook as the Slint panel's init: re-publish the Plex + // state (sections + gates) whenever the section becomes visible. + onVisibleChanged: { + if (visible) { + urlInput = plex.serverUrl || "" + QbzLocal.refreshPlex() + } + } + + // ===================== header: title + toggle + chevron ================ + Item { + width: parent.width + height: 64 + Column { + anchors.left: parent.left + anchors.right: plexControls.left + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + text: QbzSession.tr("PLEX", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 11 + font.letterSpacing: 1.5 + font.weight: theme.weightSemibold + } + Text { + width: parent.width + text: QbzSession.tr("Connect a Plex Media Server on your local network.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } + Row { + id: plexControls + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + Rectangle { + visible: QbzLocal.plexEnabled + width: 28 + height: 28 + radius: theme.radiusSm + anchors.verticalCenter: parent.verticalCenter + color: chevArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { + anchors.centerIn: parent + name: root.plex.collapsed === true ? "chevron-right" : "chevron-down" + width: 18 + height: 18 + tintName: "secondary" + } + MouseArea { + id: chevArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.settingsBool("plex-collapse", root.plex.collapsed !== true) + } + } + QbzToggle { + anchors.verticalCenter: parent.verticalCenter + checked: QbzLocal.plexEnabled + onToggled: function (v) { QbzLocal.plexSetEnabled(v) } + } + } + } + + // ============================== body ================================== + Column { + visible: QbzLocal.plexEnabled && root.plex.collapsed !== true + width: parent.width + spacing: 4 + + Item { width: 1; height: 10 } + + SettingRow { + label: QbzSession.tr("Server address", QbzSession.trRev) + description: QbzSession.tr("Only local network servers are supported.", QbzSession.trRev) + QbzLineEdit { + id: urlField + width: 240 + text: root.plex.serverUrl || "" + placeholder: "http://127.0.0.1:32400" + onEdited: function (v) { root.urlInput = v } + onCommitted: function (v) { root.urlInput = v } + } + } + SettingRow { + label: QbzSession.tr("Token", QbzSession.trRev) + description: root.plex.hasToken === true + ? QbzSession.tr("A token is stored for this server.", QbzSession.trRev) + : QbzSession.tr("Use an existing X-Plex-Token.", QbzSession.trRev) + QbzLineEdit { + id: tokenField + width: 240 + isPassword: true + placeholder: QbzSession.tr("X-Plex-Token", QbzSession.trRev) + onEdited: function (v) { root.tokenInput = v } + onCommitted: function (v) { root.tokenInput = v } + } + } + // Connect = persist the credentials + sync (plex_connect resolves the + // url, refuses anything that is not a LAN address, then runs a sync). + SettingRow { + label: QbzSession.tr("Connect", QbzSession.trRev) + description: QbzSession.tr("Saves the address and token, then fetches your libraries.", QbzSession.trRev) + SettingsButton { + text: QbzLocal.plexSyncing + ? QbzSession.tr("Working...", QbzSession.trRev) + : QbzSession.tr("Connect", QbzSession.trRev) + enabled: !QbzLocal.plexSyncing + onClicked: { + QbzLocal.plexConnect(root.urlInput !== "" ? root.urlInput + : (root.plex.serverUrl || ""), root.tokenInput) + root.tokenInput = "" + tokenField.text = "" + } + } + } + + Item { width: 1; height: 14 } + + SettingRow { + label: QbzSession.tr("Libraries", QbzSession.trRev) + description: QbzSession.tr("Fetch your Plex music libraries.", QbzSession.trRev) + SettingsButton { + text: QbzLocal.plexSyncing + ? QbzSession.tr("Working...", QbzSession.trRev) + : QbzSession.tr("Get libraries", QbzSession.trRev) + enabled: QbzLocal.plexAvailable && !QbzLocal.plexSyncing + onClicked: QbzLocal.syncPlex() + } + } + + // Status line: the last Plex error, or the last sync's track count. + Text { + visible: QbzLocal.plexError !== "" || QbzLocal.plexLastSyncTracks >= 0 + width: parent.width + text: QbzLocal.plexError !== "" + ? QbzLocal.plexError + : QbzSession.tr("Synced {} tracks", QbzSession.trRev) + .replace("{}", QbzLocal.plexLastSyncTracks) + color: QbzLocal.plexError !== "" ? theme.danger : theme.success + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + + Item { width: 1; height: 6 } + + // ---------------------- music library picker ---------------------- + Column { + visible: root.sections.length > 0 + width: parent.width + spacing: 2 + GroupHeader { text: QbzSession.tr("MUSIC LIBRARIES", QbzSession.trRev) } + Item { width: 1; height: 4 } + Repeater { + model: root.sections + delegate: Rectangle { + id: secRow + required property var modelData + width: parent ? parent.width : 0 + height: 42 + radius: theme.radiusSm + color: secArea.containsMouse ? theme.surfaceHover : "transparent" + + function toggle() { + const keys = [] + for (let i = 0; i < root.sections.length; i++) { + const s = root.sections[i] + const on = s.key === secRow.modelData.key ? !s.selected : s.selected + if (on) keys.push(s.key) + } + QbzLocal.setPlexSections(JSON.stringify(keys)) + } + + MouseArea { + id: secArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: secRow.toggle() + } + Row { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 12 + spacing: 12 + QbzCheckbox { + anchors.verticalCenter: parent.verticalCenter + checked: secRow.modelData.selected === true + onToggled: secRow.toggle() + } + Text { + width: parent.width - 18 - 24 + height: parent.height + text: secRow.modelData.title || "" + color: theme.textPrimary + font.pixelSize: theme.fontBody + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + } + Item { width: 1; height: 14 } + } + + // -------------------------- metadata write ------------------------- + SettingRow { + label: QbzSession.tr("Write metadata to Plex (experimental)", QbzSession.trRev) + description: QbzSession.tr("Allow QBZ to write track metadata back to your Plex server.", QbzSession.trRev) + QbzToggle { + checked: root.plex.metadataWrite === true + onToggled: function (v) { QbzBridge.settingsBool("plex-metadata-write", v) } + } + } + + Item { width: 1; height: 18 } + + // ------------------------- plex danger zone ------------------------ + GroupHeader { text: QbzSession.tr("PLEX DANGER ZONE", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Disconnect", QbzSession.trRev) + description: QbzSession.tr("Sign out of Plex and clear the local cache.", QbzSession.trRev) + SettingsButton { + danger: true + text: QbzSession.tr("Disconnect", QbzSession.trRev) + enabled: root.plex.hasToken === true + onClicked: QbzLocal.plexDisconnect() + } + } + SettingRow { + label: QbzSession.tr("Clear cache", QbzSession.trRev) + description: QbzSession.tr("Remove cached Plex libraries and tracks. Your sign-in is kept.", QbzSession.trRev) + SettingsButton { + danger: true + text: QbzSession.tr("Clear cache", QbzSession.trRev) + enabled: root.plex.hasToken === true + onClicked: QbzBridge.settingsString("plex-clear-cache", "") + } + } + } +} diff --git a/crates/qbz-qt/qml/settings/SandboxSettings.qml b/crates/qbz-qt/qml/settings/SandboxSettings.qml new file mode 100644 index 000000000..40c1542d5 --- /dev/null +++ b/crates/qbz-qt/qml/settings/SandboxSettings.qml @@ -0,0 +1,149 @@ +// Settings > Flatpak / Snap — the QML port of crates/qbz-ui/ui/settings/ +// SandboxSettings.slint: the one-time permission commands a sandboxed +// install needs. Shown only when the process IS sandboxed +// (settings_qt/devtools.rs detects /.flatpak-info and $SNAP, the Slint's +// SandboxState.install-method). +// +// Commands are plain strings (never translated); the copy uses a hidden +// TextEdit's own clipboard (QML has no clipboard API of its own, and this +// port has no clipboard invokable to reuse). + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Column { + id: root + + property var doc: ({}) + readonly property string method: (doc.dev || ({})).installMethod || "" + + QbzTheme { id: theme } + + spacing: 4 + + component RowTitle: Text { + width: parent ? parent.width : 0 + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + wrapMode: Text.WordWrap + } + component RowNote: Text { + width: parent ? parent.width : 0 + color: theme.textMuted + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + // A full-width shell-command block with copy feedback. `command` may hold + // several lines; the whole block copies as one payload. + component CommandBlock: Rectangle { + id: block + property string command: "" + property bool copied: false + + width: parent ? parent.width : 0 + height: Math.max(54, cmdText.implicitHeight + 20) + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: theme.surfaceElevated + + Text { + id: cmdText + anchors.left: parent.left + anchors.right: copyBtn.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 12 + text: block.command + color: theme.textPrimary + font.family: "monospace" + font.pixelSize: theme.fontLegal + wrapMode: Text.WrapAnywhere + } + // Off-screen carrier: TextEdit.copy() is the only clipboard write QML + // offers without a C++ seam. + TextEdit { + id: carrier + visible: false + text: block.command + } + SettingsButton { + id: copyBtn + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: block.copied ? QbzSession.tr("Copied!", QbzSession.trRev) + : QbzSession.tr("Copy", QbzSession.trRev) + onClicked: { + carrier.selectAll() + carrier.copy() + carrier.deselect() + block.copied = true + copiedTimer.restart() + } + } + Timer { + id: copiedTimer + interval: 1500 + repeat: false + onTriggered: block.copied = false + } + } + + // ============================== FLATPAK ============================== + Column { + visible: root.method === "flatpak" + width: parent.width + spacing: 4 + + GroupHeader { text: QbzSession.tr("FLATPAK SANDBOX", QbzSession.trRev) } + RowNote { + text: QbzSession.tr("QBZ is running inside a Flatpak sandbox. Some features need one-time permission grants — run the commands in a terminal, then restart QBZ. Grants persist across updates.", QbzSession.trRev) + } + Item { width: 1; height: 8 } + + RowTitle { text: QbzSession.tr("Exclusive DAC access (bit-perfect)", QbzSession.trRev) } + RowNote { + text: QbzSession.tr("Recommended for exclusive mode: lets QBZ ask PipeWire to hand over the DAC cleanly (D-Bus device reservation). Without it, PipeWire keeps holding the DAC and other apps keep mixing through it even with ALSA + hw + Exclusive selected.", QbzSession.trRev) + } + CommandBlock { + command: "flatpak override --user --own-name=org.freedesktop.ReserveDevice1.* com.blitzfc.qbz" + } + Item { width: 1; height: 8 } + + RowTitle { text: QbzSession.tr("Music on NAS or external drives", QbzSession.trRev) } + RowNote { + text: QbzSession.tr("Grants access to a folder outside the sandbox. Repeat the command for each folder you need.", QbzSession.trRev) + } + CommandBlock { + command: "flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz" + } + } + + // ================================ SNAP =============================== + Column { + visible: root.method === "snap" + width: parent.width + spacing: 4 + + GroupHeader { text: QbzSession.tr("SNAP SANDBOX", QbzSession.trRev) } + RowNote { + text: QbzSession.tr("QBZ is running inside a Snap sandbox. Some audio interfaces need one-time connections — run these commands in a terminal, then restart QBZ.", QbzSession.trRev) + } + Item { width: 1; height: 8 } + + RowTitle { text: QbzSession.tr("Required audio connections", QbzSession.trRev) } + CommandBlock { + command: "sudo snap connect qbz-player:alsa\nsudo snap connect qbz-player:pulseaudio\nsudo snap connect qbz-player:pipewire\nsudo snap connect qbz-player:mpris" + } + Item { width: 1; height: 8 } + + RowTitle { text: QbzSession.tr("Optional — external drives & NAS", QbzSession.trRev) } + CommandBlock { + command: "sudo snap connect qbz-player:removable-media" + } + } +} diff --git a/crates/qbz-qt/qml/settings/SettingsView.qml b/crates/qbz-qt/qml/settings/SettingsView.qml index db980a389..13ca0f211 100644 --- a/crates/qbz-qt/qml/settings/SettingsView.qml +++ b/crates/qbz-qt/qml/settings/SettingsView.qml @@ -1,7 +1,19 @@ -// 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. +// 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 @@ -9,24 +21,11 @@ // 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): -// - Sections: Audio / Playback / Appearance / Integrations render live -// (phase 19); Offline / Local Library / Blacklist / Developer render an -// honest placeholder page with the exact section header; the conditional -// Flatpak/Snap entry and the "Share logs" LogViewer are not ported. -// - 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. +// 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 QtQuick.Controls import com.blitzfc.qbz -import "../controls" import "../theme" Item { @@ -34,12 +33,16 @@ Item { QbzTheme { id: theme } - // The parsed settingsJson document ({}) until the first publish lands). + // The parsed settingsJson document ({} until the first publish lands). property var doc: ({}) - // Active sub-section (display order): 0 Audio, 1 Playback, 2 Appearance, - // 3 Offline, 4 Local Library, 5 Blacklist, 6 Integrations, 7 Developer. + // Active sub-section (display order — see the header comment). property int section: 0 + readonly property bool sandboxed: { + const m = (doc.dev || ({})).installMethod || "" + return m === "flatpak" || m === "snap" + } + function reload() { try { root.doc = JSON.parse(QbzBridge.settingsJson) @@ -80,57 +83,60 @@ Item { // 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 + 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() + } + } + Column { - anchors.fill: parent + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right 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: QbzSession.tr("Audio", QbzSession.trRev) @@ -179,20 +185,33 @@ Item { active: root.section === 7 onClicked: root.section = 7 } - // POC-NOTE: the conditional Flatpak/Snap sub-nav entry - // (SandboxState.install-method) is omitted — the POC is - // not a sandboxed install. + // 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: root.section = 8 + } + } - // "Share logs" footer (SettingsView.slint:202-215), - // pinned to the subnav bottom. - Item { width: 1; height: parent.height - 8 * 42 - 38 - 4 } + // 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 - // POC-NOTE: LogViewerState (copy + upload viewer) is - // not ported — the row is inert. - onClicked: { } + onClicked: QbzBridge.settingsString("open-log-file", "") } } } @@ -218,408 +237,50 @@ Item { width: flick.width - 60 // 20 left + 40 right padding spacing: 4 - // ===================== AUDIO ====================== - Column { + AudioSettings { visible: root.section === 0 width: parent.width - spacing: 4 - - 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) } - } - } - // POC-NOTE: the read-only "Detected device limit" - // row + fallback disclosure (#638 fix 3) are - // skipped — the device-cap probe glue is not - // ported. - - SettingsSpacer { } - SettingsDivider { } - SettingsSpacer { } - - 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) } - } - } - // POC-NOTE: the JACK "not bit-perfect" - // WarningBanner is skipped (backendIsJack is - // published for a future port). - 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 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: 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 - height: visible ? (description === "" ? 52 : 64) : 0 - 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 - height: visible ? (description === "" ? 52 : 64) : 0 - 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) } - } - } - - SettingsSpacer { } - SettingsDivider { } - SettingsSpacer { } - - 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 - height: visible ? (description === "" ? 52 : 64) : 0 - 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) } - } - } - // POC-NOTE: the HiFi Wizard row (PipeWire-only - // guided DAC setup) is skipped — DacWizardActions - // is not ported. - - SettingsSpacer { } - SettingsDivider { } - SettingsSpacer { } - - 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 - height: visible ? (description === "" ? 52 : 64) : 0 - 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. - 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: QbzSession.tr("Reset to defaults", QbzSession.trRev) - 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() - } - } + doc: root.doc } - - // ==================== PLAYBACK ==================== - Column { + PlaybackSettings { visible: root.section === 1 width: parent.width - spacing: 4 - - GroupHeader { text: QbzSession.tr("PLAYBACK", QbzSession.trRev) } - SettingRow { - label: QbzSession.tr("Continue playback after track ends", QbzSession.trRev) - description: QbzSession.tr("Keep playing the rest of the album or playlist instead of stopping.", QbzSession.trRev) - QbzToggle { - checked: root.doc.continuePlayback === true - onToggled: function (v) { QbzBridge.settingsBool("continue-playback", v) } - } - } - SettingRow { - label: QbzSession.tr("Show track playing context", QbzSession.trRev) - description: QbzSession.tr("Display the context-stack icon in the player.", QbzSession.trRev) - QbzToggle { - checked: root.doc.showContextIcon === true - onToggled: function (v) { QbzBridge.settingsBool("show-context-icon", v) } - } - } - SettingRow { - label: QbzSession.tr("Gapless playback", QbzSession.trRev) - description: QbzSession.tr("Play consecutive same-format tracks without a gap.", QbzSession.trRev) - rowEnabled: root.doc.streamingOnly !== true - QbzToggle { - checked: root.doc.gapless === true - enabled: root.doc.streamingOnly !== true - onToggled: function (v) { QbzBridge.settingsBool("gapless", v) } - } - } - - SettingsSpacer { } - SettingsDivider { } - SettingsSpacer { } - - GroupHeader { text: QbzSession.tr("SESSION", QbzSession.trRev) } - SettingRow { - label: QbzSession.tr("Restore session on startup", QbzSession.trRev) - description: QbzSession.tr("Restore the queue and current track on the next launch.", QbzSession.trRev) - QbzToggle { - checked: root.doc.persistSession === true - onToggled: function (v) { QbzBridge.settingsBool("persist-session", v) } - } - } - SettingRow { - label: QbzSession.tr("Resume playback position", QbzSession.trRev) - description: QbzSession.tr("Also seek back to where the saved track left off.", QbzSession.trRev) - 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: QbzSession.tr("Auto-connect Qobuz Connect on startup", QbzSession.trRev) - description: QbzSession.tr("Choose whether Qobuz Connect activates automatically when QBZ launches.", QbzSession.trRev) - QbzSelect { - menuWidth: 240 - options: root.doc.qconnectStartupModes || [] - currentIndex: root.doc.qconnectStartupIndex || 0 - onSelected: function (i) { QbzBridge.settingsSelect("qconnect-startup", i) } - } - } - SettingRow { - label: QbzSession.tr("Qobuz Connect device name", QbzSession.trRev) - description: QbzSession.tr("The name other Qobuz Connect apps see for this device. Applies on the next connection.", QbzSession.trRev) - QbzLineEdit { - text: root.doc.qconnectDeviceName || "" - placeholder: root.doc.qconnectDeviceNameDefault || "" - onCommitted: function (s) { QbzBridge.settingsString("qconnect-device-name", s) } - } - } - - SettingsSpacer { } - SettingsDivider { } - SettingsSpacer { } - - GroupHeader { text: QbzSession.tr("STREAMING", QbzSession.trRev) } - SettingRow { - label: QbzSession.tr("Stream uncached tracks", QbzSession.trRev) - description: QbzSession.tr("Start uncached tracks via streaming instead of waiting for the full download.", QbzSession.trRev) - 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: QbzSession.tr("Initial buffer size", QbzSession.trRev) - description: QbzSession.tr("Seconds of audio buffered before streaming playback starts.", QbzSession.trRev) - 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) + QbzSession.tr("s", QbzSession.trRev) - color: theme.textSecondary - font.pixelSize: theme.fontBody - font.weight: theme.weightMedium - } - } - } - SettingRow { - label: QbzSession.tr("Streaming only", QbzSession.trRev) - description: QbzSession.tr("Skip writing tracks to the local cache while streaming.", QbzSession.trRev) - QbzToggle { - checked: root.doc.streamingOnly === true - onToggled: function (v) { QbzBridge.settingsBool("streaming-only", v) } - } - } - SettingRow { - label: QbzSession.tr("When quality retries fail", QbzSession.trRev) - description: QbzSession.tr("What to do when every quality tier for a track is unavailable.", QbzSession.trRev) - QbzSelect { - menuWidth: 240 - options: root.doc.retryBehaviors || [] - currentIndex: root.doc.retryBehaviorIndex || 0 - onSelected: function (i) { QbzBridge.settingsSelect("retry-behavior", i) } - } - } + doc: root.doc } - - // ================== APPEARANCE (phase 19) ========== AppearanceSettings { visible: root.section === 2 width: parent.width doc: root.doc } - - // ================== INTEGRATIONS (phase 19) ======== + OfflineSettings { + visible: root.section === 3 + width: parent.width + doc: root.doc + } + LocalLibrarySettings { + visible: root.section === 4 + width: parent.width + doc: root.doc + } + BlacklistSettings { + visible: root.section === 5 + width: parent.width + doc: root.doc + } IntegrationsSettings { visible: root.section === 6 width: parent.width doc: root.doc } - - // ============ placeholder sections (phase 19) ====== - // Offline (3) / Local Library (4) / Blacklist (5) / - // Developer (7): the subnav rows render the EXACT - // section header; the panels land in a later phase - // (POC-NOTE). - Column { - visible: root.section === 3 || root.section === 4 - || root.section === 5 || root.section === 7 + DeveloperSettings { + visible: root.section === 7 width: parent.width - spacing: 4 - GroupHeader { - text: root.section === 3 ? QbzSession.tr("OFFLINE", QbzSession.trRev) - : root.section === 4 ? QbzSession.tr("LOCAL LIBRARY", QbzSession.trRev) - : root.section === 5 ? QbzSession.tr("BLACKLIST", QbzSession.trRev) - : QbzSession.tr("DEVELOPER", QbzSession.trRev) - } - Text { - width: parent.width - text: QbzSession.tr("This section is not ported to the Qt frontend yet.", QbzSession.trRev) - color: theme.textMuted - font.pixelSize: 12 - wrapMode: Text.WordWrap - } + doc: root.doc + } + SandboxSettings { + visible: root.section === 8 && root.sandboxed + width: parent.width + doc: root.doc } } } diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 4bef06ae1..9e9aea260 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -21,6 +21,7 @@ //! they take effect on the next connection, like upstream. use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use cxx_qt_lib::QString; use qbz_app::settings::playback::{ @@ -32,6 +33,13 @@ use qbz_audio::settings::{AudioSettingsState, AudioSettingsStore}; use qbz_core::LoggingAdapter; use serde::Serialize; +// Per-section controllers. Declared HERE (not in main.rs) so the settings +// controller can be split by concern without touching the app root: a +// `mod x;` in `settings_qt.rs` resolves to `src/settings_qt/x.rs`. +pub mod devtools; +pub mod library; +pub mod offline; + // --------------------------------------------------------------------------- // Stores (shared files with the Slint app) // --------------------------------------------------------------------------- @@ -502,6 +510,8 @@ const RENDERER_LABELS: &[&str] = const RENDERER_VALUES: &[&str] = &["auto", "wgpu", "gl", "software"]; const TRAY_ICON_LABELS: &[&str] = &["Auto", "Mono light", "Mono dark", "Color"]; const TRAY_ICON_VALUES: &[&str] = &["auto", "mono-light", "mono-dark", "color"]; +const AUTO_THEME_SOURCE_LABELS: &[&str] = &["System Colors", "Wallpaper Sync", "Custom Image"]; +const AUTO_THEME_SOURCE_VALUES: &[&str] = &["system", "wallpaper", "image"]; fn index_of(values: &[&str], key: &str, default: usize) -> i32 { values.iter().position(|v| *v == key).unwrap_or(default) as i32 @@ -614,6 +624,8 @@ pub struct SettingsDoc { pub app_background_modes: Vec, #[serde(rename = "appBackgroundIndex")] pub app_background_index: i32, + #[serde(rename = "autoThemeSources")] + pub auto_theme_sources: Vec, #[serde(rename = "autoThemeSourceIndex")] pub auto_theme_source_index: i32, #[serde(rename = "intelligentSearch")] @@ -726,11 +738,51 @@ pub struct SettingsDoc { pub integrations_status_kind: i32, #[serde(rename = "discordEnabled")] pub discord_enabled: bool, + // --- Per-section sub-documents (settings_qt/*.rs) --------------------- + /// Settings > Local Library (folders, scan, maintenance, Plex fields). + pub library: library::Snapshot, + /// Settings > Offline (offline MODE + the lyrics cache row). + pub offline: offline::Snapshot, + /// Settings > Developer + Blacklist counters + the sandbox gate. + pub dev: devtools::Snapshot, } /// Index -> value maps the select handlers resolve against. static MAPS: Mutex<(Vec, Vec)> = Mutex::new((Vec::new(), Vec::new())); +/// Last device enumeration (backend, rows, id map, taken at). +/// +/// Every settings change rebuilds the whole document, and a library scan +/// re-publishes it on a 2 s ticker — enumerating the backend's devices that +/// often is wasted work (a PipeWire enumeration opens a connection). The +/// short TTL keeps hot-plug latency at a few seconds, and the refresh button +/// (which exists precisely for "my DAC is not listed") drops the entry. +static DEVICE_CACHE: Mutex, Vec, Instant)>> = + Mutex::new(None); + +const DEVICE_CACHE_TTL: Duration = Duration::from_secs(4); + +fn cached_devices(backend: AudioBackendType) -> (Vec, Vec) { + if let Ok(guard) = DEVICE_CACHE.lock() { + if let Some((cached_backend, rows, ids, at)) = guard.as_ref() { + if *cached_backend == backend && at.elapsed() < DEVICE_CACHE_TTL { + return (rows.clone(), ids.clone()); + } + } + } + let fresh = enumerate_devices(backend); + if let Ok(mut guard) = DEVICE_CACHE.lock() { + *guard = Some((backend, fresh.0.clone(), fresh.1.clone(), Instant::now())); + } + fresh +} + +fn invalidate_device_cache() { + if let Ok(mut guard) = DEVICE_CACHE.lock() { + *guard = None; + } +} + /// 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(); @@ -857,7 +909,7 @@ pub async fn publish_snapshot() { .copied() .unwrap_or_default(); - let (devices, ids) = enumerate_devices(active_backend); + let (devices, ids) = cached_devices(active_backend); let device_index = match &audio_settings.output_device { None => 0, Some(id) => ids.iter().position(|d| d == id).unwrap_or(0), @@ -953,8 +1005,12 @@ pub async fn publish_snapshot() { .iter() .position(|v| *v == pref_str("app_background", "off")) .unwrap_or(0) as i32, + auto_theme_sources: AUTO_THEME_SOURCE_LABELS + .iter() + .map(|l| qbz_i18n::t(l)) + .collect(), auto_theme_source_index: index_of( - &["system", "wallpaper", "image"], + AUTO_THEME_SOURCE_VALUES, &pref_str("auto_theme_source", "system"), 0, ), @@ -1049,6 +1105,9 @@ pub async fn publish_snapshot() { integrations_status_text: integ_ui.status_text.clone(), integrations_status_kind: integ_ui.status_kind, discord_enabled: crate::integrations_qt::discord_enabled(), + library: library::snapshot(), + offline: offline::snapshot(), + dev: devtools::snapshot(), } }) .await @@ -1293,6 +1352,19 @@ pub async fn settings_bool(runtime: &Arc>, key: &str, "discord-rpc" => { crate::integrations_qt::set_discord_enabled(value).map(|_| Apply::None) } + // --- Offline ------------------------------------------------------- + // Induced offline. The engine takes the #279 stream-first snapshot, + // so the audio settings can change under us -> Reload the player. + "offline-mode-enabled" => offline::set_mode_enabled(value).map(|_| Apply::Reload), + // --- Local Library > Plex ----------------------------------------- + "plex-metadata-write" => { + library::set_metadata_write(value); + Ok(Apply::None) + } + "plex-collapse" => { + save_pref("plex_ui_collapsed", serde_json::json!(value)); + Ok(Apply::None) + } other => { log::warn!("[qbz-qt] unknown settings bool key: {other}"); return; @@ -1508,19 +1580,55 @@ pub async fn settings_slider(runtime: &Arc>, key: &st publish_snapshot().await; } +/// String-payload handler. Also the ACTION channel for the sections whose +/// affordances are buttons rather than settings (Local Library folders and +/// scans, the caches, the developer tools): the payload is the action's +/// argument ("" when it takes none). 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); - } - if key == "myqbz-label" { - save_myqbz_label(&value); - } - if key == "listenbrainz-token" { - // Validates + persists + republishes itself (async flow). - crate::integrations_qt::listenbrainz_set_token(&value).await; - return; + match 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); + } + "myqbz-label" => save_myqbz_label(&value), + "listenbrainz-token" => { + // Validates + persists + republishes itself (async flow). + crate::integrations_qt::listenbrainz_set_token(&value).await; + return; + } + // --- Local Library ------------------------------------------------- + "library-add-folder" => library::add_folder(value).await, + "library-remove-folders" => { + let ids: Vec = serde_json::from_str(&value).unwrap_or_default(); + library::remove_folders(ids).await; + } + "library-folder-enabled" => { + if let Ok(id) = value.trim().parse::() { + library::toggle_folder_enabled(id).await; + } + } + // "" = every enabled folder; "" = that folder only. + "library-scan" => library::scan(value.trim().parse::().ok()), + "library-scan-stop" => library::stop_scan(), + // Panel mount / manual refresh: fall through to the publish below. + "refresh" => {} + "library-cleanup-missing" => { + // Publishes its own progress + result. + library::cleanup_missing().await; + return; + } + "library-clear" => { + library::clear_library().await; + return; + } + "plex-clear-cache" => library::plex_clear_cache().await, + // --- Offline -------------------------------------------------------- + "lyrics-cache-clear" => offline::clear_lyrics_cache().await, + // --- Developer ------------------------------------------------------ + "open-log-file" => devtools::open_log_file(), + "export-settings" => devtools::export_settings().await, + other => log::warn!("[qbz-qt] unknown settings string key: {other}"), } publish_snapshot().await; } @@ -1543,11 +1651,13 @@ pub async fn settings_reset(runtime: &Arc>) { /// 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. + // Release whatever the player holds, then re-enumerate from scratch (the + // whole point of the button is a device that was not in the last list). let player = runtime.core().player(); if let Err(e) = player.release_device() { log::warn!("[qbz-qt] release device failed: {e}"); } + invalidate_device_cache(); publish_snapshot().await; } diff --git a/crates/qbz-qt/src/settings_qt/devtools.rs b/crates/qbz-qt/src/settings_qt/devtools.rs new file mode 100644 index 000000000..f592ca49f --- /dev/null +++ b/crates/qbz-qt/src/settings_qt/devtools.rs @@ -0,0 +1,140 @@ +//! Settings > Developer, Settings > Blacklist and the Flatpak/Snap section. +//! +//! ADR-006 again: the log file is `qbz_log::install::log_file_path()` (the +//! sink `main.rs` installs), the export bundle is the shared +//! `qbz_app::settings::bundle` engine (the same one `qbzd` uses), and the +//! blacklist counters come from the shared `BlacklistService` store. +//! +//! Deltas vs the Slint (reported, not hidden): +//! - Developer > "Connect diagnostics" is not shipped: the port has no live +//! Qobuz Connect service, so `QconnectDevState` has nothing to show. +//! - Developer > logs opens the log FILE (the port has no log-viewer overlay +//! with copy/upload); same for the sub-nav's "Share logs" entry. +//! - Developer > export writes the bundle straight to disk with auth +//! EXCLUDED. The Slint's `SettingsExportModal` (the include-auth gate, +//! default OFF) is not ported, so the safe default is the only behaviour. +//! - Blacklist > "Manage" is not shipped: there is no Blacklist Manager view +//! in this port, so the chevron would open nothing. The status line is the +//! real store's counters. + +use qbz_app::settings::bundle::{self, ExportOptions, ExportSource}; +use serde::Serialize; +use std::sync::{LazyLock, Mutex}; + +static STATUS: LazyLock> = LazyLock::new(|| Mutex::new(String::new())); + +#[derive(Clone, Default, Serialize)] +pub struct Snapshot { + /// Absolute path of the current run's log file ("" when unavailable). + #[serde(rename = "logPath")] + pub log_path: String, + /// Result line of the last developer action (export…). + pub status: String, + #[serde(rename = "blacklistEnabled")] + pub blacklist_enabled: bool, + #[serde(rename = "blacklistArtists")] + pub blacklist_artists: i32, + #[serde(rename = "blacklistAlbums")] + pub blacklist_albums: i32, + /// "flatpak" | "snap" | "" — gates the sandbox sub-nav entry. + #[serde(rename = "installMethod")] + pub install_method: String, +} + +/// Sandboxed-install detection (the Slint's `SandboxState.install-method`): +/// Flatpak exposes `/.flatpak-info`, Snap exports `$SNAP`. +fn install_method() -> String { + if std::path::Path::new("/.flatpak-info").exists() { + return "flatpak".to_string(); + } + if std::env::var_os("SNAP").is_some() { + return "snap".to_string(); + } + String::new() +} + +/// Blacklist counters, read-only. The store file is NOT created when it does +/// not exist yet — an empty blacklist reads as zeros. +fn blacklist_counts() -> (bool, i32, i32) { + let Some(path) = crate::sidebar_qt::user_dir() + .map(|d| d.join(qbz_app::settings::artist_blacklist::DB_FILE_NAME)) + else { + return (true, 0, 0); + }; + if !path.exists() { + return (true, 0, 0); + } + match qbz_app::settings::artist_blacklist::BlacklistService::new(&path) { + Ok(service) => ( + service.is_enabled(), + service.count() as i32, + service.album_count() as i32, + ), + Err(e) => { + log::warn!("[qbz-qt] blacklist store open failed: {e}"); + (true, 0, 0) + } + } +} + +pub fn snapshot() -> Snapshot { + let (blacklist_enabled, blacklist_artists, blacklist_albums) = blacklist_counts(); + Snapshot { + log_path: qbz_log::install::log_file_path() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(), + status: STATUS.lock().unwrap_or_else(|e| e.into_inner()).clone(), + blacklist_enabled, + blacklist_artists, + blacklist_albums, + install_method: install_method(), + } +} + +/// Developer > Application logs (and the sub-nav "Share logs"): reveal the +/// on-disk log so it can be attached to an issue. +pub fn open_log_file() { + let Some(path) = qbz_log::install::log_file_path() else { + return; + }; + if let Err(e) = open::that(&path) { + log::warn!("[qbz-qt] could not open the log file: {e}"); + *STATUS.lock().unwrap_or_else(|e| e.into_inner()) = + qbz_i18n::t("Could not open the log file."); + } +} + +/// Developer > Export settings… — writes `qbz-settings-YYYYMMDD.qbzb` (0600) +/// with auth EXCLUDED, then reports the path inline. +pub async fn export_settings() { + let text = tokio::task::spawn_blocking(|| { + let bundle = match bundle::export( + ExportSource::Desktop, + &ExportOptions { + include_auth: false, + }, + ) { + Ok(b) => b, + Err(e) => { + log::error!("[qbz-qt] settings export failed: {e:?}"); + return qbz_i18n::t("Export failed."); + } + }; + let Some(dir) = dirs::download_dir().or_else(dirs::home_dir) else { + return qbz_i18n::t("Export failed."); + }; + let path = dir.join(bundle::default_filename()); + match bundle::write_bundle_file(&path, &bundle) { + Ok(()) => { + qbz_i18n::t("Saved to {}").replacen("{}", &path.to_string_lossy(), 1) + } + Err(e) => { + log::error!("[qbz-qt] settings bundle write failed: {e:?}"); + qbz_i18n::t("Export failed.") + } + } + }) + .await + .unwrap_or_else(|_| qbz_i18n::t("Export failed.")); + *STATUS.lock().unwrap_or_else(|e| e.into_inner()) = text; +} diff --git a/crates/qbz-qt/src/settings_qt/library.rs b/crates/qbz-qt/src/settings_qt/library.rs new file mode 100644 index 000000000..5d0cb5588 --- /dev/null +++ b/crates/qbz-qt/src/settings_qt/library.rs @@ -0,0 +1,441 @@ +//! Settings > Local Library — the folder table, the scan engine, maintenance, +//! the danger zone, and the Plex fields the panel needs on top of what the +//! `QbzLocal` bridge already publishes. +//! +//! ADR-006: nothing here re-implements scanning, folder identity or Plex. +//! Folder CRUD is `qbz_library::LibraryDatabase`; the scan is +//! `qbz_library::scan_with_progress` (the SAME engine the Slint frontend +//! drives, cancellation and cleanup included); Plex rides `crate::local_plex` +//! (wired this round) and the `QbzLocal` invokables. +//! +//! Deltas vs `settings/LocalLibrarySettings.slint` (reported, not hidden): +//! - "Add folder" is a PATH FIELD + button, not the native folder chooser: +//! the picker helper lives private in `local_ephemeral.rs` and the port has +//! no `rfd` dependency. `open_path`-style behaviour, same end state. +//! - The per-folder EDIT modal (`LibFolderEditModal.slint`: alias + network +//! overrides) is not ported, so no edit affordance is shown. +//! - The Plex PIN flow (generate code / link code / open sign-in) is not +//! ported; the manual server-url + token path is what this wires. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{LazyLock, Mutex}; + +use qbz_library::{LibraryDatabase, ScanEvent}; +use serde::Serialize; + +// --------------------------------------------------------------------------- +// Transport rows (the `library` sub-document of the settings snapshot) +// --------------------------------------------------------------------------- + +#[derive(Clone, Serialize)] +pub struct FolderRow { + pub id: i64, + #[serde(rename = "displayName")] + pub display_name: String, + pub path: String, + /// Unix seconds; 0 = never scanned. QML formats it (locale-aware). + #[serde(rename = "lastScan")] + pub last_scan: i64, + pub enabled: bool, + #[serde(rename = "isNetwork")] + pub is_network: bool, + /// Network folders only: whether the mount answers right now. + pub accessible: bool, +} + +#[derive(Clone, Default, Serialize)] +pub struct PlexFields { + /// The persisted (resolved) server url — prefills the address field. + #[serde(rename = "serverUrl")] + pub server_url: String, + /// A token is stored (the token itself NEVER leaves Rust). + #[serde(rename = "hasToken")] + pub has_token: bool, + #[serde(rename = "metadataWrite")] + pub metadata_write: bool, + /// Section collapse state (ui_prefs, same idea as the scrobbler header). + pub collapsed: bool, +} + +#[derive(Clone, Default, Serialize)] +pub struct Snapshot { + pub folders: Vec, + pub scanning: bool, + pub processed: i32, + pub total: i32, + pub file: String, + pub cleaning: bool, + #[serde(rename = "cleanupStatus")] + pub cleanup_status: String, + pub clearing: bool, + /// Inline result of the last add/remove (empty when there is nothing to say). + pub status: String, + pub plex: PlexFields, +} + +// --------------------------------------------------------------------------- +// Live state +// --------------------------------------------------------------------------- + +static SCANNING: AtomicBool = AtomicBool::new(false); +static CANCEL: AtomicBool = AtomicBool::new(false); +static PROCESSED: AtomicU32 = AtomicU32::new(0); +static TOTAL: AtomicU32 = AtomicU32::new(0); +static CURRENT_FILE: LazyLock> = LazyLock::new(|| Mutex::new(String::new())); +static CLEANING: AtomicBool = AtomicBool::new(false); +static CLEANUP_STATUS: LazyLock> = LazyLock::new(|| Mutex::new(String::new())); +static CLEARING: AtomicBool = AtomicBool::new(false); +static STATUS: LazyLock> = LazyLock::new(|| Mutex::new(String::new())); + +fn set_status(text: String) { + *STATUS.lock().unwrap_or_else(|e| e.into_inner()) = text; +} + +/// Open (creating if needed) the per-user `library.db`. +/// +/// `local_state::with_db` deliberately returns `None` when the file does not +/// exist yet (a browse read must never create an empty library); adding the +/// FIRST folder is exactly the case that has to create it, so this module +/// owns a creating opener. +fn open_db() -> Option { + let path = crate::local_state::db_path()?; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + match LibraryDatabase::open(&path) { + Ok(db) => Some(db), + Err(e) => { + log::error!("[qbz-qt] library db open failed: {e}"); + None + } + } +} + +fn basename(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()) +} + +// --------------------------------------------------------------------------- +// Snapshot (called from the settings publish, on the blocking pool) +// --------------------------------------------------------------------------- + +pub fn snapshot() -> Snapshot { + let folders = crate::local_state::with_db(|db| db.get_folders_with_metadata()) + .unwrap_or_default() + .into_iter() + .map(|f| { + let accessible = if f.is_network { + std::fs::read_dir(&f.path).is_ok() + } else { + true + }; + FolderRow { + display_name: match f.alias.as_deref() { + Some(a) if !a.is_empty() => a.to_string(), + _ => basename(&f.path), + }, + id: f.id, + path: f.path, + last_scan: f.last_scan.unwrap_or(0), + enabled: f.enabled, + is_network: f.is_network, + accessible, + } + }) + .collect(); + + let plex_cfg = crate::local_plex::settings(); + Snapshot { + folders, + scanning: SCANNING.load(Ordering::SeqCst), + processed: PROCESSED.load(Ordering::SeqCst) as i32, + total: TOTAL.load(Ordering::SeqCst) as i32, + file: CURRENT_FILE.lock().unwrap_or_else(|e| e.into_inner()).clone(), + cleaning: CLEANING.load(Ordering::SeqCst), + cleanup_status: CLEANUP_STATUS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(), + clearing: CLEARING.load(Ordering::SeqCst), + status: STATUS.lock().unwrap_or_else(|e| e.into_inner()).clone(), + plex: PlexFields { + has_token: !plex_cfg.token.trim().is_empty(), + server_url: plex_cfg.base_url, + metadata_write: plex_cfg.metadata_write_enabled, + collapsed: super::pref_bool("plex_ui_collapsed", false), + }, + } +} + +// --------------------------------------------------------------------------- +// Folder CRUD +// --------------------------------------------------------------------------- + +/// "Add folder": validate the path, detect network-ness, insert. +pub async fn add_folder(path: String) { + let path = path.trim().to_string(); + if path.is_empty() { + return; + } + let _ = tokio::task::spawn_blocking(move || { + let p = std::path::Path::new(&path); + if !p.is_dir() { + set_status(qbz_i18n::t("That folder does not exist.")); + return; + } + let canonical = std::fs::canonicalize(p) + .map(|c| c.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.clone()); + let is_network = qbz_library::is_network_path(std::path::Path::new(&canonical)); + let fs_label = if is_network { + qbz_library::network_fs_label(std::path::Path::new(&canonical)) + } else { + None + }; + let Some(db) = open_db() else { + set_status(qbz_i18n::t("Could not open the library database.")); + return; + }; + match db.add_folder_with_network_info(&canonical, is_network, fs_label.as_deref()) { + Ok(_) => set_status(String::new()), + Err(e) => { + log::error!("[qbz-qt] add folder failed: {e}"); + set_status(qbz_i18n::t("Could not add that folder.")); + } + } + }) + .await; + refresh_browse(); +} + +/// Remove folders (their indexed tracks go with them — `remove_folder_with_tracks`). +pub async fn remove_folders(ids: Vec) { + if ids.is_empty() { + return; + } + let _ = tokio::task::spawn_blocking(move || { + let Some(db) = open_db() else { + return; + }; + let all = db.get_folders_with_metadata().unwrap_or_default(); + for id in ids { + let Some(folder) = all.iter().find(|f| f.id == id) else { + continue; + }; + if let Err(e) = db.remove_folder_with_tracks(&folder.path) { + log::error!("[qbz-qt] remove folder failed: {e}"); + set_status(qbz_i18n::t("Could not remove that folder.")); + } + } + }) + .await; + refresh_browse(); +} + +/// Flip a folder's enabled flag (a disabled folder is skipped by every scan). +pub async fn toggle_folder_enabled(id: i64) { + let _ = tokio::task::spawn_blocking(move || { + let Some(db) = open_db() else { + return; + }; + let current = db + .get_folder_by_id(id) + .ok() + .flatten() + .map(|f| f.enabled) + .unwrap_or(true); + if let Err(e) = db.set_folder_enabled(id, !current) { + log::error!("[qbz-qt] set folder enabled failed: {e}"); + } + }) + .await; + refresh_browse(); +} + +// --------------------------------------------------------------------------- +// Scan +// --------------------------------------------------------------------------- + +/// Scan every enabled folder (`None`) or exactly one (`Some(id)`). +/// +/// Progress rides the statics; a 2 s ticker republishes the settings document +/// while the scan runs (the document is the only transport this port has, and +/// republishing per FILE would rebuild the whole snapshot thousands of times). +pub fn scan(folder_id: Option) { + if SCANNING.swap(true, Ordering::SeqCst) { + return; + } + CANCEL.store(false, Ordering::SeqCst); + PROCESSED.store(0, Ordering::SeqCst); + TOTAL.store(0, Ordering::SeqCst); + *CURRENT_FILE.lock().unwrap_or_else(|e| e.into_inner()) = String::new(); + + crate::spawn(async move { + // Progress ticker. + crate::spawn(async { + while SCANNING.load(Ordering::SeqCst) { + tokio::time::sleep(std::time::Duration::from_millis(2000)).await; + super::publish_snapshot().await; + } + }); + + let _ = tokio::task::spawn_blocking(move || { + let Some(db) = open_db() else { + return; + }; + let cache = qbz_library::get_artwork_cache_dir(); + let ids = folder_id.map(|id| vec![id]); + let on_event = move |event: ScanEvent| match event { + ScanEvent::TotalsAdded { total } => { + TOTAL.store(total, Ordering::SeqCst); + } + ScanEvent::FileStarted { path } => { + let name = basename(&path); + *CURRENT_FILE.lock().unwrap_or_else(|e| e.into_inner()) = name; + } + ScanEvent::FileDone { processed, total } => { + PROCESSED.store(processed, Ordering::SeqCst); + TOTAL.store(total, Ordering::SeqCst); + } + ScanEvent::Finished { errors, .. } => { + if !errors.is_empty() { + log::warn!("[qbz-qt] library scan finished with {} errors", errors.len()); + } + } + _ => {} + }; + if let Err(e) = qbz_library::scan_with_progress( + &db, + ids.as_deref(), + &cache, + &CANCEL, + &on_event, + ) { + log::error!("[qbz-qt] library scan failed: {e}"); + set_status(qbz_i18n::t("Scan failed.")); + } + }) + .await; + + SCANNING.store(false, Ordering::SeqCst); + *CURRENT_FILE.lock().unwrap_or_else(|e| e.into_inner()) = String::new(); + refresh_browse(); + super::publish_snapshot().await; + }); +} + +/// "Stop" — checked at every file boundary by the shared engine. +pub fn stop_scan() { + CANCEL.store(true, Ordering::SeqCst); +} + +// --------------------------------------------------------------------------- +// Maintenance + danger zone +// --------------------------------------------------------------------------- + +/// Remove tracks whose files no longer exist (Slint `cleanup_missing`, same +/// guard: a network folder whose mount is DOWN stats as missing for every +/// file, so its subtree is skipped instead of being wiped). +pub async fn cleanup_missing() { + if CLEANING.swap(true, Ordering::SeqCst) { + return; + } + *CLEANUP_STATUS.lock().unwrap_or_else(|e| e.into_inner()) = + qbz_i18n::t("Scanning track paths..."); + super::publish_snapshot().await; + + let result = tokio::task::spawn_blocking(|| { + let paths = crate::local_state::with_db(|db| db.get_all_track_paths())?; + let skip: Vec = crate::local_state::with_db(|db| db.get_folders_with_metadata()) + .unwrap_or_default() + .into_iter() + .filter(|f| f.is_network && std::fs::read_dir(&f.path).is_err()) + .map(|f| { + if f.path.ends_with('/') { + f.path + } else { + format!("{}/", f.path) + } + }) + .collect(); + let checked = paths.len(); + let missing: Vec = paths + .into_iter() + .filter(|(_, p)| { + !skip.iter().any(|pre| p.starts_with(pre.as_str())) + && !std::path::Path::new(p).exists() + }) + .map(|(id, _)| id) + .collect(); + let mut removed = 0usize; + for chunk in missing.chunks(500) { + removed += crate::local_state::with_db(|db| db.delete_tracks_by_ids(chunk)).unwrap_or(0); + } + Some((checked, removed)) + }) + .await + .ok() + .flatten(); + + *CLEANUP_STATUS.lock().unwrap_or_else(|e| e.into_inner()) = match result { + Some((checked, removed)) => qbz_i18n::t("Checked {} tracks, removed {}") + .replacen("{}", &checked.to_string(), 1) + .replacen("{}", &removed.to_string(), 1), + None => qbz_i18n::t("Cleanup failed."), + }; + CLEANING.store(false, Ordering::SeqCst); + refresh_browse(); + super::publish_snapshot().await; +} + +/// "Clear library database" — drops the indexed tracks; the audio files and +/// the registered folders stay (`clear_all_tracks`, Slint parity). +pub async fn clear_library() { + if CLEARING.swap(true, Ordering::SeqCst) { + return; + } + super::publish_snapshot().await; + let _ = tokio::task::spawn_blocking(|| { + if crate::local_state::with_db(|db| db.clear_all_tracks()).is_none() { + set_status(qbz_i18n::t("Could not clear the library database.")); + } + }) + .await; + CLEARING.store(false, Ordering::SeqCst); + refresh_browse(); + super::publish_snapshot().await; +} + +// --------------------------------------------------------------------------- +// Plex (the rows the QbzLocal properties do not already carry) +// --------------------------------------------------------------------------- + +pub fn set_metadata_write(value: bool) { + crate::local_plex::set_metadata_write_enabled(value); +} + +/// Plex danger zone > "Clear cache": drop the cached libraries and tracks but +/// KEEP the sign-in (`disconnect` is the one that clears credentials). +pub async fn plex_clear_cache() { + let _ = tokio::task::spawn_blocking(|| { + if let Err(e) = qbz_plex::plex_cache_clear() { + log::error!("[qbz-qt] plex cache clear failed: {e}"); + } + }) + .await; + crate::local_bridge_ops::publish_plex_state(); + refresh_browse(); +} + +// --------------------------------------------------------------------------- +// Shared tail +// --------------------------------------------------------------------------- + +/// Re-run the Local Library browse documents after any mutation — the grid, +/// the artists rail, the Tracks page and the tab badges are all queries. +fn refresh_browse() { + crate::local_bridge_ops::reload_browse(); +} diff --git a/crates/qbz-qt/src/settings_qt/offline.rs b/crates/qbz-qt/src/settings_qt/offline.rs new file mode 100644 index 000000000..1762c6da3 --- /dev/null +++ b/crates/qbz-qt/src/settings_qt/offline.rs @@ -0,0 +1,128 @@ +//! Settings > Offline — the offline-MODE rows and the lyrics-cache row. +//! +//! ADR-006: the mode itself is the shared `qbz_app::offline_mode` engine +//! (`crate::offline_fwd::engine()`), including the #279 stream-first snapshot +//! it takes when induced offline is entered; the lyrics stats/clear go through +//! `qbz_lyrics::LyricsCacheDb` opened at the SAME per-user path +//! `lyrics_qt.rs` binds the service to. +//! +//! Deltas vs `settings/OfflineSettings.slint` (reported, not hidden): +//! - "Check now" is NOT shipped: the connectivity actor lives private in +//! `offline_fwd.rs` (`CONNECTIVITY`), so a re-probe has no seam here. The +//! status line itself IS live (QbzSession.offlineMode / captivePortal). +//! - The OFFLINE CACHE group (manager / open folder / clear all) is not +//! shipped: the port never brings up `OfflineCacheState`, so all three +//! would be dead buttons. + +use serde::Serialize; + +#[derive(Clone, Default, Serialize)] +pub struct Snapshot { + /// The persisted induced-offline flag (the "Enable Offline Mode" toggle). + #[serde(rename = "modeEnabled")] + pub mode_enabled: bool, + /// False when there is no per-user lyrics cache yet — the stats line hides. + #[serde(rename = "lyricsLoaded")] + pub lyrics_loaded: bool, + #[serde(rename = "lyricsEntries")] + pub lyrics_entries: i64, + #[serde(rename = "lyricsSize")] + pub lyrics_size: String, +} + +fn lyrics_db_path() -> Option { + let uid = qbz_app::user_data::UserDataPaths::load_last_user_id()?; + Some( + dirs::cache_dir()? + .join("qbz") + .join("users") + .join(uid.to_string()) + .join("lyrics") + .join("lyrics.db"), + ) +} + +/// `offline_manager::human_size` equivalent (B / KB / MB / GB, one decimal). +fn human_size(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; + let mut value = bytes as f64; + let mut unit = 0usize; + while value >= 1024.0 && unit + 1 < UNITS.len() { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{} {}", bytes, UNITS[0]) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +pub fn snapshot() -> Snapshot { + let engine = crate::offline_fwd::engine(); + let mode_enabled = engine + .settings() + .map(|s| s.manual_offline_mode) + .unwrap_or(false); + + let (lyrics_loaded, lyrics_entries, lyrics_size) = match lyrics_db_path() { + Some(path) if path.exists() => match qbz_lyrics::LyricsCacheDb::new(&path) { + Ok(db) => match db.stats() { + Ok(stats) => (true, stats.entries as i64, human_size(stats.size_bytes)), + Err(e) => { + log::warn!("[qbz-qt] lyrics cache stats failed: {e}"); + (false, 0, String::new()) + } + }, + Err(e) => { + log::warn!("[qbz-qt] lyrics cache open failed: {e}"); + (false, 0, String::new()) + } + }, + _ => (false, 0, String::new()), + }; + + Snapshot { + mode_enabled, + lyrics_loaded, + lyrics_entries, + lyrics_size, + } +} + +/// "Enable Offline Mode" — the persisted induced flag. `set_induced` also +/// runs the #279 stream-first snapshot/restore against the audio store, so +/// the store handle is passed in (best-effort, exactly like the Slint). +pub fn set_mode_enabled(value: bool) -> Result<(), String> { + let engine = crate::offline_fwd::engine(); + super::with_audio(|store| { + engine + .set_induced(value, Some(store)) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .or_else(|_| { + // No audio store open (pre-login): flip the flag anyway. + crate::offline_fwd::engine() + .set_induced(value, None) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) +} + +/// "Clear lyrics cache". +pub async fn clear_lyrics_cache() { + let _ = tokio::task::spawn_blocking(|| { + let Some(path) = lyrics_db_path() else { + return; + }; + if !path.exists() { + return; + } + match qbz_lyrics::LyricsCacheDb::new(&path).and_then(|db| db.clear()) { + Ok(()) => log::info!("[qbz-qt] lyrics cache cleared"), + Err(e) => log::error!("[qbz-qt] lyrics cache clear failed: {e}"), + } + }) + .await; +}