diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 74e792e98..ed18a94a1 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -6626,6 +6626,7 @@ dependencies = [ "qbz-core", "qbz-credentials", "qbz-i18n", + "qbz-integrations", "qbz-library", "qbz-log", "qbz-lyrics", @@ -6633,6 +6634,7 @@ dependencies = [ "qbz-offline-cache", "qbz-qobuz", "qbz-text-utils", + "qbz-theme", "rand 0.10.1", "reqwest", "rusqlite", diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index 2e88c5a3c..6e34e08d7 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -37,6 +37,8 @@ qbz-text-utils = { path = "../qbz-text-utils" } qbz-credentials = { path = "../qbz-credentials" } qbz-i18n = { path = "../qbz-i18n" } qbz-log = { path = "../qbz-log" } +qbz-theme = { path = "../qbz-theme" } +qbz-integrations = { path = "../qbz-integrations" } rusqlite = { version = "0.31", features = ["bundled"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 6bde35837..bd7f3e868 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -55,6 +55,17 @@ fn main() { "qml/HomeView.qml", "qml/LibraryView.qml", "qml/SettingsView.qml", + "qml/AppearanceSettings.qml", + "qml/IntegrationsSettings.qml", + "qml/SettingRow.qml", + "qml/GroupHeader.qml", + "qml/SettingsDivider.qml", + "qml/SettingsSpacer.qml", + "qml/SettingsButton.qml", + "qml/QbzToggle.qml", + "qml/QbzSelect.qml", + "qml/QbzSlider.qml", + "qml/QbzLineEdit.qml", "qml/SearchView.qml", "qml/Cortinilla.qml", "qml/PlaylistView.qml", diff --git a/crates/qbz-qt/qml/AppearanceSettings.qml b/crates/qbz-qt/qml/AppearanceSettings.qml new file mode 100644 index 000000000..d0c831740 --- /dev/null +++ b/crates/qbz-qt/qml/AppearanceSettings.qml @@ -0,0 +1,471 @@ +// 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. +// +// The THEME row is live: QbzBridge.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. + +import QtQuick +import com.blitzfc.qbz + +Column { + id: root + + property var doc: ({}) + + QbzTheme { id: theme } + + spacing: 4 + + // --- Theme row state (theme_qt.rs via the bridge) -------------------- + readonly property var themeEntries: { + try { + return JSON.parse(QbzBridge.themeListJson) + } catch (e) { + return [] + } + } + // 0 All / 1 Dark / 2 Light; the Auto/Custom synthetics only list under + // All (theme.rs:217-227). + readonly property var filteredThemes: { + const f = QbzBridge.themeFilter + const out = [] + for (let i = 0; i < themeEntries.length; i++) { + const e = themeEntries[i] + const synthetic = e.slug === "auto" || e.slug === "custom" + if (f === 1 && (e.isLight || synthetic)) continue + if (f === 2 && (!e.isLight || synthetic)) continue + out.push(e) + } + return out + } + readonly property var filteredLabels: filteredThemes.map(function (e) { return e.label }) + readonly property int currentThemeIndex: { + for (let i = 0; i < filteredThemes.length; i++) { + if (filteredThemes[i].slug === QbzBridge.themeSlug) return i + } + return -1 + } + readonly property bool tbLocked: doc.hideTitleBar === true || doc.useSystemTitleBar === true + + // ============================ THEME ================================== + GroupHeader { text: QbzBridge.tr("THEME") } + SettingRow { + label: QbzBridge.tr("Theme") + 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: QbzBridge.themeFilter === 1 ? "moon" + : QbzBridge.themeFilter === 2 ? "sun" : "sun-moon" + width: 16 + height: 16 + tintName: "secondary" + } + MouseArea { + id: fArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.themeSetFilter((QbzBridge.themeFilter + 1) % 3) + } + } + QbzSelect { + menuWidth: 220 + searchable: true + options: root.filteredLabels + currentIndex: root.currentThemeIndex + onSelected: function (i) { + if (i >= 0 && i < root.filteredThemes.length) + QbzBridge.themeSet(root.filteredThemes[i].slug) + } + } + } + } + SettingRow { + label: QbzBridge.tr("Album header gradient") + description: QbzBridge.tr("Use artwork-derived blur as a backdrop in album and artist detail views.") + QbzToggle { + checked: root.doc.albumHeaderGradient === true + onToggled: function (v) { QbzBridge.settingsBool("album-header-gradient", v) } + } + } + SettingRow { + label: QbzBridge.tr("Dynamic background") + QbzSelect { + menuWidth: 200 + options: root.doc.appBackgroundModes || [] + currentIndex: root.doc.appBackgroundIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("app-background", i) } + } + } + SettingRow { + label: QbzBridge.tr("Intelligent Search") + QbzToggle { + checked: root.doc.intelligentSearch === true + onToggled: function (v) { QbzBridge.settingsBool("intelligent-search", v) } + } + } + // Auto-theme rows (theme slug "auto" only). + SettingRow { + visible: QbzBridge.themeSlug === "auto" + label: QbzBridge.tr("Source") + QbzSelect { + menuWidth: 200 + options: [QbzBridge.tr("System Colors"), QbzBridge.tr("Wallpaper Sync"), QbzBridge.tr("Custom Image")] + currentIndex: root.doc.autoThemeSourceIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("auto-theme-source", i) } + } + } + SettingRow { + visible: QbzBridge.themeSlug === "auto" + label: QbzBridge.tr("Regenerate") + description: QbzBridge.tr("Rebuild the palette from the source (System colors in the POC).") + SettingsButton { + text: QbzBridge.tr("Regenerate") + onClicked: QbzBridge.themeSet(QbzBridge.themeSlug) + } + } + // Custom-theme rows (theme slug "custom" only) — no token editor. + SettingRow { + visible: QbzBridge.themeSlug === "custom" + label: QbzBridge.tr("Custom theme") + description: QbzBridge.tr("Applied read-only from custom_theme.json — the token editor is not ported to the Qt frontend (POC).") + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ===================== TYPOGRAPHY & LANGUAGE ========================= + GroupHeader { text: QbzBridge.tr("TYPOGRAPHY & LANGUAGE") } + SettingRow { + label: QbzBridge.tr("Language") + QbzSelect { + menuWidth: 200 + options: root.doc.languages || [] + currentIndex: root.doc.languageIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("language", i) } + } + } + SettingRow { + label: QbzBridge.tr("Interface size") + description: QbzBridge.tr("Restart to apply.") + QbzSelect { + menuWidth: 200 + options: root.doc.uiScales || [] + currentIndex: root.doc.uiScaleIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("ui-scale", i) } + } + } + SettingRow { + label: QbzBridge.tr("Immersive search") + QbzSelect { + menuWidth: 220 + options: root.doc.immersiveSearchActions || [] + currentIndex: root.doc.immersiveSearchActionIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("immersive-search-action", i) } + } + } + SettingRow { + label: QbzBridge.tr("Immersive default view") + QbzSelect { + menuWidth: 220 + options: root.doc.immersiveDefaultViews || [] + currentIndex: root.doc.immersiveDefaultViewIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("immersive-default-view", i) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ======================= LIBRARY & VISUALS =========================== + GroupHeader { text: QbzBridge.tr("LIBRARY & VISUALS") } + SettingRow { + label: QbzBridge.tr("Show navigation in sidebar") + QbzToggle { + checked: root.doc.navInSidebar === true + onToggled: function (v) { QbzBridge.settingsBool("nav-in-sidebar", v) } + } + } + SettingRow { + // ADR-010: only mounted when navigation is NOT in the sidebar. + visible: root.doc.navInSidebar !== true + label: QbzBridge.tr("Compact header navigation") + QbzToggle { + checked: root.doc.navHeaderCompact === true + onToggled: function (v) { QbzBridge.settingsBool("nav-header-compact", v) } + } + } + SettingRow { + label: QbzBridge.tr("My QBZ") + Row { + spacing: 8 + RoundedImage { + width: 34 + height: 34 + source: "assets/qbz-logo.png" + radius: theme.radiusSm + } + QbzLineEdit { + width: 150 + text: root.doc.myQbzLabel || "" + placeholder: QbzBridge.tr("My QBZ") + onCommitted: function (v) { QbzBridge.settingsString("myqbz-label", v) } + } + SettingsButton { + iconName: "rotate-ccw" + onClicked: QbzBridge.settingsString("myqbz-label", "") + } + } + } + SettingRow { + label: QbzBridge.tr("Show playlist cover collages in sidebar") + QbzToggle { + checked: root.doc.sidebarPlaylistCollage === true + onToggled: function (v) { QbzBridge.settingsBool("sidebar-playlist-collage", v) } + } + } + SettingRow { + label: QbzBridge.tr("Show album artwork in Local Library track list") + QbzToggle { + checked: root.doc.localLibraryTrackArtwork === true + onToggled: function (v) { QbzBridge.settingsBool("local-library-track-artwork", v) } + } + } + SettingRow { + label: QbzBridge.tr("Play indicator animation") + QbzToggle { + checked: root.doc.playIndicatorAnimation === true + onToggled: function (v) { QbzBridge.settingsBool("play-indicator-animation", v) } + } + } + SettingRow { + label: QbzBridge.tr("Invert swipe navigation direction") + QbzToggle { + checked: root.doc.invertSwipeNavigation === true + onToggled: function (v) { QbzBridge.settingsBool("invert-swipe-navigation", v) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ========================= NOTIFICATIONS ============================= + GroupHeader { text: QbzBridge.tr("NOTIFICATIONS") } + SettingRow { + label: QbzBridge.tr("In-app toasts notifications") + QbzToggle { + checked: root.doc.inAppToasts === true + onToggled: function (v) { QbzBridge.settingsBool("in-app-toasts", v) } + } + } + SettingRow { + label: QbzBridge.tr("System Notifications") + QbzToggle { + checked: root.doc.systemNotifications === true + onToggled: function (v) { QbzBridge.settingsBool("system-notifications", v) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ========================= WINDOW TITLE ============================== + GroupHeader { text: QbzBridge.tr("WINDOW TITLE") } + SettingRow { + label: QbzBridge.tr("Show track in window title") + QbzToggle { + checked: root.doc.windowTitleShow === true + onToggled: function (v) { QbzBridge.settingsBool("window-title-show", v) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ========================== TITLE BAR ================================ + GroupHeader { text: QbzBridge.tr("TITLE BAR") } + SettingRow { + label: QbzBridge.tr("Use system title bar") + description: QbzBridge.tr("Restart to apply.") + QbzToggle { + checked: root.doc.useSystemTitleBar === true + onToggled: function (v) { QbzBridge.settingsBool("use-system-title-bar", v) } + } + } + SettingRow { + label: QbzBridge.tr("Hide title bar") + rowEnabled: root.doc.useSystemTitleBar !== true + QbzToggle { + enabled: root.doc.useSystemTitleBar !== true + checked: root.doc.hideTitleBar === true + onToggled: function (v) { QbzBridge.settingsBool("hide-title-bar", v) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ======================= WINDOW CONTROLS ============================= + GroupHeader { text: QbzBridge.tr("WINDOW CONTROLS") } + SettingRow { + label: QbzBridge.tr("Window controls position") + rowEnabled: !root.tbLocked + QbzSelect { + enabled: !root.tbLocked + menuWidth: 160 + options: root.doc.wcPositions || [] + currentIndex: root.doc.wcPositionIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("wc-position", i) } + } + } + SettingRow { + label: QbzBridge.tr("Show window controls") + rowEnabled: !root.tbLocked + QbzToggle { + enabled: !root.tbLocked + checked: root.doc.showWindowControls === true + onToggled: function (v) { QbzBridge.settingsBool("show-window-controls", v) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ========================= PLAYER VIEWS ============================== + GroupHeader { text: QbzBridge.tr("PLAYER VIEWS") } + SettingRow { + label: QbzBridge.tr("Show volume +/- buttons") + QbzToggle { + checked: root.doc.showVolumeSteppers === true + onToggled: function (v) { QbzBridge.settingsBool("show-volume-steppers", v) } + } + } + SettingRow { + label: QbzBridge.tr("Mini player default view") + QbzSelect { + menuWidth: 220 + options: root.doc.miniDefaultViews || [] + currentIndex: root.doc.miniDefaultViewIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("mini-default-view", i) } + } + } + SettingRow { + label: QbzBridge.tr("Startup page") + QbzSelect { + menuWidth: 220 + options: root.doc.startupPages || [] + currentIndex: root.doc.startupPageIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("startup-page", i) } + } + } + SettingRow { + label: QbzBridge.tr("Show Purchases") + QbzToggle { + checked: root.doc.showPurchases === true + onToggled: function (v) { QbzBridge.settingsBool("show-purchases", v) } + } + } + SettingRow { + label: QbzBridge.tr("Purchases in title bar") + rowEnabled: !root.tbLocked + QbzToggle { + enabled: !root.tbLocked + checked: root.doc.navTbPurchases === true + onToggled: function (v) { QbzBridge.settingsBool("nav-tb-purchases", v) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ========================== SYSTEM TRAY ============================== + GroupHeader { text: QbzBridge.tr("SYSTEM TRAY") } + SettingRow { + label: QbzBridge.tr("Enable tray icon") + QbzToggle { + checked: root.doc.trayEnable === true + onToggled: function (v) { QbzBridge.settingsBool("tray-enable", v) } + } + } + SettingRow { + label: QbzBridge.tr("Close to tray") + rowEnabled: root.doc.trayEnable === true + QbzToggle { + enabled: root.doc.trayEnable === true + checked: root.doc.trayCloseToTray === true + onToggled: function (v) { QbzBridge.settingsBool("tray-close-to-tray", v) } + } + } + SettingRow { + label: QbzBridge.tr("Tray icon variant") + QbzSelect { + menuWidth: 200 + options: root.doc.trayIconThemes || [] + currentIndex: root.doc.trayIconThemeIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("tray-icon-theme", i) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // =========================== RENDERER ================================ + GroupHeader { text: QbzBridge.tr("RENDERER") } + SettingRow { + label: QbzBridge.tr("Rendering backend") + description: QbzBridge.tr("Restart to apply.") + QbzSelect { + menuWidth: 220 + options: root.doc.renderers || [] + currentIndex: root.doc.rendererIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("renderer", i) } + } + } + SettingRow { + label: QbzBridge.tr("Preferred GPU") + description: QbzBridge.tr("Restart to apply.") + QbzSelect { + menuWidth: 260 + options: root.doc.gpuPowers || [] + currentIndex: root.doc.gpuPowerIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("gpu-power", i) } + } + } +} diff --git a/crates/qbz-qt/qml/GroupHeader.qml b/crates/qbz-qt/qml/GroupHeader.qml new file mode 100644 index 000000000..007207ff8 --- /dev/null +++ b/crates/qbz-qt/qml/GroupHeader.qml @@ -0,0 +1,15 @@ +// Settings group header (AppearanceSettings.slint GroupHeader) — extracted +// from SettingsView.qml in phase 19: 11px muted uppercase, 1.5px tracking, +// semibold. + +import QtQuick +import com.blitzfc.qbz + +Text { + QbzTheme { id: theme } + + color: theme.textMuted + font.pixelSize: 11 + font.letterSpacing: 1.5 + font.weight: theme.weightSemibold +} diff --git a/crates/qbz-qt/qml/IntegrationsSettings.qml b/crates/qbz-qt/qml/IntegrationsSettings.qml new file mode 100644 index 000000000..4fc1b5c51 --- /dev/null +++ b/crates/qbz-qt/qml/IntegrationsSettings.qml @@ -0,0 +1,238 @@ +// Settings > Integrations (phase 19) — the QML port of +// crates/qbz-ui/ui/settings/IntegrationsSettings.slint. Row order, group +// headers and auth-gating mirror the Slint 1:1; all state rides the single +// settingsJson document (integrations_qt.rs over the SAME scrobbler / +// discover / ui_prefs stores the Slint app uses). +// +// POC-NOTEs (named in integrations_qt.rs too): +// - No live scrobbling (scrobble::start / on_track_changed not ported) — +// toggles + credentials persist for the Slint scrobbler / a future port. +// - Discord: the pref + live DiscordRpc flag flip; presence updates are +// not fed (no NowListening pushes on track change). +// - The Last.fm connect flow opens the system browser (open::that) — the +// offscreen smoke never clicks it. + +import QtQuick +import com.blitzfc.qbz + +Column { + id: root + + property var doc: ({}) + + QbzTheme { id: theme } + + spacing: 4 + + // ======================= RECOMMENDATIONS ============================= + GroupHeader { text: QbzBridge.tr("RECOMMENDATIONS") } + SettingRow { + label: QbzBridge.tr("Show Recommendations in Discover") + description: QbzBridge.tr("A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases.") + QbzToggle { + checked: root.doc.showRecommendations === true + onToggled: function (v) { QbzBridge.settingsBool("show-recommendations", v) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // =========================== METADATA ================================ + GroupHeader { text: QbzBridge.tr("METADATA") } + SettingRow { + label: QbzBridge.tr("MusicBrainz") + description: QbzBridge.tr("Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections.") + QbzToggle { + checked: root.doc.musicbrainzEnabled === true + onToggled: function (v) { QbzBridge.settingsBool("musicbrainz", v) } + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // ========================== SCROBBLERS =============================== + // Master header row (toggle + collapse chevron), then the body gated + // on enabled && !collapsed (IntegrationsSettings.slint:113-169). + Item { + width: parent.width + height: 64 + Column { + anchors.left: parent.left + anchors.right: scrobbleControl.left + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + Text { + width: parent.width + text: QbzBridge.tr("SCROBBLERS") + color: theme.textMuted + font.pixelSize: 11 + font.letterSpacing: 1.5 + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + Text { + width: parent.width + text: QbzBridge.tr("Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks.") + color: theme.textMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } + Row { + id: scrobbleControl + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + // Collapse chevron (only when the master toggle is on). + Rectangle { + visible: root.doc.scrobbleEnabled === true + width: 28 + height: 28 + radius: theme.radiusSm + color: colArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { + anchors.centerIn: parent + name: root.doc.scrobbleUiCollapsed === true ? "chevron-right" : "chevron-down" + width: 16 + height: 16 + tintName: "secondary" + } + MouseArea { + id: colArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.settingsBool("scrobble-collapse", root.doc.scrobbleUiCollapsed !== true) + } + } + QbzToggle { + anchors.verticalCenter: parent.verticalCenter + checked: root.doc.scrobbleEnabled === true + onToggled: function (v) { QbzBridge.settingsBool("scrobble-enable", v) } + } + } + } + + Column { + visible: root.doc.scrobbleEnabled === true && root.doc.scrobbleUiCollapsed !== true + width: parent.width + spacing: 4 + + // ------------------------- LAST.FM ------------------------------ + GroupHeader { text: QbzBridge.tr("LAST.FM") } + SettingRow { + label: QbzBridge.tr("Scrobble to Last.fm") + description: root.doc.lastfmAuthed === true + ? QbzBridge.tr("Signed in as {}.").replace("{}", root.doc.lastfmUsername || "") + : QbzBridge.tr("Connect your Last.fm account to enable scrobbling.") + QbzToggle { + checked: root.doc.lastfmEnabled === true + onToggled: function (v) { QbzBridge.settingsBool("lastfm-enable", v) } + } + } + SettingRow { + visible: root.doc.lastfmAuthed !== true + label: QbzBridge.tr("Connect") + description: QbzBridge.tr("Authorize QBZ in your browser, then click Finish.") + Row { + spacing: 8 + SettingsButton { + text: root.doc.lastfmBusy === true ? QbzBridge.tr("Working...") : QbzBridge.tr("Connect Last.fm") + enabled: root.doc.lastfmBusy !== true + onClicked: QbzBridge.integrationsAction("lastfm-connect") + } + SettingsButton { + visible: (root.doc.lastfmAuthUrl || "") !== "" + text: QbzBridge.tr("Open authorize page") + onClicked: QbzBridge.integrationsAction("lastfm-open-auth-url") + } + SettingsButton { + visible: (root.doc.lastfmAuthUrl || "") !== "" + text: QbzBridge.tr("Finish") + onClicked: QbzBridge.integrationsAction("lastfm-finish") + } + } + } + SettingRow { + visible: root.doc.lastfmAuthed === true + label: QbzBridge.tr("Disconnect Last.fm") + description: QbzBridge.tr("Sign out of Last.fm.") + SettingsButton { + danger: true + text: QbzBridge.tr("Disconnect") + onClicked: QbzBridge.integrationsAction("lastfm-disconnect") + } + } + + SettingsSpacer { } + + // ----------------------- LISTENBRAINZ ---------------------------- + GroupHeader { text: QbzBridge.tr("LISTENBRAINZ") } + SettingRow { + label: QbzBridge.tr("Scrobble to ListenBrainz") + description: root.doc.listenbrainzAuthed === true + ? QbzBridge.tr("Signed in as {}.").replace("{}", root.doc.listenbrainzUsername || "") + : QbzBridge.tr("Paste your ListenBrainz user token to enable scrobbling.") + QbzToggle { + checked: root.doc.listenbrainzEnabled === true + onToggled: function (v) { QbzBridge.settingsBool("listenbrainz-enable", v) } + } + } + SettingRow { + visible: root.doc.listenbrainzAuthed !== true + label: QbzBridge.tr("User token") + description: QbzBridge.tr("From listenbrainz.org/settings.") + QbzLineEdit { + isPassword: true + placeholder: QbzBridge.tr("ListenBrainz token") + onCommitted: function (v) { QbzBridge.settingsString("listenbrainz-token", v) } + } + } + SettingRow { + visible: root.doc.listenbrainzAuthed === true + label: QbzBridge.tr("Disconnect ListenBrainz") + description: QbzBridge.tr("Sign out of ListenBrainz.") + SettingsButton { + danger: true + text: QbzBridge.tr("Disconnect") + onClicked: QbzBridge.integrationsAction("listenbrainz-disconnect") + } + } + + // Status line (scrobble.rs set_status; kind 1 info / 2 ok / 3 error). + Text { + visible: (root.doc.integrationsStatusText || "") !== "" + width: parent.width + text: root.doc.integrationsStatusText || "" + color: root.doc.integrationsStatusKind === 2 ? theme.success + : root.doc.integrationsStatusKind === 3 ? theme.danger + : theme.textMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } + + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } + + // =========================== DISCORD ================================= + GroupHeader { text: QbzBridge.tr("DISCORD") } + SettingRow { + label: QbzBridge.tr("Discord Rich Presence") + description: QbzBridge.tr("Show what you're listening to as your Discord status. Opt-in — Discord must be running.") + QbzToggle { + checked: root.doc.discordEnabled === true + onToggled: function (v) { QbzBridge.settingsBool("discord-rpc", v) } + } + } + SettingRow { + label: QbzBridge.tr("Flatpak socket access") + description: QbzBridge.tr("Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\nflatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz") + } +} diff --git a/crates/qbz-qt/qml/QbzLineEdit.qml b/crates/qbz-qt/qml/QbzLineEdit.qml new file mode 100644 index 000000000..4716867b3 --- /dev/null +++ b/crates/qbz-qt/qml/QbzLineEdit.qml @@ -0,0 +1,57 @@ +// QbzLineEdit (std-widgets LineEdit, settings styling) — extracted from +// SettingsView.qml in phase 19. 240px / 34px elevated r8 bordered input; +// commits on Enter AND on focus loss (the Tauri onchange semantics — +// PlaybackSettings.slint). + +import QtQuick +import com.blitzfc.qbz + +Rectangle { + property string text: "" + property string placeholder: "" + property bool isPassword: false + signal committed(string value) + + QbzTheme { id: theme } + + width: 240 + height: 34 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: theme.surfaceElevated + + TextInput { + id: input + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + color: theme.textPrimary + font.pixelSize: theme.fontBody + verticalAlignment: Text.AlignVCenter + clip: true + echoMode: parent.isPassword ? TextInput.Password : TextInput.Normal + text: parent.text + onAccepted: parent.committed(text) + onActiveFocusChanged: if (!activeFocus) parent.committed(text) + // External republish (e.g. Reset) re-seeds the field while it is + // not being edited. + Binding { + target: input + property: "text" + value: input.parent.text + when: !input.activeFocus + } + } + Text { + visible: input.text === "" + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + text: placeholder + color: theme.textMuted + font.pixelSize: theme.fontBody + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } +} diff --git a/crates/qbz-qt/qml/QbzSelect.qml b/crates/qbz-qt/qml/QbzSelect.qml new file mode 100644 index 000000000..1531d78a1 --- /dev/null +++ b/crates/qbz-qt/qml/QbzSelect.qml @@ -0,0 +1,292 @@ +// QbzSelect (primitives/QbzSelect.slint, standard size) — extracted from +// SettingsView.qml in phase 19 so every settings panel shares the one +// replica (the ui-control-alignment-standard searchable dropdown: the +// filter box shows past ~8 options via `searchable: true`). +// 34px bordered control (elevated, hover -> surface-hover) + a Popup +// list (surface-main r8 border-muted, 32px rows, capped at 360px with +// scroll, optional 42px filter box, 24px group-header rows, "BP" badge +// / speaker glyph on device options). `options` entries are either +// plain strings or {label, bp, group} objects (the device list). + +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz + +Rectangle { + property var options: [] + property int currentIndex: 0 + property int menuWidth: 240 + property int popupWidth: 0 + property bool enabled: true + property bool searchable: false + signal selected(int index) + + QbzTheme { id: theme } + + id: selectRoot + width: menuWidth + height: 34 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: selArea.containsMouse && enabled ? theme.surfaceHover : theme.surfaceElevated + opacity: enabled ? 1.0 : 0.4 + + readonly property int listWidth: Math.max(popupWidth, menuWidth) + readonly property int rowHeight: 32 + readonly property int headerHeight: 24 + readonly property int searchHeight: searchable ? 42 : 0 + readonly property int maxListHeight: 360 + property string filter: "" + + function optLabel(i) { + const o = options[i] + return (typeof o === "string") ? o : (o && o.label !== undefined ? o.label : "") + } + function optHasBadges() { + return options.length > 0 && typeof options[0] !== "string" + } + function optBp(i) { + const o = options[i] + return o && o.bp === true + } + function optGroup(i) { + const o = options[i] + return (o && o.group !== undefined) ? o.group : "" + } + + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 10 + spacing: 8 + Text { + width: parent.width - 16 - 8 - badgeSlot.width - (badgeSlot.visible ? 8 : 0) + height: parent.height + text: selectRoot.currentIndex >= 0 && selectRoot.currentIndex < selectRoot.options.length + ? selectRoot.optLabel(selectRoot.currentIndex) : "" + color: theme.textPrimary + font.pixelSize: theme.fontBody + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + // Trailing BP badge / speaker glyph for the CURRENT device option. + Item { + id: badgeSlot + visible: selectRoot.optHasBadges() + width: visible ? 20 : 0 + height: parent.height + Text { + visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length + && selectRoot.optBp(selectRoot.currentIndex) + anchors.centerIn: parent + text: "BP" + color: theme.accent + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + font.letterSpacing: 0.5 + } + QbzIcon { + visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length + && !selectRoot.optBp(selectRoot.currentIndex) + name: "volume-2" + width: 14 + height: 14 + anchors.centerIn: parent + tintName: "muted" + } + } + QbzIcon { + name: "chevron-down" + width: 16 + height: 16 + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" + } + } + MouseArea { + id: selArea + anchors.fill: parent + hoverEnabled: true + cursorShape: selectRoot.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (selectRoot.enabled) { + selectRoot.filter = "" + popup.open() + } + } + } + + Popup { + id: popup + parent: selectRoot + // Right-anchored: a list wider than the control grows leftward. + x: selectRoot.width - selectRoot.listWidth + y: selectRoot.height + 4 + width: selectRoot.listWidth + height: selectRoot.searchHeight + Math.min(listContent.contentHeight, selectRoot.maxListHeight) + 10 + padding: 0 + closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape + + background: Rectangle { + color: theme.surfaceMain + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderMuted + } + contentItem: Item { + implicitWidth: selectRoot.listWidth + implicitHeight: popup.height + + // Filter box (searchable lists only). + Rectangle { + id: searchBox + visible: selectRoot.searchable + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: selectRoot.searchHeight + color: "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.bottomMargin: 6 + spacing: 8 + QbzIcon { + name: "search" + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" + } + Item { + width: parent.width - 14 - 8 + height: parent.height + TextInput { + id: searchInput + anchors.fill: parent + color: theme.textPrimary + font.pixelSize: theme.fontBody + verticalAlignment: Text.AlignVCenter + clip: true + text: selectRoot.filter + onTextChanged: selectRoot.filter = text + } + Text { + visible: searchInput.text === "" + anchors.fill: parent + text: QbzBridge.tr("Search…") + color: theme.textMuted + font.pixelSize: theme.fontBody + verticalAlignment: Text.AlignVCenter + } + } + } + } + + ListView { + id: listContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: searchBox.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 5 + anchors.bottomMargin: 5 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: selectRoot.options + + delegate: Column { + id: optRow + required property int index + required property var modelData + width: listContent.width + + readonly property string label: selectRoot.optLabel(index) + readonly property bool shown: selectRoot.filter === "" + || label.toLowerCase().indexOf(selectRoot.filter.toLowerCase()) >= 0 + + // Group-header row (ALSA device sections). + Rectangle { + visible: optRow.shown && selectRoot.optGroup(optRow.index) !== "" + width: parent.width + height: visible ? selectRoot.headerHeight : 0 + color: "transparent" + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.top: parent.top + anchors.topMargin: 4 + height: parent.height - 4 + text: selectRoot.optGroup(optRow.index) + color: theme.textMuted + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + font.letterSpacing: 0.5 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + Rectangle { + visible: optRow.shown + width: parent.width + height: visible ? selectRoot.rowHeight : 0 + color: optArea.containsMouse ? theme.surfaceHover : "transparent" + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + Text { + width: parent.width - rowBadge.width - (rowBadge.visible ? 8 : 0) + height: parent.height + text: optRow.label + color: optRow.index === selectRoot.currentIndex + ? theme.accent : theme.textSecondary + font.pixelSize: theme.fontBody + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + Item { + id: rowBadge + visible: selectRoot.optHasBadges() + width: visible ? 20 : 0 + height: parent.height + Text { + visible: selectRoot.optBp(optRow.index) + anchors.centerIn: parent + text: "BP" + color: theme.accent + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + font.letterSpacing: 0.5 + } + QbzIcon { + visible: !selectRoot.optBp(optRow.index) + name: "volume-2" + width: 14 + height: 14 + anchors.centerIn: parent + tintName: "muted" + } + } + } + MouseArea { + id: optArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + popup.close() + selectRoot.selected(optRow.index) + } + } + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/QbzSlider.qml b/crates/qbz-qt/qml/QbzSlider.qml new file mode 100644 index 000000000..524dc515c --- /dev/null +++ b/crates/qbz-qt/qml/QbzSlider.qml @@ -0,0 +1,74 @@ +// QbzSlider (primitives/QbzSlider.slint) — extracted from SettingsView.qml +// in phase 19. 200x22, 4px r2 track, accent fill, 16px thumb; integer +// steps. Like the Slint original the thumb follows the pointer during a +// drag (local dragValue) and commits each step via changed(int). + +import QtQuick +import com.blitzfc.qbz + +Item { + property int minimum: 0 + property int maximum: 10 + property int value: 0 + signal changed(int newValue) + + QbzTheme { id: theme } + + width: 200 + height: 22 + + readonly property int thumbSize: 16 + readonly property real travel: width - thumbSize + readonly property real fraction: maximum > minimum + ? Math.max(0, Math.min(1, (value - minimum) / (maximum - minimum))) : 0 + property bool dragging: false + property real dragFraction: fraction + readonly property real shownFraction: dragging ? dragFraction : fraction + onFractionChanged: if (!dragging) dragFraction = fraction + + function commit(frac) { + const v = Math.round(minimum + Math.max(0, Math.min(1, frac)) * (maximum - minimum)) + if (v !== value) changed(v) + } + + Rectangle { // track + x: 0 + y: Math.round((parent.height - height) / 2) + width: parent.width + height: 4 + radius: 2 + color: theme.surfaceElevated + } + Rectangle { // accent fill + x: 0 + y: Math.round((parent.height - height) / 2) + width: parent.thumbSize / 2 + parent.shownFraction * parent.travel + height: 4 + radius: 2 + color: theme.accent + } + Rectangle { // thumb + width: parent.thumbSize + height: parent.thumbSize + radius: parent.thumbSize / 2 + x: parent.shownFraction * parent.travel + anchors.verticalCenter: parent.verticalCenter + color: theme.textPrimary + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onPressed: { + parent.dragging = true + parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel)) + parent.commit(parent.dragFraction) + } + onPositionChanged: { + if (pressed) { + parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel)) + parent.commit(parent.dragFraction) + } + } + onReleased: parent.dragging = false + } +} diff --git a/crates/qbz-qt/qml/QbzTheme.qml b/crates/qbz-qt/qml/QbzTheme.qml index 2a8028f52..dde8b3ada 100644 --- a/crates/qbz-qt/qml/QbzTheme.qml +++ b/crates/qbz-qt/qml/QbzTheme.qml @@ -1,43 +1,101 @@ -// Shared dark-theme constants for the POC — the values the Slint app -// pushes into its ThemeColors struct for the dark theme -// (crates/qbz-ui/ui/foundation/theme.slint, dark block), plus the spacing / -// radius / typography / layout tokens the shell uses (foundation/*.slint). +// Shared theme tokens (phase 19: LIVE) — the color tokens now bind to +// QbzBridge.themeJson, the serialized qbz-theme ThemeColors for the +// persisted ui_prefs `theme` slug (theme_qt.rs — the Qt equivalent of the +// Slint theme::push_colors: switching the theme republishes the document +// and every consumer repaints, no restart). Until the first publish lands +// the baked dark fallback below applies (the bridge seeds themeJson at +// construction, so the fallback is purely defensive). +// +// The non-color tokens (spacing / radius / typography / layout from +// foundation/*.slint) stay static regardless of theme. // // Kept as a plain instantiable QtObject (NOT a pragma Singleton — the // cxx-qt-build generated qmldir cannot mark singletons). import QtQuick +import com.blitzfc.qbz QtObject { - // --- Theme colors (dark) ------------------------------------------ - readonly property color surfaceMain: "#0f0f0f" - readonly property color surfaceCard: "#1a1a1a" - readonly property color surfaceElevated: "#2a2a2a" - readonly property color surfaceHover: "#10ffffff" - readonly property color textPrimary: "#ffffff" - readonly property color textSecondary: "#cccccc" - readonly property color textMuted: "#888888" - readonly property color textDisabled: "#555555" - readonly property color accent: "#4285f4" - readonly property color accentHover: "#5a9bf4" - readonly property color accentPressed: "#3275e4" - readonly property color accentText: "#ffffff" - readonly property color warning: "#fbbf24" - readonly property color favorite: "#ef4444" - // #ffffff14 / #ffffff38 / #00000066 in the Slint ARGB notation. - readonly property color borderSubtle: "#14ffffff" - readonly property color borderMuted: "#38ffffff" - readonly property color cardShadow: "#66000000" - // Ambient-background layering (phase 14): the alpha variants the Slint - // applies when the dynamic background is active — chrome surface-card - // at 0.5, the frosted content panel surface-main at 0.22 (+ hairline), - // thin bars surface-main at 0.3 (AppearanceState knobs baked at the - // defaults; the live values ride QbzBridge.ambientSurfaceAlpha / - // ambientBarAlpha for the QBZ_BG_* env knobs). - readonly property color surfaceCardA50: "#801a1a1a" - readonly property color surfaceMainA22: "#380f0f0f" - readonly property color surfaceMainA30: "#4d0f0f0f" - readonly property color frostBorder: "#1affffff" + // Baked dark fallback (pre-publish only) — the old static POC tokens. + readonly property var _fallback: ({ + "surfaceMain": "#0f0f0f", "surfaceCard": "#1a1a1a", + "surfaceElevated": "#2a2a2a", "surfaceHover": "#10ffffff", + "bgHover": "#2a2a2a", + "textPrimary": "#ffffff", "textSecondary": "#cccccc", + "textMuted": "#888888", "textDisabled": "#555555", + "accent": "#4285f4", "accentHover": "#5a9bf4", + "accentPressed": "#3275e4", "accentText": "#ffffff", + "danger": "#e0564f", "dangerBg": "#26e0564f", + "dangerBorder": "#59e0564f", "dangerHover": "#e87670", + "warning": "#fbbf24", "warningBg": "#26fbbf24", + "warningBorder": "#59fbbf24", "warningHover": "#fdcb47", + "success": "#3fae6a", "successBg": "#263fae6a", + "successBorder": "#593fae6a", "successHover": "#55bd7e", + "borderSubtle": "#14ffffff", "borderMuted": "#38ffffff", + "borderStrong": "#59ffffff", "focusRing": "#4285f4", + "favorite": "#ef4444", "cardShadow": "#66000000", + "surfaceCardA50": "#801a1a1a", "surfaceMainA22": "#380f0f0f", + "surfaceMainA30": "#4d0f0f0f", "frostBorder": "#1affffff", + "isDark": true + }) + + readonly property var _doc: { + if (QbzBridge.themeJson === "") return _fallback + try { + return JSON.parse(QbzBridge.themeJson) + } catch (e) { + return _fallback + } + } + function _c(key) { + const v = _doc[key] + return v === undefined ? _fallback[key] : v + } + + // --- Theme colors (live; ThemeColors contract) ---------------------- + readonly property bool isDark: _doc.isDark === undefined ? true : _doc.isDark + readonly property color surfaceMain: _c("surfaceMain") + readonly property color surfaceCard: _c("surfaceCard") + readonly property color surfaceElevated: _c("surfaceElevated") + readonly property color surfaceHover: _c("surfaceHover") + readonly property color bgHover: _c("bgHover") + readonly property color textPrimary: _c("textPrimary") + readonly property color textSecondary: _c("textSecondary") + readonly property color textMuted: _c("textMuted") + readonly property color textDisabled: _c("textDisabled") + readonly property color accent: _c("accent") + readonly property color accentHover: _c("accentHover") + readonly property color accentPressed: _c("accentPressed") + readonly property color accentText: _c("accentText") + readonly property color danger: _c("danger") + readonly property color dangerBg: _c("dangerBg") + readonly property color dangerBorder: _c("dangerBorder") + readonly property color dangerHover: _c("dangerHover") + readonly property color warning: _c("warning") + readonly property color warningBg: _c("warningBg") + readonly property color warningBorder: _c("warningBorder") + readonly property color warningHover: _c("warningHover") + readonly property color success: _c("success") + readonly property color successBg: _c("successBg") + readonly property color successBorder: _c("successBorder") + readonly property color successHover: _c("successHover") + readonly property color borderSubtle: _c("borderSubtle") + readonly property color borderMuted: _c("borderMuted") + readonly property color borderStrong: _c("borderStrong") + readonly property color focusRing: _c("focusRing") + readonly property color favorite: _c("favorite") + readonly property color cardShadow: _c("cardShadow") + // 24 alpha tiers ([4..95]%, white-based dark / black-based light). + readonly property var alphaTiers: _doc.alpha === undefined ? [] : _doc.alpha + // Ambient-background layering (phase 14; theme-derived since phase 19): + // chrome surface-card @50%, frosted content panel surface-main @22%, + // thin bars surface-main @30%, hairline = alpha tier 10%. The live + // values for the QBZ_BG_* env knobs still ride QbzBridge + // .ambientSurfaceAlpha / .ambientBarAlpha. + readonly property color surfaceCardA50: _c("surfaceCardA50") + readonly property color surfaceMainA22: _c("surfaceMainA22") + readonly property color surfaceMainA30: _c("surfaceMainA30") + readonly property color frostBorder: _c("frostBorder") // --- Spacing (spacing.slint) -------------------------------------- readonly property int spacingXs: 4 diff --git a/crates/qbz-qt/qml/QbzToggle.qml b/crates/qbz-qt/qml/QbzToggle.qml new file mode 100644 index 000000000..4fdef807e --- /dev/null +++ b/crates/qbz-qt/qml/QbzToggle.qml @@ -0,0 +1,36 @@ +// QbzToggle (primitives/QbzToggle.slint) — extracted from SettingsView.qml +// in phase 19 so every settings panel shares the one replica. +// 40x22 pill r11, 16px knob, accent when on, opacity .4 disabled, +// 120ms ease-out knob travel. Emits toggled(newValue); never self-flips. + +import QtQuick +import com.blitzfc.qbz + +Rectangle { + property bool checked: false + property bool enabled: true + signal toggled(bool value) + + QbzTheme { id: theme } + + width: 40 + height: 22 + radius: 11 + color: checked ? theme.accent : theme.surfaceElevated + opacity: enabled ? 1.0 : 0.4 + + Rectangle { + width: 16 + height: 16 + radius: 8 + color: theme.textPrimary + y: 3 + x: parent.checked ? parent.width - width - 3 : 3 + Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutQuad } } + } + MouseArea { + anchors.fill: parent + cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (parent.enabled) parent.toggled(!parent.checked) + } +} diff --git a/crates/qbz-qt/qml/SettingRow.qml b/crates/qbz-qt/qml/SettingRow.qml new file mode 100644 index 000000000..e33facc20 --- /dev/null +++ b/crates/qbz-qt/qml/SettingRow.qml @@ -0,0 +1,50 @@ +// SettingRow (settings/SettingRow.slint) — extracted from SettingsView.qml +// in phase 19. 52px (64 with a description); label 15 medium + description +// 12 muted on the left (opacity .45 when disabled), the control flush right. + +import QtQuick +import com.blitzfc.qbz + +Item { + property string label: "" + property string description: "" + property bool rowEnabled: true + default property alias control: controlHost.data + + QbzTheme { id: theme } + + width: parent ? parent.width : 0 + height: description === "" ? 52 : 64 + + Column { + anchors.left: parent.left + anchors.right: controlHost.left + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + opacity: rowEnabled ? 1.0 : 0.45 + Text { + width: parent.width + text: label + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + elide: Text.ElideRight + } + Text { + visible: description !== "" + width: parent.width + text: description + color: theme.textMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } + Item { + id: controlHost + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: childrenRect.width + height: childrenRect.height + } +} diff --git a/crates/qbz-qt/qml/SettingsButton.qml b/crates/qbz-qt/qml/SettingsButton.qml new file mode 100644 index 000000000..ef4fbda46 --- /dev/null +++ b/crates/qbz-qt/qml/SettingsButton.qml @@ -0,0 +1,55 @@ +// Settings SecondaryButton (IntegrationsSettings.slint:27 / +// AppearanceSettings.slint:83) — min 160px (compact variant 34px square for +// icon buttons), 34px tall, bordered, optional danger red (#e0564f ≈ +// theme.danger), busy spinner text. + +import QtQuick +import com.blitzfc.qbz + +Rectangle { + property string text: "" + property string iconName: "" + property bool danger: false + property bool busy: false + property bool enabled: true + signal clicked() + + QbzTheme { id: theme } + + width: Math.max(iconName !== "" && text === "" ? 34 : 160, row.implicitWidth + 24) + height: 34 + radius: theme.radiusSm + border.width: 1 + border.color: danger ? theme.danger : theme.borderSubtle + color: enabled && btnArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + opacity: enabled ? 1.0 : 0.4 + + Row { + id: row + anchors.centerIn: parent + spacing: 8 + QbzIcon { + visible: iconName !== "" + name: iconName + width: 15 + height: 15 + anchors.verticalCenter: parent.verticalCenter + tintName: danger ? "favorite" : "secondary" + } + Text { + visible: text !== "" + text: parent.parent.text + color: parent.parent.danger ? theme.danger : theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + } + } + MouseArea { + id: btnArea + anchors.fill: parent + hoverEnabled: true + cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (parent.enabled) parent.clicked() + } +} diff --git a/crates/qbz-qt/qml/SettingsDivider.qml b/crates/qbz-qt/qml/SettingsDivider.qml new file mode 100644 index 000000000..8063c0d01 --- /dev/null +++ b/crates/qbz-qt/qml/SettingsDivider.qml @@ -0,0 +1,14 @@ +// Settings 1px divider (settings panel group separator) — extracted from +// SettingsView.qml in phase 19 (named SettingsDivider so it never shadows +// QtQuick.Controls.Divider in the shared qml directory). + +import QtQuick +import com.blitzfc.qbz + +Rectangle { + QbzTheme { id: theme } + + width: parent ? parent.width : 0 + height: 1 + color: theme.borderSubtle +} diff --git a/crates/qbz-qt/qml/SettingsSpacer.qml b/crates/qbz-qt/qml/SettingsSpacer.qml new file mode 100644 index 000000000..a6d8e332d --- /dev/null +++ b/crates/qbz-qt/qml/SettingsSpacer.qml @@ -0,0 +1,5 @@ +// Settings 12px group spacer — extracted from SettingsView.qml in phase 19. + +import QtQuick + +Item { width: 1; height: 12 } diff --git a/crates/qbz-qt/qml/SettingsView.qml b/crates/qbz-qt/qml/SettingsView.qml index 4cf680531..f49a8fb62 100644 --- a/crates/qbz-qt/qml/SettingsView.qml +++ b/crates/qbz-qt/qml/SettingsView.qml @@ -10,9 +10,10 @@ // "single source of truth in SettingsState" pattern). // // POC-NOTEs (deliberate cuts, named for the effort report): -// - Sub-nav: only Audio + Playback exist (sections 0/1). Appearance, -// Offline, Local Library, Blacklist, Integrations, Developer, Flatpak/ -// Snap and the "Share logs" entry have no backing glue in the POC. +// - 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 @@ -33,7 +34,8 @@ Item { // The parsed settingsJson document ({}) until the first publish lands). property var doc: ({}) - // Active sub-section: 0 = Audio, 1 = Playback (SettingsState.section). + // Active sub-section (display order): 0 Audio, 1 Playback, 2 Appearance, + // 3 Offline, 4 Local Library, 5 Blacklist, 6 Integrations, 7 Developer. property int section: 0 function reload() { @@ -49,496 +51,6 @@ Item { function onSettingsJsonChanged() { root.reload() } } - // --- QbzToggle (primitives/QbzToggle.slint) --------------------------- - // 40x22 pill r11, 16px knob, accent when on, opacity .4 disabled, - // 120ms ease-out knob travel. Emits toggled(newValue); never self-flips. - component QbzToggle: Rectangle { - property bool checked: false - property bool enabled: true - signal toggled(bool value) - - width: 40 - height: 22 - radius: 11 - color: checked ? theme.accent : theme.surfaceElevated - opacity: enabled ? 1.0 : 0.4 - - Rectangle { - width: 16 - height: 16 - radius: 8 - color: theme.textPrimary - y: 3 - x: parent.checked ? parent.width - width - 3 : 3 - Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutQuad } } - } - MouseArea { - anchors.fill: parent - cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (parent.enabled) parent.toggled(!parent.checked) - } - } - - // --- QbzSelect (primitives/QbzSelect.slint, standard size) ------------ - // 34px bordered control (elevated, hover -> surface-hover) + a Popup - // list (surface-main r8 border-muted, 32px rows, capped at 360px with - // scroll, optional 42px filter box, 24px group-header rows, "BP" badge - // / speaker glyph on device options). `options` entries are either - // plain strings or {label, bp, group} objects (the device list). - component QbzSelect: Rectangle { - property var options: [] - property int currentIndex: 0 - property int menuWidth: 240 - property int popupWidth: 0 - property bool enabled: true - property bool searchable: false - signal selected(int index) - - id: selectRoot - width: menuWidth - height: 34 - radius: theme.radiusSm - border.width: 1 - border.color: theme.borderSubtle - color: selArea.containsMouse && enabled ? theme.surfaceHover : theme.surfaceElevated - opacity: enabled ? 1.0 : 0.4 - - readonly property int listWidth: Math.max(popupWidth, menuWidth) - readonly property int rowHeight: 32 - readonly property int headerHeight: 24 - readonly property int searchHeight: searchable ? 42 : 0 - readonly property int maxListHeight: 360 - property string filter: "" - - function optLabel(i) { - const o = options[i] - return (typeof o === "string") ? o : (o && o.label !== undefined ? o.label : "") - } - function optHasBadges() { - return options.length > 0 && typeof options[0] !== "string" - } - function optBp(i) { - const o = options[i] - return o && o.bp === true - } - function optGroup(i) { - const o = options[i] - return (o && o.group !== undefined) ? o.group : "" - } - - Row { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 10 - spacing: 8 - Text { - width: parent.width - 16 - 8 - badgeSlot.width - (badgeSlot.visible ? 8 : 0) - height: parent.height - text: selectRoot.currentIndex >= 0 && selectRoot.currentIndex < selectRoot.options.length - ? selectRoot.optLabel(selectRoot.currentIndex) : "" - color: theme.textPrimary - font.pixelSize: theme.fontBody - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - // Trailing BP badge / speaker glyph for the CURRENT device option. - Item { - id: badgeSlot - visible: selectRoot.optHasBadges() - width: visible ? 20 : 0 - height: parent.height - Text { - visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length - && selectRoot.optBp(selectRoot.currentIndex) - anchors.centerIn: parent - text: "BP" - color: theme.accent - font.pixelSize: theme.fontLegal - font.weight: theme.weightSemibold - font.letterSpacing: 0.5 - } - QbzIcon { - visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length - && !selectRoot.optBp(selectRoot.currentIndex) - name: "volume-2" - width: 14 - height: 14 - anchors.centerIn: parent - tintName: "muted" - } - } - QbzIcon { - name: "chevron-down" - width: 16 - height: 16 - anchors.verticalCenter: parent.verticalCenter - tintName: "muted" - } - } - MouseArea { - id: selArea - anchors.fill: parent - hoverEnabled: true - cursorShape: selectRoot.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: { - if (selectRoot.enabled) { - selectRoot.filter = "" - popup.open() - } - } - } - - Popup { - id: popup - parent: selectRoot - // Right-anchored: a list wider than the control grows leftward. - x: selectRoot.width - selectRoot.listWidth - y: selectRoot.height + 4 - width: selectRoot.listWidth - height: selectRoot.searchHeight + Math.min(listContent.contentHeight, selectRoot.maxListHeight) + 10 - padding: 0 - closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape - - background: Rectangle { - color: theme.surfaceMain - radius: theme.radiusSm - border.width: 1 - border.color: theme.borderMuted - } - contentItem: Item { - implicitWidth: selectRoot.listWidth - implicitHeight: popup.height - - // Filter box (searchable lists only). - Rectangle { - id: searchBox - visible: selectRoot.searchable - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - height: selectRoot.searchHeight - color: "transparent" - Row { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - anchors.bottomMargin: 6 - spacing: 8 - QbzIcon { - name: "search" - width: 14 - height: 14 - anchors.verticalCenter: parent.verticalCenter - tintName: "muted" - } - Item { - width: parent.width - 14 - 8 - height: parent.height - TextInput { - id: searchInput - anchors.fill: parent - color: theme.textPrimary - font.pixelSize: theme.fontBody - verticalAlignment: Text.AlignVCenter - clip: true - text: selectRoot.filter - onTextChanged: selectRoot.filter = text - } - Text { - visible: searchInput.text === "" - anchors.fill: parent - text: QbzBridge.tr("Search…") - color: theme.textMuted - font.pixelSize: theme.fontBody - verticalAlignment: Text.AlignVCenter - } - } - } - } - - ListView { - id: listContent - anchors.left: parent.left - anchors.right: parent.right - anchors.top: searchBox.bottom - anchors.bottom: parent.bottom - anchors.topMargin: 5 - anchors.bottomMargin: 5 - clip: true - boundsBehavior: Flickable.StopAtBounds - model: selectRoot.options - - delegate: Column { - id: optRow - required property int index - required property var modelData - width: listContent.width - - readonly property string label: selectRoot.optLabel(index) - readonly property bool shown: selectRoot.filter === "" - || label.toLowerCase().indexOf(selectRoot.filter.toLowerCase()) >= 0 - - // Group-header row (ALSA device sections). - Rectangle { - visible: optRow.shown && selectRoot.optGroup(optRow.index) !== "" - width: parent.width - height: visible ? selectRoot.headerHeight : 0 - color: "transparent" - Text { - anchors.left: parent.left - anchors.right: parent.right - anchors.leftMargin: 12 - anchors.rightMargin: 12 - anchors.top: parent.top - anchors.topMargin: 4 - height: parent.height - 4 - text: selectRoot.optGroup(optRow.index) - color: theme.textMuted - font.pixelSize: theme.fontLegal - font.weight: theme.weightSemibold - font.letterSpacing: 0.5 - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - } - Rectangle { - visible: optRow.shown - width: parent.width - height: visible ? selectRoot.rowHeight : 0 - color: optArea.containsMouse ? theme.surfaceHover : "transparent" - Row { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - spacing: 8 - Text { - width: parent.width - rowBadge.width - (rowBadge.visible ? 8 : 0) - height: parent.height - text: optRow.label - color: optRow.index === selectRoot.currentIndex - ? theme.accent : theme.textSecondary - font.pixelSize: theme.fontBody - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - Item { - id: rowBadge - visible: selectRoot.optHasBadges() - width: visible ? 20 : 0 - height: parent.height - Text { - visible: selectRoot.optBp(optRow.index) - anchors.centerIn: parent - text: "BP" - color: theme.accent - font.pixelSize: theme.fontLegal - font.weight: theme.weightSemibold - font.letterSpacing: 0.5 - } - QbzIcon { - visible: !selectRoot.optBp(optRow.index) - name: "volume-2" - width: 14 - height: 14 - anchors.centerIn: parent - tintName: "muted" - } - } - } - MouseArea { - id: optArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - popup.close() - selectRoot.selected(optRow.index) - } - } - } - } - } - } - } - } - - // --- SettingRow (settings/SettingRow.slint) --------------------------- - // 52px (64 with a description); label 15 medium + description 12 muted - // on the left (opacity .45 when disabled), the control flush right. - component SettingRow: Item { - property string label: "" - property string description: "" - property bool rowEnabled: true - default property alias control: controlHost.data - - width: parent ? parent.width : 0 - height: description === "" ? 52 : 64 - - Column { - anchors.left: parent.left - anchors.right: controlHost.left - anchors.rightMargin: 24 - anchors.verticalCenter: parent.verticalCenter - spacing: 3 - opacity: rowEnabled ? 1.0 : 0.45 - Text { - width: parent.width - text: label - color: theme.textPrimary - font.pixelSize: theme.fontBody - font.weight: theme.weightMedium - elide: Text.ElideRight - } - Text { - visible: description !== "" - width: parent.width - text: description - color: theme.textMuted - font.pixelSize: 12 - wrapMode: Text.WordWrap - } - } - Item { - id: controlHost - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - width: childrenRect.width - height: childrenRect.height - } - } - - // --- GroupHeader / Divider -------------------------------------------- - component GroupHeader: Text { - color: theme.textMuted - font.pixelSize: 11 - font.letterSpacing: 1.5 - font.weight: theme.weightSemibold - } - component Divider: Rectangle { - width: parent ? parent.width : 0 - height: 1 - color: theme.borderSubtle - } - component Spacer12: Item { width: 1; height: 12 } - - // --- QbzSlider (primitives/QbzSlider.slint) --------------------------- - // 200x22, 4px r2 track, accent fill, 16px thumb; integer steps. Like the - // Slint original the thumb follows the pointer during a drag (local - // dragValue) and commits each step via changed(int). - component QbzSlider: Item { - property int minimum: 0 - property int maximum: 10 - property int value: 0 - signal changed(int newValue) - - width: 200 - height: 22 - - readonly property int thumbSize: 16 - readonly property real travel: width - thumbSize - readonly property real fraction: maximum > minimum - ? Math.max(0, Math.min(1, (value - minimum) / (maximum - minimum))) : 0 - property bool dragging: false - property real dragFraction: fraction - readonly property real shownFraction: dragging ? dragFraction : fraction - onFractionChanged: if (!dragging) dragFraction = fraction - - function commit(frac) { - const v = Math.round(minimum + Math.max(0, Math.min(1, frac)) * (maximum - minimum)) - if (v !== value) changed(v) - } - - Rectangle { // track - x: 0 - y: Math.round((parent.height - height) / 2) - width: parent.width - height: 4 - radius: 2 - color: theme.surfaceElevated - } - Rectangle { // accent fill - x: 0 - y: Math.round((parent.height - height) / 2) - width: parent.thumbSize / 2 + parent.shownFraction * parent.travel - height: 4 - radius: 2 - color: theme.accent - } - Rectangle { // thumb - width: parent.thumbSize - height: parent.thumbSize - radius: parent.thumbSize / 2 - x: parent.shownFraction * parent.travel - anchors.verticalCenter: parent.verticalCenter - color: theme.textPrimary - } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onPressed: { - parent.dragging = true - parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel)) - parent.commit(parent.dragFraction) - } - onPositionChanged: { - if (pressed) { - parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel)) - parent.commit(parent.dragFraction) - } - } - onReleased: parent.dragging = false - } - } - - // --- QbzLineEdit (std-widgets LineEdit, settings styling) ------------- - // 240px / 34px elevated r8 bordered input; commits on Enter AND on - // focus loss (the Tauri onchange semantics — PlaybackSettings.slint). - component QbzLineEdit: Rectangle { - property string text: "" - property string placeholder: "" - signal committed(string value) - - width: 240 - height: 34 - radius: theme.radiusSm - border.width: 1 - border.color: theme.borderSubtle - color: theme.surfaceElevated - - TextInput { - id: input - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - color: theme.textPrimary - font.pixelSize: theme.fontBody - verticalAlignment: Text.AlignVCenter - clip: true - text: parent.text - onAccepted: parent.committed(text) - onActiveFocusChanged: if (!activeFocus) parent.committed(text) - // External republish (e.g. Reset) re-seeds the field while it is - // not being edited. - Binding { - target: input - property: "text" - value: input.parent.text - when: !input.activeFocus - } - } - Text { - visible: input.text === "" - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - text: placeholder - color: theme.textMuted - font.pixelSize: theme.fontBody - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - } - // ============================ the view ================================ Column { anchors.fill: parent @@ -629,10 +141,57 @@ Item { active: root.section === 1 onClicked: root.section = 1 } - // POC-NOTE: Appearance / Offline / Local Library / - // Blacklist / Integrations / Developer / Flatpak / Snap / - // "Share logs" sub-nav entries are omitted (no backing - // glue in the POC). + SubNavItem { + name: "layers" + label: QbzBridge.tr("Appearance") + active: root.section === 2 + onClicked: root.section = 2 + } + SubNavItem { + name: "cloud-download" + label: QbzBridge.tr("Offline") + active: root.section === 3 + onClicked: root.section = 3 + } + SubNavItem { + name: "hard-drive" + label: QbzBridge.tr("Local Library") + active: root.section === 4 + onClicked: root.section = 4 + } + SubNavItem { + name: "blind-eye" + label: QbzBridge.tr("Blacklist") + active: root.section === 5 + onClicked: root.section = 5 + } + SubNavItem { + name: "refresh-cw" + label: QbzBridge.tr("Integrations") + active: root.section === 6 + onClicked: root.section = 6 + } + SubNavItem { + name: "bug" + label: QbzBridge.tr("Developer") + 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. + + // "Share logs" footer (SettingsView.slint:202-215), + // pinned to the subnav bottom. + Item { width: 1; height: parent.height - 8 * 42 - 38 - 4 } + SubNavItem { + name: "cloud-upload" + label: QbzBridge.tr("Share logs") + active: false + // POC-NOTE: LogViewerState (copy + upload viewer) is + // not ported — the row is inert. + onClicked: { } + } } } @@ -687,9 +246,9 @@ Item { // skipped — the device-cap probe glue is not // ported. - Spacer12 { } - Divider { } - Spacer12 { } + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } GroupHeader { text: QbzBridge.tr("OUTPUT") } SettingRow { @@ -779,9 +338,9 @@ Item { } } - Spacer12 { } - Divider { } - Spacer12 { } + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } GroupHeader { text: QbzBridge.tr("BIT-PERFECT") } SettingRow { @@ -834,9 +393,9 @@ Item { // guided DAC setup) is skipped — DacWizardActions // is not ported. - Spacer12 { } - Divider { } - Spacer12 { } + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } GroupHeader { text: QbzBridge.tr("STARTUP") } SettingRow { @@ -922,9 +481,9 @@ Item { } } - Spacer12 { } - Divider { } - Spacer12 { } + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } GroupHeader { text: QbzBridge.tr("SESSION") } SettingRow { @@ -965,9 +524,9 @@ Item { } } - Spacer12 { } - Divider { } - Spacer12 { } + SettingsSpacer { } + SettingsDivider { } + SettingsSpacer { } GroupHeader { text: QbzBridge.tr("STREAMING") } SettingRow { @@ -1021,6 +580,45 @@ Item { } } } + + // ================== APPEARANCE (phase 19) ========== + AppearanceSettings { + visible: root.section === 2 + width: parent.width + doc: root.doc + } + + // ================== INTEGRATIONS (phase 19) ======== + 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 + width: parent.width + spacing: 4 + GroupHeader { + text: root.section === 3 ? QbzBridge.tr("OFFLINE") + : root.section === 4 ? QbzBridge.tr("LOCAL LIBRARY") + : root.section === 5 ? QbzBridge.tr("BLACKLIST") + : QbzBridge.tr("DEVELOPER") + } + Text { + width: parent.width + text: QbzBridge.tr("This section is not ported to the Qt frontend yet.") + color: theme.textMuted + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } } } diff --git a/crates/qbz-qt/qml/assets/icons/accent/bug.svg b/crates/qbz-qt/qml/assets/icons/accent/bug.svg new file mode 100644 index 000000000..569ac2bf5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/accent/cloud-upload.svg new file mode 100644 index 000000000..8d78b0dc6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/layers.svg b/crates/qbz-qt/qml/assets/icons/accent/layers.svg new file mode 100644 index 000000000..23099081e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/moon.svg b/crates/qbz-qt/qml/assets/icons/accent/moon.svg new file mode 100644 index 000000000..8a01e822a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/accent/rotate-ccw.svg new file mode 100644 index 000000000..397678549 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/accent/sun-moon.svg new file mode 100644 index 000000000..73781c42f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/sun.svg b/crates/qbz-qt/qml/assets/icons/accent/sun.svg new file mode 100644 index 000000000..cfc614b37 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/bug.svg b/crates/qbz-qt/qml/assets/icons/black/bug.svg new file mode 100644 index 000000000..c94d68413 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/black/cloud-upload.svg new file mode 100644 index 000000000..8d4488a68 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/layers.svg b/crates/qbz-qt/qml/assets/icons/black/layers.svg new file mode 100644 index 000000000..154ca2d7f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/moon.svg b/crates/qbz-qt/qml/assets/icons/black/moon.svg new file mode 100644 index 000000000..bab1e0aa6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/black/rotate-ccw.svg new file mode 100644 index 000000000..9497e0a4f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/black/sun-moon.svg new file mode 100644 index 000000000..dbf7ac64b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/sun.svg b/crates/qbz-qt/qml/assets/icons/black/sun.svg new file mode 100644 index 000000000..9c25a8a62 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/bug.svg b/crates/qbz-qt/qml/assets/icons/favorite/bug.svg new file mode 100644 index 000000000..d9e30b854 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/favorite/cloud-upload.svg new file mode 100644 index 000000000..4c117fd2b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/layers.svg b/crates/qbz-qt/qml/assets/icons/favorite/layers.svg new file mode 100644 index 000000000..6cc9846d3 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/moon.svg b/crates/qbz-qt/qml/assets/icons/favorite/moon.svg new file mode 100644 index 000000000..fdf796e56 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/favorite/rotate-ccw.svg new file mode 100644 index 000000000..d971b4a28 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/favorite/sun-moon.svg new file mode 100644 index 000000000..45afea114 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/favorite/sun.svg b/crates/qbz-qt/qml/assets/icons/favorite/sun.svg new file mode 100644 index 000000000..66f99ac0c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/favorite/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/bug.svg b/crates/qbz-qt/qml/assets/icons/muted/bug.svg new file mode 100644 index 000000000..fa20f8ac6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/muted/cloud-upload.svg new file mode 100644 index 000000000..af1b9c416 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/layers.svg b/crates/qbz-qt/qml/assets/icons/muted/layers.svg new file mode 100644 index 000000000..68c853b50 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/moon.svg b/crates/qbz-qt/qml/assets/icons/muted/moon.svg new file mode 100644 index 000000000..ff1abdecd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/muted/rotate-ccw.svg new file mode 100644 index 000000000..7ef25ddaf --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/muted/sun-moon.svg new file mode 100644 index 000000000..731f18a34 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/sun.svg b/crates/qbz-qt/qml/assets/icons/muted/sun.svg new file mode 100644 index 000000000..fcfe13407 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/bug.svg b/crates/qbz-qt/qml/assets/icons/primary/bug.svg new file mode 100644 index 000000000..db2b46047 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/primary/cloud-upload.svg new file mode 100644 index 000000000..f8bf93a12 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/layers.svg b/crates/qbz-qt/qml/assets/icons/primary/layers.svg new file mode 100644 index 000000000..9fffdaf51 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/moon.svg b/crates/qbz-qt/qml/assets/icons/primary/moon.svg new file mode 100644 index 000000000..86d6c007a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/primary/rotate-ccw.svg new file mode 100644 index 000000000..17530e155 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/primary/sun-moon.svg new file mode 100644 index 000000000..2321024e1 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/sun.svg b/crates/qbz-qt/qml/assets/icons/primary/sun.svg new file mode 100644 index 000000000..02ccd7310 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/bug.svg b/crates/qbz-qt/qml/assets/icons/secondary/bug.svg new file mode 100644 index 000000000..113399614 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/secondary/cloud-upload.svg new file mode 100644 index 000000000..a68510866 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/layers.svg b/crates/qbz-qt/qml/assets/icons/secondary/layers.svg new file mode 100644 index 000000000..2955a1f69 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/moon.svg b/crates/qbz-qt/qml/assets/icons/secondary/moon.svg new file mode 100644 index 000000000..6b065abfb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/secondary/rotate-ccw.svg new file mode 100644 index 000000000..9a3ae82d5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/secondary/sun-moon.svg new file mode 100644 index 000000000..7f9c580a2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/sun.svg b/crates/qbz-qt/qml/assets/icons/secondary/sun.svg new file mode 100644 index 000000000..7b61b1604 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/bug.svg b/crates/qbz-qt/qml/assets/icons/warning/bug.svg new file mode 100644 index 000000000..2fe8bccd6 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/cloud-upload.svg b/crates/qbz-qt/qml/assets/icons/warning/cloud-upload.svg new file mode 100644 index 000000000..0d040afe5 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/cloud-upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/layers.svg b/crates/qbz-qt/qml/assets/icons/warning/layers.svg new file mode 100644 index 000000000..ca364e992 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/layers.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/moon.svg b/crates/qbz-qt/qml/assets/icons/warning/moon.svg new file mode 100644 index 000000000..dd26ba6b2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/rotate-ccw.svg b/crates/qbz-qt/qml/assets/icons/warning/rotate-ccw.svg new file mode 100644 index 000000000..c01456ecd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/rotate-ccw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/sun-moon.svg b/crates/qbz-qt/qml/assets/icons/warning/sun-moon.svg new file mode 100644 index 000000000..b296f8dd2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/sun-moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/sun.svg b/crates/qbz-qt/qml/assets/icons/warning/sun.svg new file mode 100644 index 000000000..eb6074103 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index 21dae2c41..2511bf306 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -136,6 +136,19 @@ pub mod qbz_bridge { // SongCard layers icon. #[qproperty(bool, show_context_icon)] + // --- Theme (phase 19) -------------------------------------------- + // ONE JSON token document (theme_qt.rs ThemeTokens: 30 colors + 24 + // alpha tiers + ambient derivations + isDark) — QbzTheme.qml binds + // to it, so a theme switch repaints the whole app live (the Slint + // theme::push_colors equivalent). + #[qproperty(QString, theme_json)] + // The persisted ui_prefs theme slug ("oled", "auto", "custom", ...). + #[qproperty(QString, theme_slug)] + // The dropdown catalog (36 registry themes + Auto/Custom rows). + #[qproperty(QString, theme_list_json)] + // Dropdown filter: 0 All / 1 Dark / 2 Light (ui_prefs theme_filter). + #[qproperty(i32, theme_filter)] + // --- Lyrics panel (phase 9) ---------------------------------------- #[qproperty(bool, lyrics_open)] // One JSON document (lyrics_qt.rs LyricsDoc: status/lines/synced/ @@ -453,6 +466,21 @@ pub mod qbz_bridge { #[qinvokable] fn refresh_devices(self: Pin<&mut QbzBridge>); + // --- Theme (phase 19) --------------------------------------------- + /// Appearance > Theme row: persist the picked slug + republish + /// `themeJson` (live switch). + #[qinvokable] + fn theme_set(self: Pin<&mut QbzBridge>, slug: QString); + /// Appearance > theme filter cycle button (0 All / 1 Dark / 2 Light). + #[qinvokable] + fn theme_set_filter(self: Pin<&mut QbzBridge>, index: i32); + + // --- Integrations (phase 19) --------------------------------------- + /// Non-toggle integration actions (integrations_qt.rs): Last.fm + /// connect/open-auth-url/finish/disconnect, ListenBrainz disconnect. + #[qinvokable] + fn integrations_action(self: Pin<&mut QbzBridge>, action: QString); + /// App-menu chrome toggle: flip the persisted `use_system_title_bar` /// pref (applies on the next launch — the window flags are fixed at /// creation, 1:1 Slint). Updates `systemTitleBarPref` only. @@ -631,6 +659,10 @@ pub struct QbzBridgeRust { np_artist_id: QString, npb_mode: i32, show_context_icon: bool, + theme_json: QString, + theme_slug: QString, + theme_list_json: QString, + theme_filter: i32, queue_json: QString, lyrics_open: bool, lyrics_json: QString, @@ -723,6 +755,10 @@ impl Default for QbzBridgeRust { np_artist_id: QString::default(), npb_mode: crate::settings_qt::npb_mode_index(), show_context_icon: crate::settings_qt::show_context_icon(), + theme_json: QString::from(crate::theme_qt::theme_json().as_str()), + theme_slug: QString::from(crate::theme_qt::current_slug().as_str()), + theme_list_json: QString::from(crate::theme_qt::theme_list_json().as_str()), + theme_filter: crate::theme_qt::theme_filter(), queue_json: QString::from("{}"), lyrics_open: false, lyrics_json: QString::from("{}"), @@ -1011,6 +1047,18 @@ impl qbz_bridge::QbzBridge { crate::npb_set_mode(mode); } + pub fn theme_set(self: Pin<&mut Self>, slug: QString) { + crate::theme_set(slug.to_string()); + } + + pub fn theme_set_filter(self: Pin<&mut Self>, index: i32) { + crate::theme_set_filter(index); + } + + pub fn integrations_action(self: Pin<&mut Self>, action: QString) { + crate::integrations_action(action.to_string()); + } + pub fn settings_bool(self: Pin<&mut Self>, key: QString, value: bool) { crate::settings_bool(key.to_string(), value); } diff --git a/crates/qbz-qt/src/integrations_qt.rs b/crates/qbz-qt/src/integrations_qt.rs new file mode 100644 index 000000000..19694df24 --- /dev/null +++ b/crates/qbz-qt/src/integrations_qt.rs @@ -0,0 +1,275 @@ +//! Integrations settings controller (phase 19) — the Slint +//! `crates/qbz/src/scrobble.rs` + `discord_rpc.rs` + the settings.rs +//! integration rows, ported onto the SAME stores: +//! `scrobbler_settings.db` (`qbz_app::settings::scrobblers`, per-user), +//! `discover_prefs.db` (`qbz_app::settings::discover_prefs`, per-user) and +//! the shared `ui_prefs.json` (`musicbrainz_enabled`, `discord_rpc_enabled`). +//! +//! POC-NOTEs: +//! - No live scrobbling: `scrobble::start` / `on_track_changed` (the +//! now-playing/scrobble firing + offline queues) is NOT ported — the +//! toggles + credentials persist to the SAME DB the Slint scrobbler +//! reads, so they take effect in the Slint app / a future port. +//! - Discord: the enable pref persists AND flips the live `DiscordRpc` +//! flag, but presence updates (`DiscordRpc::update` on track change) +//! are not wired (no rich-presence feed in the POC). +//! - ListenBrainz disconnect does NOT clear the shared +//! `cache/listenbrainz_v2.db` credentials row (scrobble.rs:334-342 — +//! that cache feeds the scrobbler runtime, which the POC does not run). + +use std::sync::{Arc, Mutex, OnceLock}; + +use qbz_app::settings::discover_prefs::DiscoverPrefsStore; +use qbz_app::settings::scrobblers::{ScrobblerSettings, ScrobblerSettingsState}; +use qbz_app::shell::AppRuntime; +use qbz_core::LoggingAdapter; +use qbz_integrations::discord::DiscordRpc; +use qbz_integrations::lastfm::LastFmClient; +use qbz_integrations::listenbrainz::ListenBrainzClient; + +use crate::settings_qt::{pref_bool, save_pref}; + +// --------------------------------------------------------------------------- +// Stores (SAME files as the Slint app) +// --------------------------------------------------------------------------- + +static SCROBBLE: OnceLock = OnceLock::new(); + +fn scrobble() -> &'static ScrobblerSettingsState { + SCROBBLE.get_or_init(|| { + let state = ScrobblerSettingsState::new_empty(); + if let Some(dir) = crate::sidebar_qt::user_dir() { + if let Err(e) = state.init_at(&dir) { + log::warn!("[qbz-qt] scrobbler settings store unavailable: {e}"); + } + } + state + }) +} + +static DISCOVER: OnceLock>> = OnceLock::new(); + +fn with_discover(f: impl FnOnce(&DiscoverPrefsStore) -> T) -> Option { + let cell = DISCOVER.get_or_init(|| { + let store = crate::sidebar_qt::user_dir() + .and_then(|dir| DiscoverPrefsStore::new_at(&dir).ok()); + if store.is_none() { + log::warn!("[qbz-qt] discover prefs store unavailable"); + } + Mutex::new(store) + }); + let guard = cell.lock().ok()?; + guard.as_ref().map(f) +} + +static DISCORD: OnceLock = OnceLock::new(); + +fn discord() -> &'static DiscordRpc { + DISCORD.get_or_init(DiscordRpc::new) +} + +// --------------------------------------------------------------------------- +// Transient UI state (ScrobbleState busy/auth-url/status + the Last.fm +// pending request token — process memory only, like LASTFM_PENDING_TOKEN). +// --------------------------------------------------------------------------- + +#[derive(Default)] +pub struct IntegrationUi { + pub lastfm_busy: bool, + pub listenbrainz_busy: bool, + pub lastfm_auth_url: String, + pub status_text: String, + /// 0 none / 1 info / 2 ok / 3 error (ScrobbleState.status-kind). + pub status_kind: i32, + pending_lastfm_token: String, +} + +static UI: OnceLock> = OnceLock::new(); + +fn ui_state() -> &'static Mutex { + UI.get_or_init(|| Mutex::new(IntegrationUi::default())) +} + +pub fn ui_snapshot() -> IntegrationUi { + let g = ui_state().lock().unwrap(); + IntegrationUi { + lastfm_busy: g.lastfm_busy, + listenbrainz_busy: g.listenbrainz_busy, + lastfm_auth_url: g.lastfm_auth_url.clone(), + status_text: g.status_text.clone(), + status_kind: g.status_kind, + pending_lastfm_token: String::new(), + } +} + +fn set_status(text: &str, kind: i32) { + let mut g = ui_state().lock().unwrap(); + g.status_text = text.to_string(); + g.status_kind = kind; +} + +// --------------------------------------------------------------------------- +// Snapshot fields (folded into SettingsDoc by settings_qt::publish_snapshot) +// --------------------------------------------------------------------------- + +pub fn scrobble_settings() -> ScrobblerSettings { + scrobble().get_settings().unwrap_or_default() +} + +pub fn show_recommendations() -> bool { + with_discover(|s| s.load().show_recommendations).unwrap_or(true) +} + +pub fn discord_enabled() -> bool { + pref_bool("discord_rpc_enabled", false) +} + +// --------------------------------------------------------------------------- +// Toggle handlers (settings_qt.rs bool arms delegate here) +// --------------------------------------------------------------------------- + +pub fn set_show_recommendations(value: bool) -> Result<(), String> { + with_discover(|s| { + let mut prefs = s.load(); + prefs.show_recommendations = value; + s.save(&prefs) + }) + .unwrap_or_else(|| Err("discover prefs store not open".to_string())) +} + +pub fn set_scrobble_enabled(value: bool) -> Result<(), String> { + scrobble().set_enabled(value) +} + +pub fn set_scrobble_collapsed(value: bool) -> Result<(), String> { + scrobble().set_ui_collapsed(value) +} + +pub fn set_lastfm_enabled(value: bool) -> Result<(), String> { + scrobble().set_lastfm_enabled(value) +} + +pub fn set_listenbrainz_enabled(value: bool) -> Result<(), String> { + scrobble().set_listenbrainz_enabled(value) +} + +pub fn set_discord_enabled(value: bool) -> Result<(), String> { + save_pref("discord_rpc_enabled", serde_json::json!(value)); + discord().set_enabled(value); + log::info!("[qbz-qt] discord_rpc_enabled -> {value}"); + Ok(()) +} + +pub fn set_musicbrainz_enabled(value: bool) -> Result<(), String> { + save_pref("musicbrainz_enabled", serde_json::json!(value)); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Connection flows (ScrobbleActions) +// --------------------------------------------------------------------------- + +/// ListenBrainz paste-token flow (scrobble.rs:315): validate against +/// /validate-token, persist token + username, force-enable on success. +pub async fn listenbrainz_set_token(token: &str) { + let token = token.trim().to_string(); + if token.is_empty() { + return; + } + ui_state().lock().unwrap().listenbrainz_busy = true; + crate::settings_qt::publish_snapshot().await; + + let client = ListenBrainzClient::new(); + match client.set_token(&token).await { + Ok(info) => { + if let Err(e) = scrobble().set_listenbrainz_token(&token, &info.user_name) { + log::error!("[qbz-qt] persist listenbrainz token failed: {e}"); + } + // First-connect force-enable (scrobble.rs). + let _ = scrobble().set_listenbrainz_enabled(true); + set_status(&format!("Signed in as {}.", info.user_name), 2); + } + Err(e) => set_status(&format!("ListenBrainz: {e}"), 3), + } + ui_state().lock().unwrap().listenbrainz_busy = false; + crate::settings_qt::publish_snapshot().await; +} + +/// integrations_action dispatch (non-toggle rows). +pub async fn handle_action(_runtime: &Arc>, action: &str) { + match action { + // Last.fm two-step browser auth (scrobble.rs:190): request token -> + // authorize URL in the browser -> Finish exchanges for a session. + "lastfm-connect" => { + ui_state().lock().unwrap().lastfm_busy = true; + crate::settings_qt::publish_snapshot().await; + let client = LastFmClient::new(); + match client.get_token().await { + Ok((token, url)) => { + { + let mut g = ui_state().lock().unwrap(); + g.pending_lastfm_token = token; + g.lastfm_auth_url = url.clone(); + } + set_status("Authorize QBZ in your browser, then click Finish.", 1); + if let Err(e) = open::that(&url) { + log::warn!("[qbz-qt] open Last.fm authorize page failed: {e}"); + } + } + Err(e) => set_status(&format!("Last.fm: {e}"), 3), + } + ui_state().lock().unwrap().lastfm_busy = false; + crate::settings_qt::publish_snapshot().await; + } + "lastfm-open-auth-url" => { + let url = ui_state().lock().unwrap().lastfm_auth_url.clone(); + if !url.is_empty() { + if let Err(e) = open::that(&url) { + log::warn!("[qbz-qt] open Last.fm authorize page failed: {e}"); + } + } + } + "lastfm-finish" => { + let token = ui_state().lock().unwrap().pending_lastfm_token.clone(); + if token.is_empty() { + return; + } + ui_state().lock().unwrap().lastfm_busy = true; + crate::settings_qt::publish_snapshot().await; + let mut client = LastFmClient::new(); + match client.get_session(&token).await { + Ok(session) => { + if let Err(e) = scrobble().set_lastfm_session(&session.key, &session.name) { + log::error!("[qbz-qt] persist lastfm session failed: {e}"); + } + // First-connect force-enable (scrobble.rs). + let _ = scrobble().set_lastfm_enabled(true); + { + let mut g = ui_state().lock().unwrap(); + g.pending_lastfm_token.clear(); + g.lastfm_auth_url.clear(); + } + set_status(&format!("Signed in as {}.", session.name), 2); + } + Err(e) => set_status(&format!("Last.fm: {e}"), 3), + } + ui_state().lock().unwrap().lastfm_busy = false; + crate::settings_qt::publish_snapshot().await; + } + "lastfm-disconnect" => { + if let Err(e) = scrobble().disconnect_lastfm() { + log::error!("[qbz-qt] lastfm disconnect failed: {e}"); + } + set_status("", 0); + crate::settings_qt::publish_snapshot().await; + } + "listenbrainz-disconnect" => { + if let Err(e) = scrobble().disconnect_listenbrainz() { + log::error!("[qbz-qt] listenbrainz disconnect failed: {e}"); + } + set_status("", 0); + crate::settings_qt::publish_snapshot().await; + } + other => log::warn!("[qbz-qt] unknown integrations action: {other}"), + } +} diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 6b824c176..18882b841 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -25,6 +25,8 @@ mod queue_qt; mod search_qt; mod settings_qt; mod sidebar_qt; +mod theme_qt; +mod integrations_qt; use std::pin::Pin; use std::sync::{Arc, Mutex, OnceLock}; @@ -992,6 +994,27 @@ pub(crate) fn refresh_devices() { spawn(async move { settings_qt::refresh_devices(&runtime).await }); } +/// Appearance > Theme row: persist the slug + republish the token document +/// (live switch — QbzTheme.qml rebinds every consumer). +pub(crate) fn theme_set(slug: String) { + theme_qt::set_theme(&slug); + theme_qt::publish_theme(); +} + +/// Appearance > theme filter cycle (0 All / 1 Dark / 2 Light). +pub(crate) fn theme_set_filter(index: i32) { + let index = index.clamp(0, 2); + theme_qt::set_theme_filter(index); + ui(move |mut b| b.as_mut().set_theme_filter(index)); +} + +/// Integrations panel non-toggle actions (Last.fm connect flow, LB/LFM +/// disconnects). +pub(crate) fn integrations_action(action: String) { + let runtime = app(); + spawn(async move { integrations_qt::handle_action(&runtime, &action).await }); +} + /// App-menu chrome toggle: persist the flipped pref and update the menu /// state. The APPLIED mode (`systemTitleBar`) is deliberately untouched — /// it changes on the next launch (the window flags are read once at diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index e0bc5f6ac..e7bebebfd 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -284,6 +284,107 @@ pub fn show_context_icon() -> bool { with_playback(|s| s.get_preferences().map(|p| p.show_context_icon)).unwrap_or(false) } +// --------------------------------------------------------------------------- +// Appearance + Integrations prefs (phase 19) — generic ui_prefs.json +// accessors (SAME additive patch discipline) + the per-user stores the +// Appearance/Integrations panels read (tray, My-QBZ branding). +// --------------------------------------------------------------------------- + +pub fn pref_bool(key: &str, default: bool) -> bool { + let Some(path) = prefs_path() else { + return default; + }; + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|v| v.get(key).and_then(|q| q.as_bool())) + .unwrap_or(default) +} + +pub fn pref_str(key: &str, default: &str) -> String { + let Some(path) = prefs_path() else { + return default.to_string(); + }; + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|v| v.get(key).and_then(|q| q.as_str().map(str::to_string))) + .unwrap_or_else(|| default.to_string()) +} + +pub fn save_pref(key: &str, value: serde_json::Value) { + let Some(path) = prefs_path() else { + return; + }; + let mut doc: serde_json::Value = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_else(|| serde_json::json!({})); + if let Some(obj) = doc.as_object_mut() { + obj.insert(key.to_string(), value); + if let Ok(text) = serde_json::to_string_pretty(&doc) { + let _ = std::fs::write(&path, text); + } + } +} + +/// The Appearance > app_background index: 0 off / 1 ambient / 2 blurred +/// (the POC renders blurred AS ambient — ambient_qt.rs POC-NOTE). +pub fn app_background_index() -> i32 { + match pref_str("app_background", "off").as_str() { + "ambient" => 1, + "blurred" => 2, + _ => 0, + } +} + +// Tray store (qbz_app::settings::tray — per-user tray_settings.db, the SAME +// file the Slint tray_settings.rs glue writes). +static TRAY: OnceLock = OnceLock::new(); + +fn tray() -> &'static qbz_app::settings::tray::TraySettingsState { + TRAY.get_or_init(|| { + let state = qbz_app::settings::tray::TraySettingsState::new_empty(); + if let Some(dir) = crate::sidebar_qt::user_dir() { + if let Err(e) = state.init_at(&dir) { + log::warn!("[qbz-qt] tray settings store unavailable: {e}"); + } + } + state + }) +} + +// My-QBZ branding (myqbz_prefs.rs — per-user myqbz_branding.json; the label +// row in LIBRARY & VISUALS). +fn myqbz_label() -> String { + crate::sidebar_qt::user_dir() + .map(|d| d.join("myqbz_branding.json")) + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|v| v.get("label").and_then(|q| q.as_str().map(str::to_string))) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "My QBZ".to_string()) +} + +fn save_myqbz_label(label: &str) { + let Some(path) = crate::sidebar_qt::user_dir().map(|d| d.join("myqbz_branding.json")) else { + return; + }; + let mut doc: serde_json::Value = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_else(|| serde_json::json!({})); + if let Some(obj) = doc.as_object_mut() { + obj.insert( + "label".to_string(), + serde_json::Value::String(label.trim().to_string()), + ); + if let Ok(text) = serde_json::to_string_pretty(&doc) { + let _ = std::fs::write(&path, text); + } + } +} + pub const STREAMING_QUALITY_KEYS: &[&str] = &["mp3", "cd", "hires", "hires_plus"]; pub const STREAMING_QUALITY_LABELS: &[&str] = &["MP3", "CD Quality", "Hi-Res", "Hi-Res+"]; @@ -303,6 +404,54 @@ const RETRY_BEHAVIOR_LABELS: &[&str] = &["Ask me", "Always try lowest quality", const RETRY_BEHAVIOR_VALUES: &[&str] = &["ask", "always_fallback", "always_skip"]; const QCONNECT_STARTUP_LABELS: &[&str] = &["Remember state", "On by default", "Off by default"]; const QCONNECT_STARTUP_VALUES: &[&str] = &["remember_last", "on", "off"]; +// Appearance option tables (AppearanceSettings.slint / ui_prefs.rs). +const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art"]; +const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred"]; +const LANGUAGE_LABELS: &[&str] = &[ + "Auto", "English", "Español", "Français", "Deutsch", "Português", "Русский", "日本語", + "Nederlands", +]; +const LANGUAGE_VALUES: &[&str] = &["auto", "en", "es", "fr", "de", "pt", "ru", "ja", "nl"]; +const UI_SCALE_LABELS: &[&str] = &["Extra small", "Small", "Default", "Large", "Extra large"]; +const UI_SCALE_VALUES: &[&str] = &["xs", "small", "default", "large", "xl"]; +const IMMERSIVE_SEARCH_LABELS: &[&str] = + &["Disabled", "Replace current queue", "Play next", "Add to queue"]; +const IMMERSIVE_SEARCH_VALUES: &[&str] = &["disabled", "replace", "next", "queue"]; +const IMMERSIVE_VIEW_LABELS: &[&str] = + &["Remember last", "Album Reactive", "Static", "Coverflow", "Spectrum", "Lyrics", "Queue"]; +const IMMERSIVE_VIEW_VALUES: &[&str] = + &["remember", "reactive", "static", "coverflow", "spectrum", "lyrics", "queue"]; +const MINI_VIEW_LABELS: &[&str] = + &["Remember last used", "Micro", "Compact", "Artwork", "Queue", "Lyrics"]; +const MINI_VIEW_VALUES: &[&str] = &["remember", "micro", "compact", "artwork", "queue", "lyrics"]; +const STARTUP_PAGE_LABELS: &[&str] = &["Home", "Where you left off"]; +const STARTUP_PAGE_VALUES: &[&str] = &["home", "remember"]; +const WC_POSITION_LABELS: &[&str] = &["Left", "Right"]; +const WC_POSITION_VALUES: &[&str] = &["left", "right"]; +const RENDERER_LABELS: &[&str] = + &["Auto (recommended)", "GPU", "GPU (compatibility)", "Software"]; +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"]; + +fn index_of(values: &[&str], key: &str, default: usize) -> i32 { + values.iter().position(|v| *v == key).unwrap_or(default) as i32 +} + +/// Preferred-GPU dropdown (AppearanceSettings.slint): "Auto (recommended)" +/// + detected adapter names built in Rust. POC-NOTE: no adapter enumeration +/// in the POC — the list is Auto + the currently persisted adapter (so the +/// owner's real value shows selected); picking it again is a no-op. +fn gpu_power_choice() -> (Vec, i32) { + let persisted = pref_str("gpu_power", "auto"); + let mut opts = vec![qbz_i18n::t("Auto (recommended)")]; + if persisted != "auto" && !persisted.is_empty() { + opts.push(persisted); + (opts, 1) + } else { + (opts, 0) + } +} #[derive(Clone, Default, Serialize)] pub struct DeviceOption { @@ -386,6 +535,125 @@ pub struct SettingsDoc { pub qconnect_device_name: String, #[serde(rename = "qconnectDeviceNameDefault")] pub qconnect_device_name_default: String, + // Appearance (phase 19; the ui_prefs defaults mirror ui_prefs.rs) + #[serde(rename = "albumHeaderGradient")] + pub album_header_gradient: bool, + #[serde(rename = "appBackgroundModes")] + pub app_background_modes: Vec, + #[serde(rename = "appBackgroundIndex")] + pub app_background_index: i32, + #[serde(rename = "autoThemeSourceIndex")] + pub auto_theme_source_index: i32, + #[serde(rename = "intelligentSearch")] + pub intelligent_search: bool, + pub languages: Vec, + #[serde(rename = "languageIndex")] + pub language_index: i32, + #[serde(rename = "uiScales")] + pub ui_scales: Vec, + #[serde(rename = "uiScaleIndex")] + pub ui_scale_index: i32, + #[serde(rename = "immersiveSearchActions")] + pub immersive_search_actions: Vec, + #[serde(rename = "immersiveSearchActionIndex")] + pub immersive_search_action_index: i32, + #[serde(rename = "immersiveDefaultViews")] + pub immersive_default_views: Vec, + #[serde(rename = "immersiveDefaultViewIndex")] + pub immersive_default_view_index: i32, + #[serde(rename = "navInSidebar")] + pub nav_in_sidebar: bool, + #[serde(rename = "navHeaderCompact")] + pub nav_header_compact: bool, + #[serde(rename = "myQbzLabel")] + pub my_qbz_label: String, + #[serde(rename = "sidebarPlaylistCollage")] + pub sidebar_playlist_collage: bool, + #[serde(rename = "localLibraryTrackArtwork")] + pub local_library_track_artwork: bool, + #[serde(rename = "playIndicatorAnimation")] + pub play_indicator_animation: bool, + #[serde(rename = "invertSwipeNavigation")] + pub invert_swipe_navigation: bool, + #[serde(rename = "inAppToasts")] + pub in_app_toasts: bool, + #[serde(rename = "systemNotifications")] + pub system_notifications: bool, + #[serde(rename = "windowTitleShow")] + pub window_title_show: bool, + #[serde(rename = "useSystemTitleBar")] + pub use_system_title_bar: bool, + #[serde(rename = "hideTitleBar")] + pub hide_title_bar: bool, + #[serde(rename = "wcPositions")] + pub wc_positions: Vec, + #[serde(rename = "wcPositionIndex")] + pub wc_position_index: i32, + #[serde(rename = "showWindowControls")] + pub show_window_controls: bool, + #[serde(rename = "showVolumeSteppers")] + pub show_volume_steppers: bool, + #[serde(rename = "miniDefaultViews")] + pub mini_default_views: Vec, + #[serde(rename = "miniDefaultViewIndex")] + pub mini_default_view_index: i32, + #[serde(rename = "startupPages")] + pub startup_pages: Vec, + #[serde(rename = "startupPageIndex")] + pub startup_page_index: i32, + #[serde(rename = "showPurchases")] + pub show_purchases: bool, + #[serde(rename = "navTbPurchases")] + pub nav_tb_purchases: bool, + pub renderers: Vec, + #[serde(rename = "rendererIndex")] + pub renderer_index: i32, + #[serde(rename = "gpuPowers")] + pub gpu_powers: Vec, + #[serde(rename = "gpuPowerIndex")] + pub gpu_power_index: i32, + // Appearance > SYSTEM TRAY (tray_settings.db) + #[serde(rename = "trayEnable")] + pub tray_enable: bool, + #[serde(rename = "trayCloseToTray")] + pub tray_close_to_tray: bool, + #[serde(rename = "trayIconThemes")] + pub tray_icon_themes: Vec, + #[serde(rename = "trayIconThemeIndex")] + pub tray_icon_theme_index: i32, + // Integrations (phase 19) + #[serde(rename = "showRecommendations")] + pub show_recommendations: bool, + #[serde(rename = "musicbrainzEnabled")] + pub musicbrainz_enabled: bool, + #[serde(rename = "scrobbleEnabled")] + pub scrobble_enabled: bool, + #[serde(rename = "scrobbleUiCollapsed")] + pub scrobble_ui_collapsed: bool, + #[serde(rename = "lastfmEnabled")] + pub lastfm_enabled: bool, + #[serde(rename = "lastfmAuthed")] + pub lastfm_authed: bool, + #[serde(rename = "lastfmUsername")] + pub lastfm_username: String, + #[serde(rename = "lastfmAuthUrl")] + pub lastfm_auth_url: String, + #[serde(rename = "lastfmBusy")] + pub lastfm_busy: bool, + #[serde(rename = "listenbrainzEnabled")] + pub listenbrainz_enabled: bool, + #[serde(rename = "listenbrainzAuthed")] + pub listenbrainz_authed: bool, + #[serde(rename = "listenbrainzUsername")] + pub listenbrainz_username: String, + #[serde(rename = "listenbrainzBusy")] + pub listenbrainz_busy: bool, + #[serde(rename = "integrationsStatusText")] + pub integrations_status_text: String, + #[serde(rename = "integrationsStatusKind")] + pub integrations_status_kind: i32, + #[serde(rename = "discordEnabled")] + pub discord_enabled: bool, } /// Index -> value maps the select handlers resolve against. @@ -527,6 +795,8 @@ pub async fn publish_snapshot() { .position(|v| *v == audio_settings.quality_fallback_behavior) .unwrap_or(0); let qconnect_startup = qconnect_load_startup_mode(); + let scrobble_snap = crate::integrations_qt::scrobble_settings(); + let integ_ui = crate::integrations_qt::ui_snapshot(); let qconnect_startup_index = QCONNECT_STARTUP_VALUES .iter() .position(|v| *v == qconnect_startup) @@ -595,6 +865,111 @@ pub async fn publish_snapshot() { qconnect_startup_index: qconnect_startup_index as i32, qconnect_device_name: qconnect_load_device_name().unwrap_or_default(), qconnect_device_name_default: qconnect_default_name(), + album_header_gradient: pref_bool("album_header_gradient", true), + app_background_modes: APP_BACKGROUND_LABELS + .iter() + .map(|l| qbz_i18n::t(l)) + .collect(), + app_background_index: APP_BACKGROUND_VALUES + .iter() + .position(|v| *v == pref_str("app_background", "off")) + .unwrap_or(0) as i32, + auto_theme_source_index: index_of( + &["system", "wallpaper", "image"], + &pref_str("auto_theme_source", "system"), + 0, + ), + intelligent_search: pref_bool("intelligent_search", true), + languages: LANGUAGE_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(), + language_index: index_of(LANGUAGE_VALUES, &pref_str("language", "auto"), 0), + ui_scales: UI_SCALE_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(), + ui_scale_index: index_of(UI_SCALE_VALUES, &pref_str("ui_scale", "default"), 2), + immersive_search_actions: IMMERSIVE_SEARCH_LABELS + .iter() + .map(|l| qbz_i18n::t(l)) + .collect(), + immersive_search_action_index: index_of( + IMMERSIVE_SEARCH_VALUES, + &pref_str("immersive_search_action", "replace"), + 1, + ), + immersive_default_views: IMMERSIVE_VIEW_LABELS + .iter() + .map(|l| qbz_i18n::t(l)) + .collect(), + immersive_default_view_index: index_of( + IMMERSIVE_VIEW_VALUES, + &pref_str("immersive_default_view", "remember"), + 0, + ), + nav_in_sidebar: pref_bool("nav_in_sidebar", true), + nav_header_compact: pref_bool("nav_header_compact", true), + my_qbz_label: myqbz_label(), + sidebar_playlist_collage: pref_bool("sidebar_playlist_collage", true), + local_library_track_artwork: pref_bool("local_library_track_artwork", false), + play_indicator_animation: pref_bool("play_indicator_animation", false), + invert_swipe_navigation: pref_bool("invert_swipe_navigation", false), + in_app_toasts: pref_bool("in_app_toasts", true), + system_notifications: pref_bool("system_notifications", true), + window_title_show: pref_bool("window_title_show", false), + use_system_title_bar: use_system_title_bar(), + hide_title_bar: pref_bool("hide_title_bar", false), + wc_positions: WC_POSITION_LABELS + .iter() + .map(|l| qbz_i18n::t(l)) + .collect(), + wc_position_index: index_of(WC_POSITION_VALUES, &pref_str("wc_position", "right"), 1), + show_window_controls: pref_bool("show_window_controls", true), + show_volume_steppers: pref_bool("show_volume_steppers", false), + mini_default_views: MINI_VIEW_LABELS + .iter() + .map(|l| qbz_i18n::t(l)) + .collect(), + mini_default_view_index: index_of( + MINI_VIEW_VALUES, + &pref_str("mini_default_view", "remember"), + 0, + ), + startup_pages: STARTUP_PAGE_LABELS + .iter() + .map(|l| qbz_i18n::t(l)) + .collect(), + startup_page_index: index_of(STARTUP_PAGE_VALUES, &pref_str("startup_page", "home"), 0), + show_purchases: pref_bool("show_purchases", false), + nav_tb_purchases: pref_bool("nav_tb_purchases", false), + renderers: RENDERER_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(), + renderer_index: index_of(RENDERER_VALUES, &pref_str("renderer", "auto"), 0), + gpu_powers: gpu_power_choice().0, + gpu_power_index: gpu_power_choice().1, + tray_enable: tray().get_settings().map(|t| t.enable_tray).unwrap_or(true), + tray_close_to_tray: tray() + .get_settings() + .map(|t| t.close_to_tray) + .unwrap_or(false), + tray_icon_themes: TRAY_ICON_LABELS + .iter() + .map(|l| qbz_i18n::t(l)) + .collect(), + tray_icon_theme_index: tray() + .get_settings() + .map(|t| index_of(TRAY_ICON_VALUES, &t.tray_icon_theme, 0)) + .unwrap_or(0), + show_recommendations: crate::integrations_qt::show_recommendations(), + musicbrainz_enabled: pref_bool("musicbrainz_enabled", true), + scrobble_enabled: scrobble_snap.enabled, + scrobble_ui_collapsed: scrobble_snap.ui_collapsed, + lastfm_enabled: scrobble_snap.lastfm_enabled, + lastfm_authed: scrobble_snap.lastfm_is_authed(), + lastfm_username: scrobble_snap.lastfm_username.clone(), + lastfm_auth_url: integ_ui.lastfm_auth_url.clone(), + lastfm_busy: integ_ui.lastfm_busy, + listenbrainz_enabled: scrobble_snap.listenbrainz_enabled, + listenbrainz_authed: scrobble_snap.listenbrainz_is_authed(), + listenbrainz_username: scrobble_snap.listenbrainz_username.clone(), + listenbrainz_busy: integ_ui.listenbrainz_busy, + integrations_status_text: integ_ui.status_text.clone(), + integrations_status_kind: integ_ui.status_kind, + discord_enabled: crate::integrations_qt::discord_enabled(), } }) .await @@ -724,6 +1099,118 @@ pub async fn settings_bool(runtime: &Arc>, key: &str, "resume-position" => { with_playback(|s| s.set_resume_playback_position(value)).map(|_| Apply::None) } + // --- Appearance (phase 19): plain ui_prefs bools (+ live bridge + // side-effects where the POC already has the consumer). + "album-header-gradient" => { + save_pref("album_header_gradient", serde_json::json!(value)); + Ok(Apply::None) + } + "intelligent-search" => { + save_pref("intelligent_search", serde_json::json!(value)); + crate::ui(move |mut b| b.as_mut().set_intelligent_search(value)); + Ok(Apply::None) + } + "nav-in-sidebar" => { + save_pref("nav_in_sidebar", serde_json::json!(value)); + Ok(Apply::None) + } + "nav-header-compact" => { + save_pref("nav_header_compact", serde_json::json!(value)); + Ok(Apply::None) + } + "sidebar-playlist-collage" => { + save_pref("sidebar_playlist_collage", serde_json::json!(value)); + Ok(Apply::None) + } + "local-library-track-artwork" => { + save_pref("local_library_track_artwork", serde_json::json!(value)); + Ok(Apply::None) + } + "play-indicator-animation" => { + save_pref("play_indicator_animation", serde_json::json!(value)); + Ok(Apply::None) + } + "invert-swipe-navigation" => { + save_pref("invert_swipe_navigation", serde_json::json!(value)); + Ok(Apply::None) + } + "in-app-toasts" => { + save_pref("in_app_toasts", serde_json::json!(value)); + Ok(Apply::None) + } + "system-notifications" => { + save_pref("system_notifications", serde_json::json!(value)); + Ok(Apply::None) + } + "window-title-show" => { + save_pref("window_title_show", serde_json::json!(value)); + Ok(Apply::None) + } + "use-system-title-bar" => { + save_pref("use_system_title_bar", serde_json::json!(value)); + // Restart semantics (1:1 Slint): the APPLIED mode is untouched; + // only the menu/row state flips live. + log::info!("[qbz-qt] use_system_title_bar -> {value} (applies on next launch)"); + crate::ui(move |mut b| b.as_mut().set_system_title_bar_pref(value)); + Ok(Apply::None) + } + "hide-title-bar" => { + save_pref("hide_title_bar", serde_json::json!(value)); + Ok(Apply::None) + } + "show-window-controls" => { + save_pref("show_window_controls", serde_json::json!(value)); + Ok(Apply::None) + } + "show-volume-steppers" => { + save_pref("show_volume_steppers", serde_json::json!(value)); + Ok(Apply::None) + } + "show-purchases" => { + save_pref("show_purchases", serde_json::json!(value)); + Ok(Apply::None) + } + "nav-tb-purchases" => { + save_pref("nav_tb_purchases", serde_json::json!(value)); + Ok(Apply::None) + } + "tray-enable" => tray() + .set_enable_tray(value) + .map_err(|e| e.to_string()) + .map(|_| Apply::None), + "tray-close-to-tray" => tray() + .set_close_to_tray(value) + .map_err(|e| e.to_string()) + .map(|_| Apply::None), + // --- Integrations (phase 19) -------------------------------------- + "show-recommendations" => { + crate::integrations_qt::set_show_recommendations(value).map(|_| Apply::None) + } + "musicbrainz" => { + match crate::integrations_qt::set_musicbrainz_enabled(value) { + Ok(()) => { + // Live apply (main.rs:9019-9029 seeds core from this pref). + runtime.core().musicbrainz_set_enabled(value).await; + Ok(Apply::None) + } + Err(e) => Err(e), + } + } + "scrobble-enable" => { + crate::integrations_qt::set_scrobble_enabled(value).map(|_| Apply::None) + } + "scrobble-collapse" => { + crate::integrations_qt::set_scrobble_collapsed(value).map(|_| Apply::None) + } + "lastfm-enable" => { + crate::integrations_qt::set_lastfm_enabled(value).map(|_| Apply::None) + } + "listenbrainz-enable" => { + crate::integrations_qt::set_listenbrainz_enabled(value).map(|_| Apply::None) + } + "discord-rpc" => { + crate::integrations_qt::set_discord_enabled(value).map(|_| Apply::None) + } other => { log::warn!("[qbz-qt] unknown settings bool key: {other}"); return; @@ -838,6 +1325,92 @@ pub async fn settings_select(runtime: &Arc>, key: &st }; qconnect_persist_startup_mode(mode); } + // --- Appearance (phase 19) ---------------------------------------- + "app-background" => { + let Some(mode) = APP_BACKGROUND_VALUES.get(index) else { + return; + }; + save_pref("app_background", serde_json::json!(mode)); + // Live (pure QML layering; blurred renders AS ambient in the POC). + let ambient = if *mode == "off" { 0 } else { 1 }; + crate::ui(move |mut b| b.as_mut().set_ambient_mode(ambient)); + } + "language" => { + let Some(lang) = LANGUAGE_VALUES.get(index) else { + return; + }; + save_pref("language", serde_json::json!(lang)); + // POC-NOTE: live language switching is phase 20; the pref + // persists here and applies on the next launch. + log::info!("[qbz-qt] language -> {lang} (applies on next launch; live switch is phase 20)"); + } + "ui-scale" => { + let Some(scale) = UI_SCALE_VALUES.get(index) else { + return; + }; + save_pref("ui_scale", serde_json::json!(scale)); + log::info!("[qbz-qt] ui_scale -> {scale} (restart to apply)"); + } + "immersive-search-action" => { + let Some(v) = IMMERSIVE_SEARCH_VALUES.get(index) else { + return; + }; + save_pref("immersive_search_action", serde_json::json!(v)); + } + "immersive-default-view" => { + let Some(v) = IMMERSIVE_VIEW_VALUES.get(index) else { + return; + }; + save_pref("immersive_default_view", serde_json::json!(v)); + } + "wc-position" => { + let Some(v) = WC_POSITION_VALUES.get(index) else { + return; + }; + save_pref("wc_position", serde_json::json!(v)); + } + "mini-default-view" => { + let Some(v) = MINI_VIEW_VALUES.get(index) else { + return; + }; + save_pref("mini_default_view", serde_json::json!(v)); + } + "startup-page" => { + let Some(v) = STARTUP_PAGE_VALUES.get(index) else { + return; + }; + save_pref("startup_page", serde_json::json!(v)); + } + "renderer" => { + let Some(v) = RENDERER_VALUES.get(index) else { + return; + }; + save_pref("renderer", serde_json::json!(v)); + log::info!("[qbz-qt] renderer -> {v} (restart to apply)"); + } + "gpu-power" => { + // Only "Auto" is actionable in the POC (no adapter enumeration — + // the persisted-name row is display-only). + if index == 0 { + save_pref("gpu_power", serde_json::json!("auto")); + log::info!("[qbz-qt] gpu_power -> auto (restart to apply)"); + } + } + "tray-icon-theme" => { + let Some(v) = TRAY_ICON_VALUES.get(index) else { + return; + }; + if let Err(e) = tray().set_tray_icon_theme(v) { + log::error!("[qbz-qt] persist tray icon theme failed: {e}"); + } + } + "auto-theme-source" => { + const SOURCES: &[&str] = &["system", "wallpaper", "image"]; + let Some(v) = SOURCES.get(index) else { + return; + }; + save_pref("auto_theme_source", serde_json::json!(v)); + } other => log::warn!("[qbz-qt] unknown settings select key: {other}"), } publish_snapshot().await; @@ -860,6 +1433,14 @@ pub async fn settings_string(key: &str, value: 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; + } publish_snapshot().await; } diff --git a/crates/qbz-qt/src/theme_qt.rs b/crates/qbz-qt/src/theme_qt.rs new file mode 100644 index 000000000..a8f412eff --- /dev/null +++ b/crates/qbz-qt/src/theme_qt.rs @@ -0,0 +1,313 @@ +//! Theme controller (phase 19) — the Slint `crates/qbz/src/theme.rs` port: +//! the persisted `ui_prefs.theme` slug resolves through the SAME `qbz-theme` +//! registry (`ThemeId`/`palette()`), and the materialized `ThemeColors` are +//! serialized to ONE JSON document that QbzTheme.qml binds to — the Qt +//! equivalent of `theme::push_colors` (live switch, no restart). +//! +//! Synthetic slugs (theme.rs): "auto" (AutoSource::System regeneration — +//! falls back to OLED when headless generation fails) and "custom" +//! (`/qbz/custom_theme.json`, the SAME file the Slint custom-theme +//! editor writes; read-only apply here — POC-NOTE: no token editor). The +//! "system" registry theme resolves via the OS palette in Slint; the POC +//! maps it to the Dark palette (POC-NOTE). + +use cxx_qt_lib::QString; +use qbz_theme::{Rgba, ThemeColors, ThemeId}; +use serde::Serialize; + +// --------------------------------------------------------------------------- +// ui_prefs.json keys (theme + theme_filter) — additive patch like +// settings_qt.rs so every other Slint key survives. +// --------------------------------------------------------------------------- + +fn prefs_path() -> Option { + Some(dirs::data_dir()?.join("qbz").join("ui_prefs.json")) +} + +fn read_pref(key: &str) -> Option { + let path = prefs_path()?; + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|v| v.get(key).cloned()) +} + +fn write_pref(key: &str, value: serde_json::Value) { + let Some(path) = prefs_path() else { + return; + }; + let mut doc: serde_json::Value = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_else(|| serde_json::json!({})); + if let Some(obj) = doc.as_object_mut() { + obj.insert(key.to_string(), value); + if let Ok(text) = serde_json::to_string_pretty(&doc) { + let _ = std::fs::write(&path, text); + } + } +} + +/// The persisted theme slug (ui_prefs.rs default: "oled"). +pub fn current_slug() -> String { + read_pref("theme") + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_else(|| "oled".to_string()) +} + +/// The dropdown filter (theme.rs: 0 All / 1 Dark / 2 Light; default 0). +pub fn theme_filter() -> i32 { + read_pref("theme_filter") + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32 +} + +pub fn set_theme_filter(index: i32) { + write_pref("theme_filter", serde_json::json!(index.clamp(0, 2))); +} + +/// Persist a new slug (additive); the caller republishes `themeJson`. +pub fn set_theme(slug: &str) { + write_pref("theme", serde_json::Value::String(slug.to_string())); + log::info!("[qbz-qt] theme -> {slug}"); +} + +// --------------------------------------------------------------------------- +// Slug -> ThemeColors (theme.rs apply_theme / auto / custom startup dispatch) +// --------------------------------------------------------------------------- + +fn custom_theme_path() -> Option { + Some(dirs::data_dir()?.join("qbz").join("custom_theme.json")) +} + +fn custom_colors() -> ThemeColors { + let base = custom_theme_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|text| serde_json::from_str::(&text).ok()) + .unwrap_or_else(qbz_theme::CustomThemeBase::default_oled); + qbz_theme::theme_from_base(&base) +} + +fn auto_colors() -> ThemeColors { + match qbz_theme::generate_auto_theme(&qbz_theme::AutoSource::System) { + Ok(c) => c, + Err(e) => { + // Headless / no DE portal: keep the row selectable but fall back + // to the default dark tokens (POC-NOTE). + log::warn!("[qbz-qt] auto theme generation failed ({e}); falling back to OLED"); + qbz_theme::palette(qbz_theme::default_theme_id()) + } + } +} + +/// Resolve a slug to its materialized colors (unknown -> OLED, theme.rs +/// `id_for_slug` fallback). +pub fn colors_for_slug(slug: &str) -> ThemeColors { + match slug { + "auto" => auto_colors(), + "custom" => custom_colors(), + // POC-NOTE: "system" reads the OS palette in Slint; the POC maps it + // to the Dark registry palette. + "system" => qbz_theme::palette(ThemeId::Dark), + other => ThemeId::from_slug(other) + .map(qbz_theme::palette) + .unwrap_or_else(|| qbz_theme::palette(qbz_theme::default_theme_id())), + } +} + +// --------------------------------------------------------------------------- +// ThemeColors -> QML token JSON (theme::push_colors equivalent) +// --------------------------------------------------------------------------- + +/// QML color literal: "#aarrggbb" (Qt ARGB — the notation QbzTheme.qml +/// already uses). +fn argb(c: Rgba) -> String { + format!("#{:02x}{:02x}{:02x}{:02x}", c.a, c.r, c.g, c.b) +} + +fn with_alpha(c: Rgba, a: u8) -> String { + argb(Rgba { + r: c.r, + g: c.g, + b: c.b, + a, + }) +} + +/// The alpha-ramp percents (colors.rs:12-17) — indices into the 24 tiers. +const ALPHA_PCTS: [u32; 24] = [ + 4, 5, 6, 8, 10, 12, 15, 18, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, +]; + +#[derive(Serialize)] +struct ThemeTokens { + // 30 named colors (ThemeColors contract). + #[serde(rename = "surfaceMain")] + surface_main: String, + #[serde(rename = "surfaceCard")] + surface_card: String, + #[serde(rename = "surfaceElevated")] + surface_elevated: String, + #[serde(rename = "surfaceHover")] + surface_hover: String, + #[serde(rename = "bgHover")] + bg_hover: String, + #[serde(rename = "textPrimary")] + text_primary: String, + #[serde(rename = "textSecondary")] + text_secondary: String, + #[serde(rename = "textMuted")] + text_muted: String, + #[serde(rename = "textDisabled")] + text_disabled: String, + accent: String, + #[serde(rename = "accentHover")] + accent_hover: String, + #[serde(rename = "accentPressed")] + accent_pressed: String, + #[serde(rename = "accentText")] + accent_text: String, + danger: String, + #[serde(rename = "dangerBg")] + danger_bg: String, + #[serde(rename = "dangerBorder")] + danger_border: String, + #[serde(rename = "dangerHover")] + danger_hover: String, + warning: String, + #[serde(rename = "warningBg")] + warning_bg: String, + #[serde(rename = "warningBorder")] + warning_border: String, + #[serde(rename = "warningHover")] + warning_hover: String, + success: String, + #[serde(rename = "successBg")] + success_bg: String, + #[serde(rename = "successBorder")] + success_border: String, + #[serde(rename = "successHover")] + success_hover: String, + #[serde(rename = "borderSubtle")] + border_subtle: String, + #[serde(rename = "borderMuted")] + border_muted: String, + #[serde(rename = "borderStrong")] + border_strong: String, + #[serde(rename = "focusRing")] + focus_ring: String, + favorite: String, + #[serde(rename = "cardShadow")] + card_shadow: String, + // 24 alpha tiers (alpha4..alpha95, white-based dark / black-based light). + alpha: Vec, + // Ambient-layer derivations (phase-14 tokens, now theme-derived): + // chrome surface-card @50%, frosted panel surface-main @22%, thin bars + // surface-main @30%, hairline = alpha tier 10%. + #[serde(rename = "surfaceCardA50")] + surface_card_a50: String, + #[serde(rename = "surfaceMainA22")] + surface_main_a22: String, + #[serde(rename = "surfaceMainA30")] + surface_main_a30: String, + #[serde(rename = "frostBorder")] + frost_border: String, + // Flags (Theme.is-dark). + #[serde(rename = "isDark")] + is_dark: bool, +} + +fn tokens_for(colors: &ThemeColors) -> ThemeTokens { + let is_dark = qbz_theme::relative_luminance(colors.surface_main) < 0.5; + ThemeTokens { + surface_main: argb(colors.surface_main), + surface_card: argb(colors.surface_card), + surface_elevated: argb(colors.surface_elevated), + surface_hover: argb(colors.surface_hover), + bg_hover: argb(colors.bg_hover), + text_primary: argb(colors.text_primary), + text_secondary: argb(colors.text_secondary), + text_muted: argb(colors.text_muted), + text_disabled: argb(colors.text_disabled), + accent: argb(colors.accent), + accent_hover: argb(colors.accent_hover), + accent_pressed: argb(colors.accent_pressed), + accent_text: argb(colors.accent_text), + danger: argb(colors.danger), + danger_bg: argb(colors.danger_bg), + danger_border: argb(colors.danger_border), + danger_hover: argb(colors.danger_hover), + warning: argb(colors.warning), + warning_bg: argb(colors.warning_bg), + warning_border: argb(colors.warning_border), + warning_hover: argb(colors.warning_hover), + success: argb(colors.success), + success_bg: argb(colors.success_bg), + success_border: argb(colors.success_border), + success_hover: argb(colors.success_hover), + border_subtle: argb(colors.border_subtle), + border_muted: argb(colors.border_muted), + border_strong: argb(colors.border_strong), + focus_ring: argb(colors.focus_ring), + favorite: argb(colors.favorite), + card_shadow: argb(colors.card_shadow), + alpha: colors.alpha.iter().map(|c| argb(*c)).collect(), + surface_card_a50: with_alpha(colors.surface_card, 0x80), + surface_main_a22: with_alpha(colors.surface_main, 0x38), + surface_main_a30: with_alpha(colors.surface_main, 0x4d), + frost_border: argb(colors.alpha[ALPHA_PCTS.iter().position(|p| *p == 10).unwrap_or(4)]), + is_dark, + } +} + +/// The current theme as the QML token document. +pub fn theme_json() -> String { + let colors = colors_for_slug(¤t_slug()); + serde_json::to_string(&tokens_for(&colors)).unwrap_or_else(|_| "{}".into()) +} + +/// Re-resolve + republish after a slug change (the push_colors moment). +pub fn publish_theme() { + let json = theme_json(); + let slug = current_slug(); + crate::ui(move |mut b| { + b.as_mut().set_theme_json(QString::from(json.as_str())); + b.as_mut().set_theme_slug(QString::from(slug.as_str())); + }); +} + +// --------------------------------------------------------------------------- +// The dropdown catalog (theme.rs dropdown_labels + the 2 synthetic rows) +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct ThemeEntry { + label: String, + slug: String, + #[serde(rename = "isLight")] + is_light: bool, +} + +/// All 36 registry themes + "Auto (dynamic)" + "Custom" (theme.rs:128-142, +/// 217-227 — the synthetics only show under the All filter; QML filters). +pub fn theme_list_json() -> String { + let mut entries: Vec = qbz_theme::theme_list() + .into_iter() + .map(|e| ThemeEntry { + label: e.display_name.to_string(), + slug: e.slug.to_string(), + is_light: e.is_light, + }) + .collect(); + entries.push(ThemeEntry { + label: "Auto (dynamic)".to_string(), + slug: "auto".to_string(), + is_light: false, + }); + entries.push(ThemeEntry { + label: "Custom".to_string(), + slug: "custom".to_string(), + is_light: false, + }); + serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into()) +}