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:
vicrodh committed 2026-07-27 23:37:13 -06:00
1 parent cc71337293
commit 9f86a40f52
19 files changed
+599 -13

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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"
+1
View File
@@ -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,
+5 -1
View File
@@ -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.
+109
View File
@@ -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)
}
}
+29 -6
View File
@@ -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 {
+5 -1
View File
@@ -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.
+14 -1
View File
@@ -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")
+5 -1
View File
@@ -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.
+5 -1
View File
@@ -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.
+1
View File
@@ -41,6 +41,7 @@ ApplicationWindow {
Loader { Loader {
id: screenLoader id: screenLoader
anchors.fill: parent anchors.fill: parent
+5 -1
View File
@@ -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 }
+10
View File
@@ -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
+4 -1
View File
@@ -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
+283
View File
@@ -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()));
});
}
});
}
+40
View File
@@ -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();
}
} }
+9
View File
@@ -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;
+4
View File
@@ -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();
+68
View File
@@ -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+"];