feat(qbz-qt): phase 19 — full Settings subnav, Appearance + Integrations, live theming
Theme system as data: qbz_theme registry (36 themes + auto/custom) resolved from the shared ui_prefs slug into a themeJson document; QbzTheme rebound to it so the whole app repaints live (OLED->Light probe-verified). Full subnav (8 sections + Share logs footer); Appearance 1:1 (theme dropdown with dark/light filter, ambient/titlebar rows in their real home, typography, library visuals, window chrome, tray via tray_settings.db, renderer); Integrations 1:1 (MusicBrainz, Last.fm 2-step browser connect + authed state, ListenBrainz token validate, Discord toggle -> live DiscordRpc.set_enabled). Offline/Local Library/Blacklist/Developer render honest placeholders. km
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Settings 12px group spacer — extracted from SettingsView.qml in phase 19.
|
||||
|
||||
import QtQuick
|
||||
|
||||
Item { width: 1; height: 12 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
||||
<path d="M14.12 3.88 16 2" />
|
||||
<path d="M21 21a4 4 0 0 0-3.81-4" />
|
||||
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
|
||||
<path d="M22 13h-4" />
|
||||
<path d="M3 21a4 4 0 0 1 3.81-4" />
|
||||
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
|
||||
<path d="M6 13H2" />
|
||||
<path d="m8 2 1.88 1.88" />
|
||||
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 13v8" />
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
||||
<path d="m8 17 4-4 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.9 4.9 1.4 1.4" />
|
||||
<path d="m17.7 17.7 1.4 1.4" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.3 17.7-1.4 1.4" />
|
||||
<path d="m19.1 4.9-1.4 1.4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
||||
<path d="M14.12 3.88 16 2" />
|
||||
<path d="M21 21a4 4 0 0 0-3.81-4" />
|
||||
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
|
||||
<path d="M22 13h-4" />
|
||||
<path d="M3 21a4 4 0 0 1 3.81-4" />
|
||||
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
|
||||
<path d="M6 13H2" />
|
||||
<path d="m8 2 1.88 1.88" />
|
||||
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 13v8" />
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
||||
<path d="m8 17 4-4 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.9 4.9 1.4 1.4" />
|
||||
<path d="m17.7 17.7 1.4 1.4" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.3 17.7-1.4 1.4" />
|
||||
<path d="m19.1 4.9-1.4 1.4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
||||
<path d="M14.12 3.88 16 2" />
|
||||
<path d="M21 21a4 4 0 0 0-3.81-4" />
|
||||
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
|
||||
<path d="M22 13h-4" />
|
||||
<path d="M3 21a4 4 0 0 1 3.81-4" />
|
||||
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
|
||||
<path d="M6 13H2" />
|
||||
<path d="m8 2 1.88 1.88" />
|
||||
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 13v8" />
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
||||
<path d="m8 17 4-4 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.9 4.9 1.4 1.4" />
|
||||
<path d="m17.7 17.7 1.4 1.4" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.3 17.7-1.4 1.4" />
|
||||
<path d="m19.1 4.9-1.4 1.4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
||||
<path d="M14.12 3.88 16 2" />
|
||||
<path d="M21 21a4 4 0 0 0-3.81-4" />
|
||||
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
|
||||
<path d="M22 13h-4" />
|
||||
<path d="M3 21a4 4 0 0 1 3.81-4" />
|
||||
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
|
||||
<path d="M6 13H2" />
|
||||
<path d="m8 2 1.88 1.88" />
|
||||
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 13v8" />
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
||||
<path d="m8 17 4-4 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.9 4.9 1.4 1.4" />
|
||||
<path d="m17.7 17.7 1.4 1.4" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.3 17.7-1.4 1.4" />
|
||||
<path d="m19.1 4.9-1.4 1.4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
||||
<path d="M14.12 3.88 16 2" />
|
||||
<path d="M21 21a4 4 0 0 0-3.81-4" />
|
||||
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
|
||||
<path d="M22 13h-4" />
|
||||
<path d="M3 21a4 4 0 0 1 3.81-4" />
|
||||
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
|
||||
<path d="M6 13H2" />
|
||||
<path d="m8 2 1.88 1.88" />
|
||||
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 13v8" />
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
||||
<path d="m8 17 4-4 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.9 4.9 1.4 1.4" />
|
||||
<path d="m17.7 17.7 1.4 1.4" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.3 17.7-1.4 1.4" />
|
||||
<path d="m19.1 4.9-1.4 1.4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
||||
<path d="M14.12 3.88 16 2" />
|
||||
<path d="M21 21a4 4 0 0 0-3.81-4" />
|
||||
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
|
||||
<path d="M22 13h-4" />
|
||||
<path d="M3 21a4 4 0 0 1 3.81-4" />
|
||||
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
|
||||
<path d="M6 13H2" />
|
||||
<path d="m8 2 1.88 1.88" />
|
||||
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 13v8" />
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
||||
<path d="m8 17 4-4 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.9 4.9 1.4 1.4" />
|
||||
<path d="m17.7 17.7 1.4 1.4" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.3 17.7-1.4 1.4" />
|
||||
<path d="m19.1 4.9-1.4 1.4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
||||
<path d="M14.12 3.88 16 2" />
|
||||
<path d="M21 21a4 4 0 0 0-3.81-4" />
|
||||
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
|
||||
<path d="M22 13h-4" />
|
||||
<path d="M3 21a4 4 0 0 1 3.81-4" />
|
||||
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
|
||||
<path d="M6 13H2" />
|
||||
<path d="m8 2 1.88 1.88" />
|
||||
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 13v8" />
|
||||
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
||||
<path d="m8 17 4-4 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.9 4.9 1.4 1.4" />
|
||||
<path d="m17.7 17.7 1.4 1.4" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.3 17.7-1.4 1.4" />
|
||||
<path d="m19.1 4.9-1.4 1.4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ScrobblerSettingsState> = 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<Mutex<Option<DiscoverPrefsStore>>> = OnceLock::new();
|
||||
|
||||
fn with_discover<T>(f: impl FnOnce(&DiscoverPrefsStore) -> T) -> Option<T> {
|
||||
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<DiscordRpc> = 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<Mutex<IntegrationUi>> = OnceLock::new();
|
||||
|
||||
fn ui_state() -> &'static Mutex<IntegrationUi> {
|
||||
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<AppRuntime<LoggingAdapter>>, 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}"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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::<serde_json::Value>(&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::<serde_json::Value>(&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<qbz_app::settings::tray::TraySettingsState> = 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::<serde_json::Value>(&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<String>, 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<String>,
|
||||
#[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<String>,
|
||||
#[serde(rename = "languageIndex")]
|
||||
pub language_index: i32,
|
||||
#[serde(rename = "uiScales")]
|
||||
pub ui_scales: Vec<String>,
|
||||
#[serde(rename = "uiScaleIndex")]
|
||||
pub ui_scale_index: i32,
|
||||
#[serde(rename = "immersiveSearchActions")]
|
||||
pub immersive_search_actions: Vec<String>,
|
||||
#[serde(rename = "immersiveSearchActionIndex")]
|
||||
pub immersive_search_action_index: i32,
|
||||
#[serde(rename = "immersiveDefaultViews")]
|
||||
pub immersive_default_views: Vec<String>,
|
||||
#[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<String>,
|
||||
#[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<String>,
|
||||
#[serde(rename = "miniDefaultViewIndex")]
|
||||
pub mini_default_view_index: i32,
|
||||
#[serde(rename = "startupPages")]
|
||||
pub startup_pages: Vec<String>,
|
||||
#[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<String>,
|
||||
#[serde(rename = "rendererIndex")]
|
||||
pub renderer_index: i32,
|
||||
#[serde(rename = "gpuPowers")]
|
||||
pub gpu_powers: Vec<String>,
|
||||
#[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<String>,
|
||||
#[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<AppRuntime<LoggingAdapter>>, 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<AppRuntime<LoggingAdapter>>, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
//! (`<data_dir>/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<std::path::PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("ui_prefs.json"))
|
||||
}
|
||||
|
||||
fn read_pref(key: &str) -> Option<serde_json::Value> {
|
||||
let path = prefs_path()?;
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|text| serde_json::from_str::<serde_json::Value>(&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<std::path::PathBuf> {
|
||||
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::<qbz_theme::CustomThemeBase>(&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<String>,
|
||||
// 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<ThemeEntry> = 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())
|
||||
}
|
||||