feat(qbz-qt): phase 14 — app-wide ambient background (modo Cider)
Port of the spec's palette derivation (16x16 downscale, coverage- weighted 24-bin hue histogram, genuine secondary cluster or deeper same-hue, +180 accent, B/W defaults) driven by the now-playing artwork on track change. QPainter AmbientField with the WGSL scene's exact four blob orbits, dim scrim, 30fps cap paused when inactive; chrome surfaces translucent per the AppShell alpha tokens (content frame surface-main @ 0.22 frosted). Same shared app_background pref (owner has it 'ambient'), app-menu toggle applying live, QBZ_BG_* env knobs. 4 new unit tests (14/14 total). km
This commit is contained in:
19 files changed
+599
-13
No files matched your search
Generated
+1
@@ -6617,6 +6617,7 @@ dependencies = [
|
|||||||
"cxx-qt-build",
|
"cxx-qt-build",
|
||||||
"cxx-qt-lib",
|
"cxx-qt-lib",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
"image",
|
||||||
"log",
|
"log",
|
||||||
"open",
|
"open",
|
||||||
"qbz-app",
|
"qbz-app",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ qbz-i18n = { path = "../qbz-i18n" }
|
|||||||
qbz-log = { path = "../qbz-log" }
|
qbz-log = { path = "../qbz-log" }
|
||||||
|
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
cxx-qt-build = "0.7"
|
cxx-qt-build = "0.7"
|
||||||
@@ -57,6 +57,7 @@ fn main() {
|
|||||||
"qml/QbzScrollBar.qml",
|
"qml/QbzScrollBar.qml",
|
||||||
"qml/QbzSpinner.qml",
|
"qml/QbzSpinner.qml",
|
||||||
"qml/RoundedImage.qml",
|
"qml/RoundedImage.qml",
|
||||||
|
"qml/AmbientField.qml",
|
||||||
"qml/QbzContextMenu.qml",
|
"qml/QbzContextMenu.qml",
|
||||||
],
|
],
|
||||||
qrc_files: &qrc_refs,
|
qrc_files: &qrc_refs,
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import com.blitzfc.qbz
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
color: theme.surfaceMain
|
// Transparent while the ambient background is active (phase 14 —
|
||||||
|
// HomeView.slint:163: the frosted content panel shows through).
|
||||||
|
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||||
|
readonly property bool ambientOn: QbzBridge.ambientMode > 0 && QbzBridge.npHasTrack
|
||||||
|
|
||||||
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
||||||
// are rectangular, so the frame's own rounding never reaches the
|
// are rectangular, so the frame's own rounding never reaches the
|
||||||
// view — the view's own fill must round instead.
|
// view — the view's own fill must round instead.
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// AmbientField — the app-wide dynamic background ("modo Cider", phase 14).
|
||||||
|
//
|
||||||
|
// The QML rendering of the Slint ambient WGSL scene (crates/qbz-ui/ui/
|
||||||
|
// shaders/ambient.wgsl): FOUR album-colored blobs on the shader's EXACT
|
||||||
|
// wandering orbits (10-18s, t scaled 0.75), additive-blended over a black
|
||||||
|
// base so near blobs fuse like the metaball field, plus the shader's
|
||||||
|
// vertical edge shade. Colors come from the ambient_qt album triad
|
||||||
|
// (QbzBridge.ambientPrimary/Secondary/Accent, recomputed on track change —
|
||||||
|
// the field crossfades because Canvas repaints are continuous, ~0.6s of
|
||||||
|
// visible blend per the blob softness).
|
||||||
|
//
|
||||||
|
// POC-NOTEs (vs the WGSL original, ambient_qt.rs has the full list):
|
||||||
|
// - No fbm domain warp and no r²/d² metaball potential — plain radial
|
||||||
|
// gradients with 'lighter' compositing approximate the fusion.
|
||||||
|
// - No audio breathe (the POC has no FFT tap; level_smooth = 0 → the
|
||||||
|
// shader's breathe is a constant 1.0 there anyway) and no grain dither.
|
||||||
|
// - Rendered with QPainter (renderTarget Image) so it works on the
|
||||||
|
// software/offscreen path too; 30fps cap + paused when the window is
|
||||||
|
// inactive (the spec's cost controls §6).
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import com.blitzfc.qbz
|
||||||
|
|
||||||
|
Canvas {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// The album triad (hex strings from ambient_qt).
|
||||||
|
property color primary: QbzBridge.ambientPrimary
|
||||||
|
property color secondary: QbzBridge.ambientSecondary
|
||||||
|
property color accent: QbzBridge.ambientAccent
|
||||||
|
// Set false by the host to freeze the clock (inactive / hidden).
|
||||||
|
property bool running: true
|
||||||
|
|
||||||
|
renderTarget: Canvas.Image
|
||||||
|
renderStrategy: Canvas.Cooperative
|
||||||
|
|
||||||
|
// The shader clock (t = time * 0.75). Accumulated at ~30fps.
|
||||||
|
property real t: Math.random() * 40.0
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 33
|
||||||
|
repeat: true
|
||||||
|
running: root.running && root.visible && root.Window.active
|
||||||
|
onTriggered: {
|
||||||
|
root.t += interval / 1000.0 * 0.75
|
||||||
|
root.requestPaint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPrimaryChanged: requestPaint()
|
||||||
|
onSecondaryChanged: requestPaint()
|
||||||
|
onAccentChanged: requestPaint()
|
||||||
|
onWidthChanged: requestPaint()
|
||||||
|
onHeightChanged: requestPaint()
|
||||||
|
|
||||||
|
// One blob: radial gradient, center color at `a` alpha fading to
|
||||||
|
// transparent (the shader's wide smoothstep falloff).
|
||||||
|
function blob(ctx, cx, cy, r, c, a) {
|
||||||
|
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r)
|
||||||
|
g.addColorStop(0.0, Qt.rgba(c.r, c.g, c.b, a))
|
||||||
|
g.addColorStop(0.55, Qt.rgba(c.r, c.g, c.b, a * 0.45))
|
||||||
|
g.addColorStop(1.0, Qt.rgba(c.r, c.g, c.b, 0.0))
|
||||||
|
ctx.fillStyle = g
|
||||||
|
ctx.fillRect(cx - r, cy - r, 2 * r, 2 * r)
|
||||||
|
}
|
||||||
|
|
||||||
|
onPaint: {
|
||||||
|
var ctx = getContext("2d")
|
||||||
|
ctx.reset()
|
||||||
|
var W = width
|
||||||
|
var H = height
|
||||||
|
if (W <= 0 || H <= 0) return
|
||||||
|
|
||||||
|
// Base: the shader's field->0 color (black).
|
||||||
|
ctx.fillStyle = "#000000"
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
|
var t = root.t
|
||||||
|
// The shader's orbit constants (ambient.wgsl cA..cD, aspect folded
|
||||||
|
// back out — window-fraction centers are the plain expressions).
|
||||||
|
var cA = [0.32 + 0.30 * Math.sin(t * 0.40), 0.42 + 0.30 * Math.cos(t * 0.33)]
|
||||||
|
var cB = [0.66 + 0.30 * Math.sin(t * 0.35 + 2.1), 0.56 + 0.32 * Math.cos(t * 0.29 + 1.3)]
|
||||||
|
var cC = [0.50 + 0.34 * Math.cos(t * 0.31 + 4.0), 0.36 + 0.30 * Math.sin(t * 0.45 + 3.2)]
|
||||||
|
var cD = [0.46 + 0.32 * Math.sin(t * 0.27 + 5.3), 0.64 + 0.28 * Math.cos(t * 0.49 + 0.7)]
|
||||||
|
|
||||||
|
// rr = 0.34 of the field; the shader's brightness lands ~0.92 with
|
||||||
|
// the 0.42-1.12 shape spread — the alpha spread below reproduces
|
||||||
|
// that mid-to-low brightness read under the host's dim scrim.
|
||||||
|
var rr = 0.36 * Math.max(W, H)
|
||||||
|
var c4 = Qt.rgba(
|
||||||
|
(primary.r + accent.r) / 2, (primary.g + accent.g) / 2, (primary.b + accent.b) / 2, 1.0)
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = "lighter"
|
||||||
|
blob(ctx, cA[0] * W, cA[1] * H, rr, primary, 0.50)
|
||||||
|
blob(ctx, cB[0] * W, cB[1] * H, rr * 0.95, secondary, 0.46)
|
||||||
|
blob(ctx, cC[0] * W, cC[1] * H, rr * 0.88, accent, 0.44)
|
||||||
|
blob(ctx, cD[0] * W, cD[1] * H, rr * 0.82, c4, 0.42)
|
||||||
|
ctx.globalCompositeOperation = "source-over"
|
||||||
|
|
||||||
|
// Vertical shade: 16% darker at the very top/bottom edges (the
|
||||||
|
// shader's vshade — chrome sits on calmer color).
|
||||||
|
var v = ctx.createLinearGradient(0, 0, 0, H)
|
||||||
|
v.addColorStop(0.0, Qt.rgba(0, 0, 0, 0.16))
|
||||||
|
v.addColorStop(0.5, Qt.rgba(0, 0, 0, 0.0))
|
||||||
|
v.addColorStop(1.0, Qt.rgba(0, 0, 0, 0.16))
|
||||||
|
ctx.fillStyle = v
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,31 @@ import com.blitzfc.qbz
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
// OPAQUE surface-card chrome (phase 12: the translucent-window read
|
// OPAQUE base (the Slint root background is opaque surface-main too —
|
||||||
// was wrong — the Slint main window is opaque with square corners;
|
// invisible while the ambient field mounts over it; this IS the D4
|
||||||
// the compositor owns any rounding).
|
// no-track fallback). The translucent chrome comes from each chrome
|
||||||
|
// piece's OWN background going surface-card @ 0.5 above the field.
|
||||||
color: theme.surfaceCard
|
color: theme.surfaceCard
|
||||||
|
|
||||||
|
// App-wide dynamic background (phase 14): active when the mode pref is
|
||||||
|
// on AND a track is loaded (D4: no track -> opaque theme restored).
|
||||||
|
readonly property bool ambientOn: QbzBridge.ambientMode > 0 && QbzBridge.npHasTrack
|
||||||
|
// The bottom-most visual layer (AppShell.slint:206-242, declared FIRST
|
||||||
|
// so every chrome surface paints above it): the ambient album-colored
|
||||||
|
// field + the dark dim scrim that keeps chrome text legible
|
||||||
|
// (QBZ_BG_DIM-tunable, default 0.35).
|
||||||
|
AmbientField {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: root.ambientOn
|
||||||
|
running: root.ambientOn
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: root.ambientOn
|
||||||
|
color: "#000000"
|
||||||
|
opacity: QbzBridge.ambientDim
|
||||||
|
}
|
||||||
|
|
||||||
// The host ApplicationWindow (custom chrome: drag / maximize / resize).
|
// The host ApplicationWindow (custom chrome: drag / maximize / resize).
|
||||||
property var hostWindow: null
|
property var hostWindow: null
|
||||||
|
|
||||||
@@ -66,7 +86,7 @@ Rectangle {
|
|||||||
anchors.bottom: npb.top
|
anchors.bottom: npb.top
|
||||||
width: (QbzBridge.queueOpen || QbzBridge.lyricsOpen) ? theme.queuePanelWidth : 0
|
width: (QbzBridge.queueOpen || QbzBridge.lyricsOpen) ? theme.queuePanelWidth : 0
|
||||||
clip: true
|
clip: true
|
||||||
color: theme.surfaceCard
|
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||||
|
|
||||||
Behavior on width {
|
Behavior on width {
|
||||||
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
|
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
|
||||||
@@ -112,8 +132,11 @@ Rectangle {
|
|||||||
anchors.rightMargin: 8
|
anchors.rightMargin: 8
|
||||||
anchors.bottomMargin: 8
|
anchors.bottomMargin: 8
|
||||||
radius: theme.radiusMd
|
radius: theme.radiusMd
|
||||||
color: theme.surfaceMain
|
// Frosted content panel while the ambient background is active
|
||||||
border.width: 0
|
// (AppShell.slint: surface-main @ 0.22 + 1px #ffffff@0.10 hairline).
|
||||||
|
color: root.ambientOn ? theme.surfaceMainA22 : theme.surfaceMain
|
||||||
|
border.width: root.ambientOn ? 1 : 0
|
||||||
|
border.color: theme.frostBorder
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
Loader {
|
Loader {
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import com.blitzfc.qbz
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
color: theme.surfaceMain
|
// Transparent while the ambient background is active (phase 14 —
|
||||||
|
// HomeView.slint:163: the frosted content panel shows through).
|
||||||
|
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||||
|
readonly property bool ambientOn: QbzBridge.ambientMode > 0 && QbzBridge.npHasTrack
|
||||||
|
|
||||||
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
||||||
// are rectangular, so the frame's own rounding never reaches the
|
// are rectangular, so the frame's own rounding never reaches the
|
||||||
// view — the view's own fill must round instead.
|
// view — the view's own fill must round instead.
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import com.blitzfc.qbz
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
color: theme.surfaceCard
|
// surface-card @ 0.5 while the ambient background is active (phase 14,
|
||||||
|
// HeaderBar.slint's with-alpha(app-background-surface-alpha)).
|
||||||
|
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||||
|
readonly property bool ambientOn: QbzBridge.ambientMode > 0 && QbzBridge.npHasTrack
|
||||||
|
|
||||||
|
|
||||||
// The host ApplicationWindow (custom chrome); null in previews.
|
// The host ApplicationWindow (custom chrome); null in previews.
|
||||||
property var hostWindow: null
|
property var hostWindow: null
|
||||||
@@ -540,6 +544,15 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppMenuItem {
|
||||||
|
name: "layout-grid"
|
||||||
|
label: QbzBridge.tr("Ambient background")
|
||||||
|
checkedItem: QbzBridge.ambientMode > 0
|
||||||
|
onClicked: {
|
||||||
|
QbzBridge.toggleAmbientBackground()
|
||||||
|
// Applies LIVE (pure QML layering); the check flips now.
|
||||||
|
}
|
||||||
|
}
|
||||||
AppMenuItem {
|
AppMenuItem {
|
||||||
name: "layout-grid"
|
name: "layout-grid"
|
||||||
label: QbzBridge.tr("Use system title bar")
|
label: QbzBridge.tr("Use system title bar")
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ import com.blitzfc.qbz
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
color: theme.surfaceMain
|
// Transparent while the ambient background is active (phase 14 —
|
||||||
|
// HomeView.slint:163: the frosted content panel shows through).
|
||||||
|
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||||
|
readonly property bool ambientOn: QbzBridge.ambientMode > 0 && QbzBridge.npHasTrack
|
||||||
|
|
||||||
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
||||||
// are rectangular, so the frame's own rounding never reaches the
|
// are rectangular, so the frame's own rounding never reaches the
|
||||||
// view — the view's own fill must round instead.
|
// view — the view's own fill must round instead.
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import com.blitzfc.qbz
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
color: theme.surfaceMain
|
// Transparent while the ambient background is active (phase 14 —
|
||||||
|
// HomeView.slint:163: the frosted content panel shows through).
|
||||||
|
color: ambientOn ? "transparent" : theme.surfaceMain
|
||||||
|
readonly property bool ambientOn: QbzBridge.ambientMode > 0 && QbzBridge.npHasTrack
|
||||||
|
|
||||||
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
||||||
// are rectangular, so the frame's own rounding never reaches the
|
// are rectangular, so the frame's own rounding never reaches the
|
||||||
// view — the view's own fill must round instead.
|
// view — the view's own fill must round instead.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ ApplicationWindow {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Loader {
|
Loader {
|
||||||
id: screenLoader
|
id: screenLoader
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ import com.blitzfc.qbz
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
color: theme.surfaceCard
|
// surface-card @ 0.5 while the ambient background is active (phase 14,
|
||||||
|
// PlayerBarSmall.slint).
|
||||||
|
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||||
|
readonly property bool ambientOn: QbzBridge.ambientMode > 0 && QbzBridge.npHasTrack
|
||||||
|
|
||||||
|
|
||||||
QbzTheme { id: theme }
|
QbzTheme { id: theme }
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ QtObject {
|
|||||||
readonly property color borderSubtle: "#14ffffff"
|
readonly property color borderSubtle: "#14ffffff"
|
||||||
readonly property color borderMuted: "#38ffffff"
|
readonly property color borderMuted: "#38ffffff"
|
||||||
readonly property color cardShadow: "#66000000"
|
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"
|
||||||
|
|
||||||
// --- Spacing (spacing.slint) --------------------------------------
|
// --- Spacing (spacing.slint) --------------------------------------
|
||||||
readonly property int spacingXs: 4
|
readonly property int spacingXs: 4
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ Rectangle {
|
|||||||
width: QbzBridge.sidebarState === 2 ? 0
|
width: QbzBridge.sidebarState === 2 ? 0
|
||||||
: QbzBridge.sidebarState === 1 ? theme.sidebarMiniWidth
|
: QbzBridge.sidebarState === 1 ? theme.sidebarMiniWidth
|
||||||
: theme.sidebarOpenWidth
|
: theme.sidebarOpenWidth
|
||||||
color: theme.surfaceCard
|
// surface-card @ 0.5 while the ambient background is active (phase 14).
|
||||||
|
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||||
|
readonly property bool ambientOn: QbzBridge.ambientMode > 0 && QbzBridge.npHasTrack
|
||||||
|
|
||||||
// Square edges; clip keeps content from spilling while the width
|
// Square edges; clip keeps content from spilling while the width
|
||||||
// animates (same as the Slint root).
|
// animates (same as the Slint root).
|
||||||
clip: true
|
clip: true
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
//! App-wide dynamic ambient background ("modo Cider", phase 14) — the
|
||||||
|
//! palette half of the feature. Ports the album-art triad extraction the
|
||||||
|
//! Slint pushes to `shader_underlay::set_palette` on every track change
|
||||||
|
//! (crates/qbz/src/playback.rs:1590-1643 → crates/qbz/src/immersive.rs):
|
||||||
|
//!
|
||||||
|
//! - primary / secondary: `spectrum_colors` — coverage-weighted hue
|
||||||
|
//! histogram over the cover's CHROMATIC pixels (24 bins, one vote per
|
||||||
|
//! pixel, L in 0.10..=0.93, S >= 0.08, >= 4 chromatic pixels else the
|
||||||
|
//! teal/purple default). Primary = the most abundant hue cluster;
|
||||||
|
//! secondary = a second genuine cluster >= ~45° away and >= 35% of the
|
||||||
|
//! peak, else the same hue deeper (never a fabricated rotation).
|
||||||
|
//! - accent: `lyrics_accent_color` — the dominant hue COMPLEMENTED (+180°),
|
||||||
|
//! vivid mid-bright.
|
||||||
|
//!
|
||||||
|
//! Input = the CURRENT now-playing artwork (the same cached cover the bar
|
||||||
|
//! shows), recomputed on track change from playback_qt's refresh — 1:1 the
|
||||||
|
//! Slint drive point. Decode + downscale run on a spawned task; only the
|
||||||
|
//! three hex strings cross to the UI thread.
|
||||||
|
//!
|
||||||
|
//! POC-NOTEs:
|
||||||
|
//! - The Slint renders the ambient scene with a custom WGSL shader (four
|
||||||
|
//! domain-warped album-colored metaballs + grain + audio breathe). The
|
||||||
|
//! POC renders the same orbit model as additive canvas gradients
|
||||||
|
//! (qml/AmbientField.qml): no fbm warp, no true metaball fusion, no
|
||||||
|
//! grain, no level_smooth breathe (the POC has no FFT tap), so the field
|
||||||
|
//! is a close-but-simpler read of the same concept.
|
||||||
|
//! - The "blurred art" mode (route A, ImmersiveAtmosphere) is NOT
|
||||||
|
//! implemented; a persisted "blurred" pref maps to the ambient look.
|
||||||
|
//! - The Slint gates the whole feature to the wgpu renderer tier; the
|
||||||
|
//! POC's canvas path is renderer-agnostic, so it is always available.
|
||||||
|
|
||||||
|
use cxx_qt_lib::QString;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HSL helpers (immersive.rs rgb_to_hsl / hsl_to_rgb, ported 1:1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
|
||||||
|
let r = r as f32 / 255.0;
|
||||||
|
let g = g as f32 / 255.0;
|
||||||
|
let b = b as f32 / 255.0;
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
let l = (max + min) / 2.0;
|
||||||
|
let d = max - min;
|
||||||
|
if d < 1.0e-6 {
|
||||||
|
return (0.0, 0.0, l);
|
||||||
|
}
|
||||||
|
let s = (d / (1.0 - (2.0 * l - 1.0).abs())).clamp(0.0, 1.0);
|
||||||
|
let h = if max == r {
|
||||||
|
60.0 * (((g - b) / d) % 6.0)
|
||||||
|
} else if max == g {
|
||||||
|
60.0 * ((b - r) / d + 2.0)
|
||||||
|
} else {
|
||||||
|
60.0 * ((r - g) / d + 4.0)
|
||||||
|
};
|
||||||
|
(h.rem_euclid(360.0), s, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
|
||||||
|
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||||
|
let hp = h.rem_euclid(360.0) / 60.0;
|
||||||
|
let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
|
||||||
|
let (r1, g1, b1) = match hp as i32 {
|
||||||
|
0 => (c, x, 0.0),
|
||||||
|
1 => (x, c, 0.0),
|
||||||
|
2 => (0.0, c, x),
|
||||||
|
3 => (0.0, x, c),
|
||||||
|
4 => (x, 0.0, c),
|
||||||
|
_ => (c, 0.0, x),
|
||||||
|
};
|
||||||
|
let m = l - c / 2.0;
|
||||||
|
(
|
||||||
|
((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||||
|
((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||||
|
((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Palette extraction (immersive.rs spectrum_colors / lyrics_accent_color)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Coverage-weighted dominant hue bin over the 16x16 sample (shared by both
|
||||||
|
/// extractors). Returns (best bin, best score, chromatic count).
|
||||||
|
fn dominant_hue_bin(img: &image::RgbaImage) -> (usize, f32, u32) {
|
||||||
|
const BINS: usize = 24;
|
||||||
|
let mut hist = [0.0f32; BINS];
|
||||||
|
let mut chromatic = 0u32;
|
||||||
|
for px in img.pixels() {
|
||||||
|
let (h, s, l) = rgb_to_hsl(px[0], px[1], px[2]);
|
||||||
|
if !(0.10..=0.93).contains(&l) || s < 0.08 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bin = ((h / 360.0 * BINS as f32) as usize).min(BINS - 1);
|
||||||
|
hist[bin] += 1.0;
|
||||||
|
chromatic += 1;
|
||||||
|
}
|
||||||
|
let score_at = |i: usize| {
|
||||||
|
hist[i] + 0.5 * (hist[(i + BINS - 1) % BINS] + hist[(i + 1) % BINS])
|
||||||
|
};
|
||||||
|
let mut best_i = 0usize;
|
||||||
|
let mut best = -1.0f32;
|
||||||
|
for (i, _) in hist.iter().enumerate() {
|
||||||
|
let sc = score_at(i);
|
||||||
|
if sc > best {
|
||||||
|
best = sc;
|
||||||
|
best_i = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(best_i, best, chromatic)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// primary + secondary (spectrum_colors).
|
||||||
|
fn spectrum_colors(img: &image::RgbaImage) -> ((u8, u8, u8), (u8, u8, u8)) {
|
||||||
|
const BINS: usize = 24;
|
||||||
|
let default = ((0, 220, 200), (150, 50, 255));
|
||||||
|
let (best_i, best, chromatic) = dominant_hue_bin(img);
|
||||||
|
if chromatic < 4 {
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
let primary_hue = (best_i as f32 + 0.5) * (360.0 / BINS as f32);
|
||||||
|
|
||||||
|
// Rebuild the histogram scoring for the secondary search (kept 1:1 with
|
||||||
|
// the Slint, which recomputes scores per candidate).
|
||||||
|
let mut hist = [0.0f32; BINS];
|
||||||
|
for px in img.pixels() {
|
||||||
|
let (h, s, l) = rgb_to_hsl(px[0], px[1], px[2]);
|
||||||
|
if !(0.10..=0.93).contains(&l) || s < 0.08 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bin = ((h / 360.0 * BINS as f32) as usize).min(BINS - 1);
|
||||||
|
hist[bin] += 1.0;
|
||||||
|
}
|
||||||
|
let score_at = |i: usize| {
|
||||||
|
hist[i] + 0.5 * (hist[(i + BINS - 1) % BINS] + hist[(i + 1) % BINS])
|
||||||
|
};
|
||||||
|
let mut sec_i: Option<usize> = None;
|
||||||
|
let mut sec_best = 0.0f32;
|
||||||
|
for i in 0..BINS {
|
||||||
|
let circ = (i as i32 - best_i as i32).rem_euclid(BINS as i32);
|
||||||
|
let dist = circ.min(BINS as i32 - circ);
|
||||||
|
if dist < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sc = score_at(i);
|
||||||
|
if sc > sec_best {
|
||||||
|
sec_best = sc;
|
||||||
|
sec_i = Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let primary = hsl_to_rgb(primary_hue, 0.85, 0.58);
|
||||||
|
let secondary = match sec_i.filter(|_| sec_best >= best * 0.35) {
|
||||||
|
Some(si) => {
|
||||||
|
let sec_hue = (si as f32 + 0.5) * (360.0 / BINS as f32);
|
||||||
|
hsl_to_rgb(sec_hue, 0.88, 0.62)
|
||||||
|
}
|
||||||
|
None => hsl_to_rgb(primary_hue, 0.95, 0.40),
|
||||||
|
};
|
||||||
|
(primary, secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// accent (lyrics_accent_color): dominant hue complemented +180°.
|
||||||
|
fn lyrics_accent_color(img: &image::RgbaImage) -> (u8, u8, u8) {
|
||||||
|
let default = (0x3f, 0xd9, 0xc8);
|
||||||
|
let (best_i, _best, chromatic) = dominant_hue_bin(img);
|
||||||
|
if chromatic < 4 {
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
let dominant_hue = (best_i as f32 + 0.5) * (360.0 / 24.0);
|
||||||
|
let accent_hue = (dominant_hue + 180.0).rem_euclid(360.0);
|
||||||
|
hsl_to_rgb(accent_hue, 0.85, 0.62)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(c: (u8, u8, u8)) -> String {
|
||||||
|
format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn img_from(pixels: &[(u8, u8, u8)]) -> image::RgbaImage {
|
||||||
|
let mut img = image::RgbaImage::new(16, 16);
|
||||||
|
for (i, px) in img.pixels_mut().enumerate() {
|
||||||
|
let (r, g, b) = pixels[i % pixels.len()];
|
||||||
|
*px = image::Rgba([r, g, b, 255]);
|
||||||
|
}
|
||||||
|
img
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dominant_hue_wins_over_speck() {
|
||||||
|
// 3/4 red-ish + 1/4 blue-ish: the coverage-weighted primary must be
|
||||||
|
// the red hue (not the more saturated speck).
|
||||||
|
let img = img_from(&[
|
||||||
|
(200, 30, 30), (200, 30, 30), (200, 30, 30), (30, 30, 220),
|
||||||
|
]);
|
||||||
|
let (primary, _secondary) = spectrum_colors(&img);
|
||||||
|
let (h, _s, _l) = rgb_to_hsl(primary.0, primary.1, primary.2);
|
||||||
|
assert!(h < 15.0 || h > 345.0, "primary hue {h} should be red-ish");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grey_cover_falls_back_to_defaults() {
|
||||||
|
let img = img_from(&[(128, 128, 128)]);
|
||||||
|
let (primary, secondary) = spectrum_colors(&img);
|
||||||
|
assert_eq!(primary, (0, 220, 200));
|
||||||
|
assert_eq!(secondary, (150, 50, 255));
|
||||||
|
assert_eq!(lyrics_accent_color(&img), (0x3f, 0xd9, 0xc8));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accent_complements_the_dominant_hue() {
|
||||||
|
let img = img_from(&[(200, 30, 30), (200, 30, 30), (200, 30, 30), (210, 40, 40)]);
|
||||||
|
let accent = lyrics_accent_color(&img);
|
||||||
|
let (h, _s, _l) = rgb_to_hsl(accent.0, accent.1, accent.2);
|
||||||
|
assert!((170.0..=190.0).contains(&h), "accent hue {h} should be cyan-ish");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secondary_stays_on_album_for_mono_covers() {
|
||||||
|
// Single-hue cover: the secondary must be the SAME hue, deeper —
|
||||||
|
// never a fabricated rotation (immersive.rs comment).
|
||||||
|
let img = img_from(&[(220, 40, 120), (200, 30, 110)]);
|
||||||
|
let (primary, secondary) = spectrum_colors(&img);
|
||||||
|
let (ph, _, _) = rgb_to_hsl(primary.0, primary.1, primary.2);
|
||||||
|
let (sh, _, sl) = rgb_to_hsl(secondary.0, secondary.1, secondary.2);
|
||||||
|
let dist = (ph - sh).abs().min(360.0 - (ph - sh).abs());
|
||||||
|
assert!(dist < 30.0, "mono cover: secondary hue {sh} vs primary {ph}");
|
||||||
|
assert!(sl < 0.45, "mono cover: secondary should be deeper (L={sl})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Recompute the ambient triad from the current track's artwork and publish
|
||||||
|
/// it to the bridge (ambientPrimary/Secondary/Accent). Called on track
|
||||||
|
/// change from playback_qt's now-playing refresh. A cover that is not on
|
||||||
|
/// disk yet is downloaded first (the Slint fetches+decodes itself the same
|
||||||
|
/// way); a failed download leaves the previous triad in place (the Slint
|
||||||
|
/// default-until-resolved behavior).
|
||||||
|
pub fn update_for_artwork(artwork_url: &str) {
|
||||||
|
if artwork_url.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let url = artwork_url.to_string();
|
||||||
|
crate::spawn(async move {
|
||||||
|
if crate::artwork_qt::cached_path(&url).is_empty() {
|
||||||
|
crate::artwork_qt::download_missing(vec![url.clone()]).await;
|
||||||
|
}
|
||||||
|
let path = crate::artwork_qt::cached_path(&url);
|
||||||
|
if path.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let path = path.trim_start_matches("file://").to_string();
|
||||||
|
let triad = tokio::task::spawn_blocking(move || {
|
||||||
|
let bytes = std::fs::read(&path).ok()?;
|
||||||
|
let img = image::load_from_memory(&bytes).ok()?;
|
||||||
|
let tiny = image::imageops::resize(
|
||||||
|
&img.to_rgba8(),
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
image::imageops::FilterType::Triangle,
|
||||||
|
);
|
||||||
|
let (primary, secondary) = spectrum_colors(&tiny);
|
||||||
|
let accent = lyrics_accent_color(&tiny);
|
||||||
|
Some((hex(primary), hex(secondary), hex(accent)))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
if let Some((primary, secondary, accent)) = triad {
|
||||||
|
log::info!("[qbz-qt] ambient palette: {primary} / {secondary} / {accent}");
|
||||||
|
crate::ui(move |mut b| {
|
||||||
|
b.as_mut().set_ambient_primary(QString::from(primary.as_str()));
|
||||||
|
b.as_mut()
|
||||||
|
.set_ambient_secondary(QString::from(secondary.as_str()));
|
||||||
|
b.as_mut().set_ambient_accent(QString::from(accent.as_str()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -159,6 +159,21 @@ pub mod qbz_bridge {
|
|||||||
// flips live when the user toggles, applies on the next launch).
|
// flips live when the user toggles, applies on the next launch).
|
||||||
#[qproperty(bool, system_title_bar_pref)]
|
#[qproperty(bool, system_title_bar_pref)]
|
||||||
|
|
||||||
|
// --- Ambient background (phase 14) --------------------------------
|
||||||
|
// 0 = off, 1 = on (the ambient look; the owner's store carries
|
||||||
|
// "ambient"). Live — toggling applies immediately (pure QML
|
||||||
|
// layering). Combined in QML with npHasTrack for the D4 no-track
|
||||||
|
// rule.
|
||||||
|
#[qproperty(i32, ambient_mode)]
|
||||||
|
// Album-art triad (ambient_qt.rs), pushed on track change.
|
||||||
|
#[qproperty(QString, ambient_primary)]
|
||||||
|
#[qproperty(QString, ambient_secondary)]
|
||||||
|
#[qproperty(QString, ambient_accent)]
|
||||||
|
// Look knobs (Slint AppearanceState defaults; QBZ_BG_* env seed).
|
||||||
|
#[qproperty(f32, ambient_dim)]
|
||||||
|
#[qproperty(f32, ambient_surface_alpha)]
|
||||||
|
#[qproperty(f32, ambient_bar_alpha)]
|
||||||
|
|
||||||
type QbzBridge = super::QbzBridgeRust;
|
type QbzBridge = super::QbzBridgeRust;
|
||||||
|
|
||||||
/// Called once from Main.qml's Component.onCompleted: registers the
|
/// Called once from Main.qml's Component.onCompleted: registers the
|
||||||
@@ -393,6 +408,12 @@ pub mod qbz_bridge {
|
|||||||
/// creation, 1:1 Slint). Updates `systemTitleBarPref` only.
|
/// creation, 1:1 Slint). Updates `systemTitleBarPref` only.
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn toggle_system_title_bar(self: Pin<&mut QbzBridge>);
|
fn toggle_system_title_bar(self: Pin<&mut QbzBridge>);
|
||||||
|
|
||||||
|
/// App-menu ambient toggle: flip the persisted `app_background` pref
|
||||||
|
/// off <-> "ambient" and apply LIVE (pure QML layering — no restart,
|
||||||
|
/// unlike the titlebar).
|
||||||
|
#[qinvokable]
|
||||||
|
fn toggle_ambient_background(self: Pin<&mut QbzBridge>);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl cxx_qt::Threading for QbzBridge {}
|
impl cxx_qt::Threading for QbzBridge {}
|
||||||
@@ -468,6 +489,13 @@ pub struct QbzBridgeRust {
|
|||||||
settings_json: QString,
|
settings_json: QString,
|
||||||
system_title_bar: bool,
|
system_title_bar: bool,
|
||||||
system_title_bar_pref: bool,
|
system_title_bar_pref: bool,
|
||||||
|
ambient_mode: i32,
|
||||||
|
ambient_primary: QString,
|
||||||
|
ambient_secondary: QString,
|
||||||
|
ambient_accent: QString,
|
||||||
|
ambient_dim: f32,
|
||||||
|
ambient_surface_alpha: f32,
|
||||||
|
ambient_bar_alpha: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for QbzBridgeRust {
|
impl Default for QbzBridgeRust {
|
||||||
@@ -533,6 +561,14 @@ impl Default for QbzBridgeRust {
|
|||||||
settings_json: QString::from("{}"),
|
settings_json: QString::from("{}"),
|
||||||
system_title_bar: crate::settings_qt::use_system_title_bar(),
|
system_title_bar: crate::settings_qt::use_system_title_bar(),
|
||||||
system_title_bar_pref: crate::settings_qt::use_system_title_bar(),
|
system_title_bar_pref: crate::settings_qt::use_system_title_bar(),
|
||||||
|
ambient_mode: crate::settings_qt::app_background_mode(),
|
||||||
|
// The Slint ImmersiveState default triad (pre-artwork colors).
|
||||||
|
ambient_primary: QString::from("#00dcc8"),
|
||||||
|
ambient_secondary: QString::from("#9632ff"),
|
||||||
|
ambient_accent: QString::from("#3fd9c8"),
|
||||||
|
ambient_dim: crate::settings_qt::ambient_dim(),
|
||||||
|
ambient_surface_alpha: crate::settings_qt::ambient_surface_alpha(),
|
||||||
|
ambient_bar_alpha: crate::settings_qt::ambient_bar_alpha(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -811,4 +847,8 @@ impl qbz_bridge::QbzBridge {
|
|||||||
pub fn toggle_system_title_bar(self: Pin<&mut Self>) {
|
pub fn toggle_system_title_bar(self: Pin<&mut Self>) {
|
||||||
crate::toggle_system_title_bar();
|
crate::toggle_system_title_bar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn toggle_ambient_background(self: Pin<&mut Self>) {
|
||||||
|
crate::toggle_ambient_background();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ mod nav_qt;
|
|||||||
mod now_playing;
|
mod now_playing;
|
||||||
mod offline_fwd;
|
mod offline_fwd;
|
||||||
mod album_qt;
|
mod album_qt;
|
||||||
|
mod ambient_qt;
|
||||||
mod artist_qt;
|
mod artist_qt;
|
||||||
mod lyrics_qt;
|
mod lyrics_qt;
|
||||||
mod playback_qt;
|
mod playback_qt;
|
||||||
@@ -746,6 +747,14 @@ pub(crate) fn toggle_system_title_bar() {
|
|||||||
ui(move |mut b| b.as_mut().set_system_title_bar_pref(next));
|
ui(move |mut b| b.as_mut().set_system_title_bar_pref(next));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// App-menu ambient toggle: persist the flipped pref and apply LIVE (the
|
||||||
|
/// ambient layer is pure QML — no restart needed).
|
||||||
|
pub(crate) fn toggle_ambient_background() {
|
||||||
|
let mode = settings_qt::toggle_ambient_background();
|
||||||
|
log::info!("[qbz-qt] app_background -> {}", if mode == 1 { "ambient" } else { "off" });
|
||||||
|
ui(move |mut b| b.as_mut().set_ambient_mode(mode));
|
||||||
|
}
|
||||||
|
|
||||||
fn load_library_once() {
|
fn load_library_once() {
|
||||||
if *LIBRARY_LOADED.lock().unwrap() {
|
if *LIBRARY_LOADED.lock().unwrap() {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -508,6 +508,10 @@ pub async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
|||||||
// Artwork through the same cache pipeline as Home (attach + background
|
// Artwork through the same cache pipeline as Home (attach + background
|
||||||
// download + republish — single url here).
|
// download + republish — single url here).
|
||||||
crate::artwork_qt::attach_now_playing(&track.artwork_url.clone().unwrap_or_default());
|
crate::artwork_qt::attach_now_playing(&track.artwork_url.clone().unwrap_or_default());
|
||||||
|
// Ambient background triad (phase 14): recompute from the new track's
|
||||||
|
// cover (no-op until the cover lands on disk — the previous palette
|
||||||
|
// stays, like the Slint's default-until-resolved).
|
||||||
|
crate::ambient_qt::update_for_artwork(&track.artwork_url.clone().unwrap_or_default());
|
||||||
// Lyrics for the new track (loading state, then the doc).
|
// Lyrics for the new track (loading state, then the doc).
|
||||||
crate::lyrics_qt::publish_loading();
|
crate::lyrics_qt::publish_loading();
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
|
|||||||
@@ -156,6 +156,74 @@ pub fn toggle_system_title_bar() -> bool {
|
|||||||
next
|
next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ui_prefs.json (app-wide ambient background, phase 14) — the Slint
|
||||||
|
// `app_background` enum key: "off" | "ambient" | "blurred"
|
||||||
|
// (crates/qbz/src/ui_prefs.rs; default "off", the owner's store carries
|
||||||
|
// "ambient"). Same additive key patch.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Mode index, ui_prefs.rs `app_background_index` semantics, except the POC
|
||||||
|
/// has no blurred-art route: 0 = off, 1 = on (ambient look) for BOTH
|
||||||
|
/// "ambient" and "blurred" (see ambient_qt.rs POC-NOTE).
|
||||||
|
pub fn app_background_mode() -> i32 {
|
||||||
|
let Some(path) = prefs_path() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let key = std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
|
||||||
|
.and_then(|v| {
|
||||||
|
v.get("app_background")
|
||||||
|
.and_then(|q| q.as_str().map(str::to_string))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "off".to_string());
|
||||||
|
if key == "off" { 0 } else { 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live-tuning knobs (Slint AppearanceState defaults; the QBZ_BG_* envs are
|
||||||
|
/// the same dev knobs the Slint seeds at startup).
|
||||||
|
pub fn ambient_dim() -> f32 {
|
||||||
|
std::env::var("QBZ_BG_DIM")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(0.35)
|
||||||
|
}
|
||||||
|
pub fn ambient_surface_alpha() -> f32 {
|
||||||
|
std::env::var("QBZ_BG_SURFACE_ALPHA")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(0.5)
|
||||||
|
}
|
||||||
|
pub fn ambient_bar_alpha() -> f32 {
|
||||||
|
std::env::var("QBZ_BG_BAR_ALPHA")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(0.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flip + persist off <-> "ambient" (the owner's mode; "blurred" is not a
|
||||||
|
/// POC route). Returns the new mode index (0/1).
|
||||||
|
pub fn toggle_ambient_background() -> i32 {
|
||||||
|
let next = app_background_mode() == 0;
|
||||||
|
if let Some(path) = prefs_path() {
|
||||||
|
let mut value: 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) = value.as_object_mut() {
|
||||||
|
obj.insert(
|
||||||
|
"app_background".to_string(),
|
||||||
|
serde_json::Value::String(if next { "ambient" } else { "off" }.to_string()),
|
||||||
|
);
|
||||||
|
if let Ok(text) = serde_json::to_string_pretty(&value) {
|
||||||
|
let _ = std::fs::write(&path, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next { 1 } else { 0 }
|
||||||
|
}
|
||||||
|
|
||||||
pub const STREAMING_QUALITY_KEYS: &[&str] = &["mp3", "cd", "hires", "hires_plus"];
|
pub const STREAMING_QUALITY_KEYS: &[&str] = &["mp3", "cd", "hires", "hires_plus"];
|
||||||
pub const STREAMING_QUALITY_LABELS: &[&str] = &["MP3", "CD Quality", "Hi-Res", "Hi-Res+"];
|
pub const STREAMING_QUALITY_LABELS: &[&str] = &["MP3", "CD Quality", "Hi-Res", "Hi-Res+"];
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user