feat(qt): immersive bridge, viz two-source enable, coverflow payload (B1)

km
This commit is contained in:
vicrodh committed 2026-08-02 18:27:54 -06:00
1 parent ec8588687b
commit b5298b78bd
9 files changed
+1010 -21

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ fn main() {
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain // (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
// modules and must NOT be listed — only files that declare a // modules and must NOT be listed — only files that declare a
// #[cxx_qt::bridge] mod belong in this array. // #[cxx_qt::bridge] mod belong in this array.
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs"], rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs"],
qml_files: &[ qml_files: &[
"qml/LoginScreen.qml", "qml/LoginScreen.qml",
"qml/Main.qml", "qml/Main.qml",
+6
View File
@@ -117,6 +117,12 @@ ApplicationWindow {
QbzQueue.boot() QbzQueue.boot()
QbzHome.boot() QbzHome.boot()
QbzViz.boot() QbzViz.boot()
// Immersive mode (2026-08-02 immersive-port contract, block B1).
// Booted right after QbzViz: the overlay's open funnel drives the
// viz two-source enable (§4.2), and ambient_qt publishes glow_color/
// atmosphere_url to this singleton on every track change — a missing
// boot line would drop those publishes silently (see above).
QbzImmersive.boot()
QbzLocal.boot() QbzLocal.boot()
QbzLibrary.boot() QbzLibrary.boot()
QbzAlbum.boot() QbzAlbum.boot()
+114 -3
View File
@@ -177,6 +177,47 @@ fn hex(c: (u8, u8, u8)) -> String {
format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2) format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
} }
/// AlbumReactive glow color (immersive-port contract §4.3) — 1:1 port of
/// `crates/qbz/src/immersive.rs:63-88`: the most-saturated NON-EXTREME
/// (luminance 50..220) sample of the 8x8 tiny, fallback (100, 100, 255) ≡
/// the `#6464ff59` default. NOTE: its lightness/saturation are NOT
/// `rgb_to_hsl` above — the Slint glow uses its own lum = (max+min)/2 on the
/// 0..255 scale with the two-arm saturation below; keep them separate.
/// Returns RGB; the alpha (0x59) is applied at the publish site.
fn glow_color(tiny: &image::RgbaImage) -> (u8, u8, u8) {
let mut best_sat = 0.0f32;
let mut best = (100u8, 100u8, 255u8);
for px in tiny.pixels() {
let r = px[0] as f32;
let g = px[1] as f32;
let b = px[2] as f32;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let lum = (max + min) / 2.0;
let sat = if (max - min).abs() < f32::EPSILON {
0.0
} else if lum > 127.0 {
(max - min) / (510.0 - max - min).max(1.0)
} else {
(max - min) / (max + min).max(1.0)
};
if lum > 50.0 && lum < 220.0 && sat > best_sat {
best_sat = sat;
best = (px[0], px[1], px[2]);
}
}
best
}
/// Qt publishes of the glow: Qt colors parse as `#AARRGGBB`, the Slint glow
/// is RRGGBBAA with alpha 0x59 (`#6464ff59` style) — converted HERE, once,
/// at the source, so QML never sees the Slint byte order.
fn glow_hex_qt(c: (u8, u8, u8)) -> String {
format!("#59{:02x}{:02x}{:02x}", c.0, c.1, c.2)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -231,6 +272,54 @@ mod tests {
assert!(dist < 30.0, "mono cover: secondary hue {sh} vs primary {ph}"); assert!(dist < 30.0, "mono cover: secondary hue {sh} vs primary {ph}");
assert!(sl < 0.45, "mono cover: secondary should be deeper (L={sl})"); assert!(sl < 0.45, "mono cover: secondary should be deeper (L={sl})");
} }
// --- glow_color (contract §4.3; port fidelity vs immersive.rs:63-88) ---
fn img8_from(pixels: &[(u8, u8, u8)]) -> image::RgbaImage {
let mut img = image::RgbaImage::new(8, 8);
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 glow_picks_the_most_saturated_non_extreme_sample() {
// Hand-computed against the Slint formula (lum = (max+min)/2 on the
// 0..255 scale; sat two-arm at lum > 127):
// A (200,30,30): lum=115 in range, sat=(170)/(230)=0.739 <- max
// B (60,200,60): lum=130 in range, sat=(140)/(510-260)=0.56
// C (240,230,230): lum=235 EXCLUDED (> 220) despite saturation
// D (20,25,20): lum=22.5 EXCLUDED (< 50)
// grey: sat=0
let img = img8_from(&[
(200, 30, 30),
(60, 200, 60),
(240, 230, 230),
(20, 25, 20),
(128, 128, 128),
]);
assert_eq!(glow_color(&img), (200, 30, 30));
}
#[test]
fn glow_falls_back_when_nothing_qualifies() {
// All-grey 8x8: sat 0 everywhere -> the (100,100,255) fallback ≡ the
// #6464ff59 default.
let img = img8_from(&[(128, 128, 128)]);
assert_eq!(glow_color(&img), (100, 100, 255));
// Only extreme-luminance chroma (too bright / too dark): also fallback.
let img = img8_from(&[(250, 240, 240), (10, 15, 10)]);
assert_eq!(glow_color(&img), (100, 100, 255));
}
#[test]
fn glow_hex_is_qt_aarrggbb_with_the_slint_alpha() {
// Slint RRGGBBAA alpha 0x59 -> Qt #AARRGGBB.
assert_eq!(glow_hex_qt((100, 100, 255)), "#596464ff");
assert_eq!(glow_hex_qt((200, 30, 30)), "#59c81e1e");
}
} }
@@ -257,20 +346,33 @@ pub fn update_for_artwork(artwork_url: &str) {
let triad = tokio::task::spawn_blocking(move || { let triad = tokio::task::spawn_blocking(move || {
let bytes = std::fs::read(&path).ok()?; let bytes = std::fs::read(&path).ok()?;
let img = image::load_from_memory(&bytes).ok()?; let img = image::load_from_memory(&bytes).ok()?;
let rgba = img.to_rgba8();
let tiny = image::imageops::resize( let tiny = image::imageops::resize(
&img.to_rgba8(), &rgba,
16, 16,
16, 16,
image::imageops::FilterType::Triangle, image::imageops::FilterType::Triangle,
); );
let (primary, secondary) = spectrum_colors(&tiny); let (primary, secondary) = spectrum_colors(&tiny);
let accent = lyrics_accent_color(&tiny); let accent = lyrics_accent_color(&tiny);
Some((hex(primary), hex(secondary), hex(accent))) // Immersive (contract §4.3): the SAME decode feeds the 8x8 glow
// sample and the atmosphere asset — both inside this
// spawn_blocking, never on the Qt thread
// (atmosphere_qt.rs:126-127).
let tiny8 = image::imageops::resize(
&rgba,
8,
8,
image::imageops::FilterType::Triangle,
);
let glow = glow_hex_qt(glow_color(&tiny8));
let atmosphere = crate::atmosphere_qt::for_cover_blocking(&path);
Some((hex(primary), hex(secondary), hex(accent), glow, atmosphere))
}) })
.await .await
.ok() .ok()
.flatten(); .flatten();
if let Some((primary, secondary, accent)) = triad { if let Some((primary, secondary, accent, glow, atmosphere)) = triad {
log::info!("[qbz-qt] ambient palette: {primary} / {secondary} / {accent}"); log::info!("[qbz-qt] ambient palette: {primary} / {secondary} / {accent}");
crate::shell_bridge::ui(move |mut b| { crate::shell_bridge::ui(move |mut b| {
b.as_mut().set_ambient_primary(QString::from(primary.as_str())); b.as_mut().set_ambient_primary(QString::from(primary.as_str()));
@@ -278,6 +380,15 @@ pub fn update_for_artwork(artwork_url: &str) {
.set_ambient_secondary(QString::from(secondary.as_str())); .set_ambient_secondary(QString::from(secondary.as_str()));
b.as_mut().set_ambient_accent(QString::from(accent.as_str())); b.as_mut().set_ambient_accent(QString::from(accent.as_str()));
}); });
// Publish ONLY on success; on failure (or a missing atmosphere)
// the previous values stay — never cleared on track change (the
// flicker fix, playback.rs:2344-2351 semantics; contract §4.3).
crate::immersive_bridge::ui(move |mut x| {
x.as_mut().set_glow_color(QString::from(glow.as_str()));
if let Some(url) = atmosphere {
x.as_mut().set_atmosphere_url(QString::from(url.as_str()));
}
});
} }
}); });
} }
+353
View File
@@ -0,0 +1,353 @@
//! QbzImmersive — immersive-mode domain bridge (viz_bridge.rs exemplar:
//! OnceLock<CxxQtThread>, `ui()` no-op pre-boot, `boot()` invokable, `impl
//! Default` for construction-seeded values).
//!
//! Port of the Slint `ImmersiveState` global per the immersive-port contract
//! (`qbz-nix-docs/qt-frontend/2026-08-02-immersive-port/00-CONTRACT.md` §3).
//! All enums are `i32` with the Slint numbering verbatim
//! (`state.slint:4238,4249,4254`): view_mode 0 FOCUS / 1 SPLIT; mode 0 Album
//! Reactive, 1 Static, 2 Coverflow, 3 Spectrum, 4 Lyrics, 5 Queue, 6 WaveBed;
//! split_panel 0 Lyrics, 1 TrackInfo, 2 Suggestions, 3 Queue.
//!
//! The `open` property uses a CUSTOM WRITE (`set_open`) so EVERY write — the
//! five QML exit paths AND the Rust invokables — funnels through one Rust
//! setter (contract §3/D3: Qt's answer to Slint's 6 write sites + 150 ms
//! guard timer). The false→true edge runs §3.1 apply-default-view; both edges
//! drive the viz two-source enable (§3.3a/§4.2).
//!
//! The §3.4 search surface is a BRIDGE SURFACE ONLY in B1: `search_live`
//! mirrors the text, `dismiss_search` clears open+selection, move/activate
//! are no-ops. The controller (debounce, ≥2-char gate, version-guarded load,
//! dispatch table) lands in B5 (`search_qt.rs`).
use std::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
/// Full-shape default for the search document (contract §3.4 payload: a
/// `sections` array, NEVER "{}" — trap 15). B5 fills the section schema when
/// the controller lands.
const IMM_SEARCH_EMPTY: &str = r#"{"sections":[]}"#;
/// Full-shape default for the atmosphere-independent docs is not needed, but
/// the glow default IS a color Qt must parse: Qt colors are `#AARRGGBB`, so
/// the Slint default `#6464ff59` (RRGGBBAA, `state.slint:4231`) is stored
/// converted. `ambient_qt` publishes the same conversion — see its comment.
const GLOW_DEFAULT_QT: &str = "#596464ff";
#[cxx_qt::bridge]
pub mod qbz_immersive {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// Overlay open flag. CUSTOM WRITE (the `set_open` funnel below) —
// QML writes `QbzImmersive.open = false` on every exit path and the
// same edge logic runs (contract §3/D3).
#[qproperty(bool, open, READ, WRITE = set_open, NOTIFY)]
// 0 FOCUS / 1 SPLIT. Mutated ONLY by `setView` / the open-edge
// restore — QML never writes these directly (§3.2).
#[qproperty(i32, view_mode)]
// FOCUS foreground: 0 AlbumReactive, 1 Static, 2 Coverflow, 3
// Spectrum, 4 Lyrics, 5 Queue, 6 WaveBed.
#[qproperty(i32, mode)]
// SPLIT active panel: 0 Lyrics, 1 TrackInfo, 2 Suggestions, 3 Queue.
#[qproperty(i32, split_panel)]
// AlbumReactive glow, Qt `#AARRGGBB` (Slint `#6464ff59` RRGGBBAA
// converted — see GLOW_DEFAULT_QT). Published by ambient_qt only on
// success, never cleared (§4.3).
#[qproperty(QString, glow_color)]
// 128×128 atmosphere PNG as a file:// URL. Published ONLY when a
// fresh asset is ready — NEVER cleared on track change (the flicker
// fix, playback.rs:2344-2351 semantics; §4.3).
#[qproperty(QString, atmosphere_url)]
// --- Immersive search surface (§3.4; controller lands in B5) -------
#[qproperty(bool, imm_search_open)]
// Sections Albums/Artists/Playlists (+ local albums offline) — NO
// track rows, NO top result. Full-shape default, never "{}".
#[qproperty(QString, imm_search_json)]
#[qproperty(bool, imm_search_loading)]
// -1 = no selection (Down from -1 selects the first row; no wrap).
#[qproperty(i32, imm_search_selected_index)]
#[qproperty(f64, imm_search_scroll_y)]
// Two-way with the search field.
#[qproperty(QString, search_input_text)]
type QbzImmersive = super::QbzImmersiveRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
/// domain singleton; only QbzSession.boot also fires crate::on_boot).
#[qinvokable]
fn boot(self: Pin<&mut QbzImmersive>);
/// Shift+I path (§3.3): flips `open`; does NOT force mode=0
/// (1:1 keybindings.rs:538-545).
#[qinvokable]
fn toggle(self: Pin<&mut QbzImmersive>);
/// NPB ViewModeMenu path (§3.3): FIRST setView(current view_mode, 0,
/// current split_panel), THEN open=true.
#[qinvokable]
fn open_from_menu(self: Pin<&mut QbzImmersive>);
/// The ONLY mode mutator (§3.2): sets the three properties and
/// persists the triple ONLY when immersive_default_view == "remember".
#[qinvokable]
fn set_view(self: Pin<&mut QbzImmersive>, view_mode: i32, mode: i32, split_panel: i32);
/// §3.4 search surface — B1: mirrors text into search_input_text and
/// nothing else (the controller lands in B5).
#[qinvokable]
fn search_live(self: Pin<&mut QbzImmersive>, text: QString);
/// §3.4 search surface — B1: NO-OP (B5 implements the clamp/scroll).
#[qinvokable]
fn search_move_selection(self: Pin<&mut QbzImmersive>, delta: i32);
/// §3.4 search surface — B1: NO-OP (B5 implements the dispatch table).
#[qinvokable]
fn search_row_activated(self: Pin<&mut QbzImmersive>, flat_index: i32);
/// §3.4: clears imm_search_open + the selection.
#[qinvokable]
fn dismiss_search(self: Pin<&mut QbzImmersive>);
}
// The custom WRITE target of the `open` property. NOT a qinvokable — QML
// reaches it by writing `QbzImmersive.open`, never by name. (cxx-qt
// custom-setter pattern: the property's auto setter is replaced by this
// method, which owns the store + notify + edge logic.)
unsafe extern "RustQt" {
fn set_open(self: Pin<&mut QbzImmersive>, value: bool);
}
impl cxx_qt::Threading for QbzImmersive {}
}
use qbz_immersive::QbzImmersive;
/// Rust side of the immersive bridge (plain storage, phase-1 pattern).
pub struct QbzImmersiveRust {
open: bool,
view_mode: i32,
mode: i32,
split_panel: i32,
glow_color: QString,
atmosphere_url: QString,
imm_search_open: bool,
imm_search_json: QString,
imm_search_loading: bool,
imm_search_selected_index: i32,
imm_search_scroll_y: f64,
search_input_text: QString,
}
impl Default for QbzImmersiveRust {
fn default() -> Self {
Self {
open: false,
view_mode: 0,
mode: 0,
split_panel: 0,
glow_color: QString::from(GLOW_DEFAULT_QT),
atmosphere_url: QString::from(""),
imm_search_open: false,
imm_search_json: QString::from(IMM_SEARCH_EMPTY),
imm_search_loading: false,
imm_search_selected_index: -1,
imm_search_scroll_y: 0.0,
search_input_text: QString::from(""),
}
}
}
// ---------------------------------------------------------------------------
// The UI hop (viz_bridge.rs pattern)
// ---------------------------------------------------------------------------
static QT_THREAD: OnceLock<CxxQtThread<QbzImmersive>> = OnceLock::new();
/// Queue an immersive-bridge mutation onto the Qt event loop (no-op before
/// boot registers the thread).
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzImmersive>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
/// §3.1 pinned map: a pinned default always lands on `view_mode=0` + the
/// matching FOCUS `mode` (never WaveBed). `None` = "remember"/unknown — the
/// caller restores the persisted triple / does nothing respectively.
fn pinned_view(default_key: &str) -> Option<(i32, i32)> {
match default_key {
"reactive" => Some((0, 0)),
"static" => Some((0, 1)),
"coverflow" => Some((0, 2)),
"spectrum" => Some((0, 3)),
"lyrics" => Some((0, 4)),
"queue" => Some((0, 5)),
_ => None,
}
}
/// The single funnel behind every `open` write (contract §3/D3). Holds the
/// store + notify, the §3.1 apply-default-view on the false→true edge, and
/// the §3.3a viz keep-alive on both edges.
fn apply_open(mut this: Pin<&mut QbzImmersive>, value: bool) {
use cxx_qt::CxxQtType as _;
if this.open == value {
return;
}
this.as_mut().rust_mut().open = value;
this.as_mut().open_changed();
if value {
// §3.1 apply-default-view — runs on EVERY false→true open edge, from
// both entries. Restore writes go DIRECTLY to the properties, NOT
// through set_view (no re-persist on restore — §3.1). The Slint
// shader-mode force-reset is a v1 no-op (§1.3).
let key = crate::settings_qt::pref_str("immersive_default_view", "remember");
if key == "remember" {
// The remember-last triple. Stored as JSON NUMBERS: ui_prefs.json
// is co-owned with the Slint app, whose typed ui_prefs struct
// declares these keys i32 (crates/qbz/src/ui_prefs.rs:517-526) —
// string writes would poison its whole-document load. (The
// contract's "pref_str i32-parse" would read NEITHER app's
// numbers; pref_i32 is the compatible reader — delivery notes.)
let vm = crate::settings_qt::pref_i32("immersive_last_view_mode", 0);
let m = crate::settings_qt::pref_i32("immersive_last_mode", 0);
let sp = crate::settings_qt::pref_i32("immersive_last_split_panel", 0);
this.as_mut().set_view_mode(vm);
this.as_mut().set_mode(m);
this.as_mut().set_split_panel(sp);
} else if let Some((vm, m)) = pinned_view(&key) {
// Pinned: view_mode 0 + the mapped mode; split_panel untouched.
this.as_mut().set_view_mode(vm);
this.as_mut().set_mode(m);
}
// Unknown key: Slint's `_ => {}` — leave the current view alone.
crate::viz_qt::immersive_opened();
} else {
crate::viz_qt::immersive_closed();
}
}
impl qbz_immersive::QbzImmersive {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] immersive Qt thread already registered");
}
}
/// The custom WRITE target of the `open` property — the funnel every
/// QML `QbzImmersive.open = ...` write passes through (contract §3/D3).
pub fn set_open(self: Pin<&mut Self>, value: bool) {
apply_open(self, value);
}
/// Shift+I path (§3.3): flips `open`; mode NOT forced.
pub fn toggle(mut self: Pin<&mut Self>) {
let next = !self.open;
apply_open(self.as_mut(), next);
}
/// NPB ViewModeMenu path (§3.3): FIRST setView(current view_mode, 0,
/// current split_panel) (persists per §3.2 when "remember"), THEN
/// open=true — the §3.1 restore on the open edge then lands the
/// remembered view-mode/split with mode 0. Net: the menu ALWAYS opens on
/// Album Reactive with the remembered view-mode/split.
pub fn open_from_menu(mut self: Pin<&mut Self>) {
let (vm, sp) = (self.view_mode, self.split_panel);
self.as_mut().set_view(vm, 0, sp);
apply_open(self.as_mut(), true);
}
/// The ONLY mode mutator (§3.2). Persists the triple via
/// `settings_qt::save_pref` ONLY when immersive_default_view ==
/// "remember"; a pinned default wins on the next open, so it must not be
/// overwritten (main.rs:10339-10357 parity).
pub fn set_view(mut self: Pin<&mut Self>, view_mode: i32, mode: i32, split_panel: i32) {
self.as_mut().set_view_mode(view_mode);
self.as_mut().set_mode(mode);
self.as_mut().set_split_panel(split_panel);
if crate::settings_qt::pref_str("immersive_default_view", "remember") == "remember" {
// JSON numbers — co-owned file, Slint declares the keys i32 (see
// apply_open's comment).
crate::settings_qt::save_pref(
"immersive_last_view_mode",
serde_json::Value::Number(view_mode.into()),
);
crate::settings_qt::save_pref(
"immersive_last_mode",
serde_json::Value::Number(mode.into()),
);
crate::settings_qt::save_pref(
"immersive_last_split_panel",
serde_json::Value::Number(split_panel.into()),
);
}
}
/// §3.4 surface — B1: mirror the text only. The 220 ms debounce, the
/// ≥2-char gate, the pref re-read and the version-guarded load all land
/// with the B5 controller in search_qt.rs.
pub fn search_live(mut self: Pin<&mut Self>, text: QString) {
self.as_mut().set_search_input_text(text);
}
/// §3.4 surface — B1 NO-OP: B5 implements the arrow clamp (Down from -1
/// selects the first row, Up from the first row returns to -1, no wrap)
/// and the 56/24/4 scroll-y arithmetic.
pub fn search_move_selection(self: Pin<&mut Self>, _delta: i32) {}
/// §3.4 surface — B1 NO-OP: B5 implements the per-row-kind × action
/// dispatch table (album/artist/playlist × replace/next/queue).
pub fn search_row_activated(self: Pin<&mut Self>, _flat_index: i32) {}
/// §3.4 dismiss: clears the dropdown + the selection (main.rs:10552-10564).
pub fn dismiss_search(mut self: Pin<&mut Self>) {
self.as_mut().set_imm_search_open(false);
self.as_mut().set_imm_search_selected_index(-1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pinned_map_matches_the_slint_open_table() {
// main.rs:10311-10336: reactive/static/coverflow/spectrum/lyrics/queue
// -> view_mode 0 + mode 0..5; a pinned view never lands on WaveBed.
assert_eq!(pinned_view("reactive"), Some((0, 0)));
assert_eq!(pinned_view("static"), Some((0, 1)));
assert_eq!(pinned_view("coverflow"), Some((0, 2)));
assert_eq!(pinned_view("spectrum"), Some((0, 3)));
assert_eq!(pinned_view("lyrics"), Some((0, 4)));
assert_eq!(pinned_view("queue"), Some((0, 5)));
}
#[test]
fn remember_and_unknown_keys_are_not_pinned() {
assert_eq!(pinned_view("remember"), None);
assert_eq!(pinned_view(""), None);
assert_eq!(pinned_view("wavebed"), None); // pinned never lands on WaveBed
}
#[test]
fn search_doc_default_is_full_shape() {
let doc: serde_json::Value = serde_json::from_str(IMM_SEARCH_EMPTY).unwrap();
assert!(doc.get("sections").is_some_and(|s| s.is_array()));
}
#[test]
fn glow_default_is_the_slint_default_in_qt_byte_order() {
// Slint #6464ff59 (RRGGBBAA) -> Qt #AARRGGBB: alpha 0x59 first.
assert_eq!(GLOW_DEFAULT_QT, "#596464ff");
}
}
+11
View File
@@ -26,6 +26,10 @@ mod player_bridge;
mod queue_bridge; mod queue_bridge;
mod home_bridge; mod home_bridge;
mod viz_bridge; mod viz_bridge;
// Immersive mode (2026-08-02 immersive-port contract, block B1): the
// QbzImmersive singleton (§3) — open funnel + view persistence + the §3.4
// search surface. The QML overlay lands in B2.
mod immersive_bridge;
mod local_bridge; mod local_bridge;
mod library_bridge; mod library_bridge;
mod album_bridge; mod album_bridge;
@@ -1062,6 +1066,13 @@ pub(crate) fn queue_play_upcoming(index: i32) {
spawn(async move { queue_qt::play_upcoming(&runtime, index.max(0) as usize).await }); spawn(async move { queue_qt::play_upcoming(&runtime, index.max(0) as usize).await });
} }
/// Immersive coverflow / up-next rows: QUEUE-WIDE 0-based upcoming index
/// (contract §4.4) — bypasses the queue panel's page/search VIEW.
pub(crate) fn queue_play_upcoming_flat(index: i32) {
let runtime = app();
spawn(async move { queue_qt::play_upcoming_flat(&runtime, index.max(0) as usize).await });
}
pub(crate) fn queue_remove_upcoming(index: i32) { pub(crate) fn queue_remove_upcoming(index: i32) {
let runtime = app(); let runtime = app();
spawn(async move { queue_qt::remove_upcoming(&runtime, index.max(0) as usize).await }); spawn(async move { queue_qt::remove_upcoming(&runtime, index.max(0) as usize).await });
+23
View File
@@ -38,6 +38,15 @@ pub mod qbz_queue {
// + pagination + #442 section markers). Supersedes `queueModel`. // + pagination + #442 section markers). Supersedes `queueModel`.
#[qproperty(QString, queue_json)] #[qproperty(QString, queue_json)]
// --- Immersive coverflow (2026-08-02 immersive-port contract §4.4) --
// `{"index":i32,"tracks":[{id,title,artist,artUrl}]}` over the FULL
// flat queue ([history oldest-first, NOW, upcoming]); `index` = flat
// position of NOW (= history.len). The tracks array is rebuilt only
// on id-sequence change; a pure advance moves only `index`. The
// immersive Queue badge + coverflow read THIS, never the paged
// `queue_json`. Full-shape default, never "{}" (trap 15).
#[qproperty(QString, coverflow_json)]
// --- Sleep timer (queue footer) ------------------------------------ // --- Sleep timer (queue footer) ------------------------------------
// Kept OUT of `queue_json`: the countdown reformats every second and // Kept OUT of `queue_json`: the countdown reformats every second and
// the document is re-parsed by three QML files on every change, so // the document is re-parsed by three QML files on every change, so
@@ -63,6 +72,12 @@ pub mod qbz_queue {
/// Row actions. /// Row actions.
#[qinvokable] #[qinvokable]
fn queue_play_upcoming(self: Pin<&mut QbzQueue>, index: i32); fn queue_play_upcoming(self: Pin<&mut QbzQueue>, index: i32);
/// Immersive coverflow / up-next rows: play by QUEUE-WIDE 0-based
/// index into the UNFILTERED upcoming list (§4.4). `queuePlayUpcoming`
/// is PAGE-LOCAL (resolved through the filtered/paginated VIEW) —
/// never use it from immersive (trap 23).
#[qinvokable]
fn queue_play_upcoming_flat(self: Pin<&mut QbzQueue>, index: i32);
#[qinvokable] #[qinvokable]
fn queue_remove_upcoming(self: Pin<&mut QbzQueue>, index: i32); fn queue_remove_upcoming(self: Pin<&mut QbzQueue>, index: i32);
#[qinvokable] #[qinvokable]
@@ -113,6 +128,7 @@ type QListQVariant = QList<QVariant>;
pub struct QbzQueueRust { pub struct QbzQueueRust {
queue_model: QListQVariant, queue_model: QListQVariant,
queue_json: QString, queue_json: QString,
coverflow_json: QString,
sleep_active: bool, sleep_active: bool,
sleep_remaining: QString, sleep_remaining: QString,
} }
@@ -122,6 +138,9 @@ impl Default for QbzQueueRust {
Self { Self {
queue_model: QListQVariant::default(), queue_model: QListQVariant::default(),
queue_json: QString::from("{}"), queue_json: QString::from("{}"),
// Full-shape default (trap 15): QML reads `.tracks.length` in the
// pre-publish frame.
coverflow_json: QString::from(r#"{"index":0,"tracks":[]}"#),
sleep_active: false, sleep_active: false,
sleep_remaining: QString::default(), sleep_remaining: QString::default(),
} }
@@ -161,6 +180,10 @@ impl qbz_queue::QbzQueue {
crate::queue_play_upcoming(index); crate::queue_play_upcoming(index);
} }
pub fn queue_play_upcoming_flat(self: Pin<&mut Self>, index: i32) {
crate::queue_play_upcoming_flat(index);
}
pub fn queue_remove_upcoming(self: Pin<&mut Self>, index: i32) { pub fn queue_remove_upcoming(self: Pin<&mut Self>, index: i32) {
crate::queue_remove_upcoming(index); crate::queue_remove_upcoming(index);
} }
+235 -3
View File
@@ -11,9 +11,9 @@
//! while connected). There is no QConnect service in this port yet, so the //! while connected). There is no QConnect service in this port yet, so the
//! local core path is the only one; when the service lands, `move_track` //! local core path is the only one; when the service lands, `move_track`
//! grows that arm BEFORE `core().move_track`. //! grows that arm BEFORE `core().move_track`.
//! - Coverflow: the reference also feeds the immersive coverflow's flat model //!
//! from this same refresh. The immersive view does not exist here //! The immersive coverflow's flat model (the old second owed port) is now
//! (PARITY-DEBT #31), so there is nothing to feed. //! fed by `publish_coverflow` below (immersive-port contract §4.4).
//! //!
//! Row favorite seeds from the phase-5 library feed (Slint uses fav_cache — //! Row favorite seeds from the phase-5 library feed (Slint uses fav_cache —
//! same truth). //! same truth).
@@ -213,6 +213,112 @@ fn paginate(total: usize, requested_page: usize) -> (usize, usize, usize, usize)
(page, page_count, start, end) (page, page_count, start, end)
} }
// ---------------------------------------------------------------------------
// Immersive coverflow (contract §4.4) — the FLAT full-queue model
// ---------------------------------------------------------------------------
/// One coverflow entry. Only the four fields the immersive panels read
/// (`{id, title, artist, artUrl}`) — NOT a full QueueRow: the flat model is
/// rebuilt only on id-sequence change and QML resolves covers via
/// `artwork_qt` from `artUrl`.
#[derive(Clone, Serialize)]
struct CoverflowTrack {
id: String,
title: String,
artist: String,
#[serde(rename = "artUrl")]
art_url: String,
}
impl CoverflowTrack {
fn from_track(t: &QueueTrack) -> Self {
Self {
id: t.id.to_string(),
title: display_title(t),
artist: t.artist.clone(),
art_url: t.artwork_url.clone().unwrap_or_default(),
}
}
}
/// The flat model + NOW index, 1:1 with the Slint builder
/// (`crates/qbz/src/queue.rs:296-321`, semantics `state.slint:4854-4885`):
/// `[history.reversed (oldest..newest), NOW-PLAYING?, upcoming...]`.
/// `history` arrives MOST-RECENT-FIRST (core order), so it is reversed for
/// the oldest-first flat order. The index is `history.len()` in both arms:
/// with a current track it is NOW's slot (state.slint:4878-4881); without
/// one it points at the first upcoming (Slint queue.rs:315-318).
fn coverflow_flat(
history: &[QueueTrack],
current: Option<&QueueTrack>,
upcoming: &[QueueTrack],
) -> (Vec<CoverflowTrack>, usize) {
let mut flat: Vec<CoverflowTrack> = Vec::with_capacity(history.len() + 1 + upcoming.len());
flat.extend(history.iter().rev().map(CoverflowTrack::from_track));
let index = flat.len();
if let Some(t) = current {
flat.push(CoverflowTrack::from_track(t));
}
flat.extend(upcoming.iter().map(CoverflowTrack::from_track));
(flat, index)
}
/// Order-sensitive rolling fingerprint of the flat id-sequence (Slint
/// queue.rs:328-336): equal => pure advance/jump => the tracks array is
/// REUSED and only `index` moves; different => rebuild. Hashing the ordered
/// ids (not a set) catches shuffle/reorder.
fn coverflow_seq_hash(flat: &[CoverflowTrack]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
flat.len().hash(&mut h);
for t in flat {
t.id.hash(&mut h);
}
h.finish()
}
/// (seq hash, serialized tracks array) — the array is rebuilt ONLY on
/// id-sequence change (state.slint:4854-4885 semantics).
static COVERFLOW_CACHE: Mutex<Option<(u64, String)>> = Mutex::new(None);
/// Last coverflow document handed to Qt — same equality-guarded publish
/// discipline as `LAST_JSON` below (:395-419 precedent).
static LAST_COVERFLOW_JSON: Mutex<String> = Mutex::new(String::new());
/// Build + publish the coverflow document (`{"index":i32,"tracks":[...]}`).
/// Runs on EVERY publish BEFORE the queueJson dedup: a change past the
/// current page can leave queueJson byte-identical (same page rows, same
/// totals) while the flat model moved.
fn publish_coverflow(state: &qbz_models::QueueState) {
let (flat, index) = coverflow_flat(
&state.history,
state.current_track.as_ref(),
&state.upcoming,
);
let seq = coverflow_seq_hash(&flat);
let tracks_json = {
let mut cache = COVERFLOW_CACHE.lock().unwrap();
match cache.as_ref() {
Some((h, json)) if *h == seq => json.clone(),
_ => {
let json = serde_json::to_string(&flat).unwrap_or_else(|_| "[]".into());
*cache = Some((seq, json.clone()));
json
}
}
};
let doc = format!("{{\"index\":{index},\"tracks\":{tracks_json}}}");
// Identical document -> no Qt hop, no QML re-parse (LAST_JSON precedent).
let mut last = LAST_COVERFLOW_JSON.lock().unwrap();
if *last == doc {
return;
}
last.clear();
last.push_str(&doc);
crate::queue_bridge::ui(move |mut b| {
b.as_mut().set_coverflow_json(QString::from(doc.as_str()));
});
}
/// Queue-wide upcoming indices of the rows the panel is CURRENTLY showing, in /// Queue-wide upcoming indices of the rows the panel is CURRENTLY showing, in
/// display order (search filter + pagination applied) — the hoisted port of /// display order (search filter + pagination applied) — the hoisted port of
/// `crates/qbz/src/queue.rs::resolve_upcoming_index`, resolved once per action /// `crates/qbz/src/queue.rs::resolve_upcoming_index`, resolved once per action
@@ -393,6 +499,9 @@ pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
.filter_map(|r| canonical_u64(&r.id)) .filter_map(|r| canonical_u64(&r.id))
.collect(); .collect();
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into()); let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into());
// The coverflow doc publishes on EVERY publish, BEFORE the queueJson
// dedup below — see publish_coverflow for why.
publish_coverflow(&state);
// Identical document -> no Qt hop, no QML re-parse, no artwork re-dispatch. // Identical document -> no Qt hop, no QML re-parse, no artwork re-dispatch.
// The compare/store and the Qt post stay under ONE lock so two concurrent // The compare/store and the Qt post stay under ONE lock so two concurrent
// publishes cannot post out of order and strand a stale document behind an // publishes cannot post out of order and strand a stale document behind an
@@ -445,6 +554,18 @@ pub async fn play_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index
} }
} }
/// QUEUE-WIDE 0-based index into the UNFILTERED upcoming list (contract
/// §4.4) — the immersive coverflow side cards and up-next rows call this.
/// Unlike `play_upcoming` above it bypasses the page/search VIEW entirely,
/// so it resolves the right track even when the desktop panel was left
/// filtered or paged (state.slint:4896-4900 `play-coverflow-upcoming`).
pub async fn play_upcoming_flat(runtime: &Arc<AppRuntime<LoggingAdapter>>, upcoming_index: usize) {
match runtime.core().play_upcoming_at(upcoming_index).await {
Some(track) => crate::playback_qt::play_queue_track_public(runtime, track.id).await,
None => log::warn!("[qbz-qt] queue: play_upcoming_flat {upcoming_index} out of range"),
}
}
pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) { pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
let Some(&upcoming_index) = current_page_indices(runtime).await.get(page_index) else { let Some(&upcoming_index) = current_page_indices(runtime).await.get(page_index) else {
log::warn!("[qbz-qt] queue: remove_upcoming {page_index} out of range"); log::warn!("[qbz-qt] queue: remove_upcoming {page_index} out of range");
@@ -718,3 +839,114 @@ pub async fn toggle_favorite(
} }
publish(runtime).await; publish(runtime).await;
} }
#[cfg(test)]
mod tests {
use super::*;
fn track(id: u64, title: &str) -> QueueTrack {
// Only the fields the coverflow reads matter; the rest are inert.
// (QueueTrack has no Default impl — every field is spelled out.)
QueueTrack {
id,
title: title.to_string(),
version: None,
artist: format!("artist-{id}"),
album: String::new(),
album_version: None,
duration_secs: 0,
artwork_url: Some(format!("https://img/{id}.jpg")),
hires: false,
bit_depth: None,
sample_rate: None,
is_local: false,
album_id: None,
artist_id: None,
streamable: true,
source: None,
parental_warning: false,
source_item_id_hint: None,
context_kind: None,
context_id: None,
}
}
/// §4.4 JSON shape: {index, tracks:[{id,title,artist,artUrl}]} over the
/// FULL flat queue, history OLDEST-first.
#[test]
fn coverflow_doc_shape_and_flat_order() {
let history = vec![track(3, "h-new"), track(2, "h-mid"), track(1, "h-old")]; // core: most-recent-first
let current = track(4, "now");
let upcoming = vec![track(5, "up-0"), track(6, "up-1")];
let (flat, index) = coverflow_flat(&history, Some(&current), &upcoming);
// Flat = [h-old, h-mid, h-new, NOW, up-0, up-1]; NOW at history.len.
let ids: Vec<&str> = flat.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, ["1", "2", "3", "4", "5", "6"]);
assert_eq!(index, 3);
let doc = format!(
"{{\"index\":{index},\"tracks\":{}}}",
serde_json::to_string(&flat).unwrap()
);
let v: serde_json::Value = serde_json::from_str(&doc).unwrap();
assert_eq!(v["index"], 3);
let rows = v["tracks"].as_array().unwrap();
assert_eq!(rows.len(), 6);
assert_eq!(rows[3]["id"], "4");
assert_eq!(rows[3]["title"], "now");
assert_eq!(rows[3]["artist"], "artist-4");
assert_eq!(rows[3]["artUrl"], "https://img/4.jpg");
// Exactly the four contract fields, nothing else.
assert_eq!(rows[3].as_object().unwrap().len(), 4);
}
/// NOW index with no current track: history.len (the first upcoming
/// slot), 0 when the whole queue is empty (Slint queue.rs:315-318).
#[test]
fn coverflow_index_without_current() {
let history = vec![track(2, "b"), track(1, "a")];
let upcoming = vec![track(3, "c")];
let (flat, index) = coverflow_flat(&history, None, &upcoming);
let ids: Vec<&str> = flat.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, ["1", "2", "3"]); // no NOW slot
assert_eq!(index, 2);
let (flat, index) = coverflow_flat(&[], None, &[]);
assert!(flat.is_empty());
assert_eq!(index, 0);
}
/// Id-sequence gate: a pure advance keeps the flat id-sequence (tracks
/// array reused, only `index` moves); any membership/order change
/// rebuilds.
#[test]
fn coverflow_seq_hash_gates_the_rebuild() {
let h1 = vec![track(2, "b"), track(1, "a")]; // most-recent-first
let cur = track(3, "c");
let up = vec![track(4, "d")];
let (flat1, idx1) = coverflow_flat(&h1, Some(&cur), &up);
let hash1 = coverflow_seq_hash(&flat1);
assert_eq!(idx1, 2);
// PURE ADVANCE materialized: track 3 moved to history, 4 is NOW —
// the FLAT id-sequence is IDENTICAL ([1,2,3,4]), so the gate skips
// the tracks rebuild and only `index` moves (state.slint:4857-4864).
let h2 = vec![track(3, "c"), track(2, "b"), track(1, "a")];
let cur2 = track(4, "d");
let (flat2, idx2) = coverflow_flat(&h2, Some(&cur2), &[]);
assert_eq!(coverflow_seq_hash(&flat2), hash1);
assert_eq!(idx2, 3);
assert_ne!(idx2, idx1);
// Membership change (enqueue): different sequence -> rebuild.
let up_added = vec![track(4, "d"), track(5, "e")];
let (flat3, _) = coverflow_flat(&h1, Some(&cur), &up_added);
assert_ne!(coverflow_seq_hash(&flat3), hash1);
// Reorder with identical membership: also a rebuild (order-sensitive).
let up_swap = vec![track(5, "e"), track(4, "d")];
let (flat4, _) = coverflow_flat(&h1, Some(&cur), &up_swap);
assert_ne!(coverflow_seq_hash(&flat4), coverflow_seq_hash(&flat3));
}
}
+16
View File
@@ -893,6 +893,22 @@ pub fn pref_str(key: &str, default: &str) -> String {
.unwrap_or_else(|| default.to_string()) .unwrap_or_else(|| default.to_string())
} }
/// i32 reader — same additive single-key discipline as `pref_bool`/`pref_str`.
/// Needed for keys the SLINT app writes as JSON numbers (its typed ui_prefs
/// struct declares them i32, e.g. the immersive remember-last triple), which
/// `pref_str` (string-only) cannot see.
pub fn pref_i32(key: &str, default: i32) -> i32 {
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(serde_json::Value::as_i64))
.map(|n| n as i32)
.unwrap_or(default)
}
/// Additive single-key patch of ui_prefs.json — THE writer every other pref /// Additive single-key patch of ui_prefs.json — THE writer every other pref
/// setter in this file funnels through, so they all inherit `update_prefs`' /// setter in this file funnels through, so they all inherit `update_prefs`'
/// atomic rename and its refusal to rebuild an unparsable document. /// atomic rename and its refusal to rebuild an unparsable document.
+251 -14
View File
@@ -25,13 +25,15 @@
//! producer instead of re-running the FFT over a stale ring buffer. //! producer instead of re-running the FFT over a stale ring buffer.
//! Plus the mode gate: only the ONE stream the visible mode renders is //! Plus the mode gate: only the ONE stream the visible mode renders is
//! marshalled into a `QList` (in Bars mode the 512-float waveform is latched //! marshalled into a `QList` (in Bars mode the 512-float waveform is latched
//! for an instant mode switch but never published). //! for an instant mode switch but never published) — UNLESS the immersive
//! overlay is open, which owns the marshalling set and publishes all three
//! streams every frame (§4.2 of the immersive-port contract).
//! //!
//! Protected-audio note: this lives entirely downstream of the read-only ring //! Protected-audio note: this lives entirely downstream of the read-only ring
//! buffer. It touches none of the device/stream init (CLAUDE.md "Audio //! buffer. It touches none of the device/stream init (CLAUDE.md "Audio
//! backend — PROTECTED"); the tap is a passive sample copy. //! backend — PROTECTED"); the tap is a passive sample copy.
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::thread::Thread; use std::thread::Thread;
@@ -61,21 +63,24 @@ impl VizSink for QtVizSink {
// The producer emits several variants per FFT frame (spectral, bars, // The producer emits several variants per FFT frame (spectral, bars,
// energy, transient, waveform). Waking the drain on each of them would // energy, transient, waveform). Waking the drain on each of them would
// cost 3-4 wakeups per frame for one useful publish, so only the // cost 3-4 wakeups per frame for one useful publish, so only the
// stream the visible mode renders signals. The other cells are still // stream the visible mode renders signals — UNLESS the immersive
// latched so a mode switch has a frame ready immediately. // overlay is open (MARSHAL_ALL, §4.2b), which consumes all three. The
// other cells are still latched so a mode switch has a frame ready
// immediately.
let mode = ACTIVE_MODE.load(Ordering::Relaxed); let mode = ACTIVE_MODE.load(Ordering::Relaxed);
let all = MARSHAL_ALL.load(Ordering::Relaxed);
let wake = match frame { let wake = match frame {
VizFrame::Viz16(b) => { VizFrame::Viz16(b) => {
*self.cells.bars.lock().unwrap() = Some(b); *self.cells.bars.lock().unwrap() = Some(b);
!matches!(mode, 1 | 2) stream_wakes(mode, all, Stream::Bars)
} }
VizFrame::Wave256x2(b) => { VizFrame::Wave256x2(b) => {
*self.cells.waveform.lock().unwrap() = Some(b); *self.cells.waveform.lock().unwrap() = Some(b);
mode == 1 stream_wakes(mode, all, Stream::Waveform)
} }
VizFrame::Energy5(b) => { VizFrame::Energy5(b) => {
*self.cells.energy.lock().unwrap() = Some(b); *self.cells.energy.lock().unwrap() = Some(b);
mode == 2 stream_wakes(mode, all, Stream::Energy)
} }
// The dock renders none of these; ignoring them costs nothing (the // The dock renders none of these; ignoring them costs nothing (the
// producer computes the spectral ribbon only for the shader scenes). // producer computes the spectral ribbon only for the shader scenes).
@@ -97,12 +102,78 @@ static HANDLES: OnceLock<VizHandles> = OnceLock::new();
/// frame can never find itself with nowhere to signal. /// frame can never find itself with nowhere to signal.
static DRAIN_THREAD: OnceLock<Thread> = OnceLock::new(); static DRAIN_THREAD: OnceLock<Thread> = OnceLock::new();
/// Drives the drain loop: false parks it indefinitely, true lets it publish. /// Drives the drain loop: false parks it indefinitely, true lets it publish.
/// This is the EFFECTIVE enable — the OR of the per-source bits in
/// `ENABLED_MASK` (§4.2a); nothing writes it except `apply_effective_enabled`.
static ENABLED: AtomicBool = AtomicBool::new(false); static ENABLED: AtomicBool = AtomicBool::new(false);
/// The band's render mode (0 Bars / 1 Waveform / 2 Energy). The drain only /// The band's render mode (0 Bars / 1 Waveform / 2 Energy). The drain only
/// publishes the ONE stream that mode consumes: in Bars mode, marshalling the /// publishes the ONE stream that mode consumes: in Bars mode, marshalling the
/// 512-float waveform into a QList 30 times a second would be pure waste. /// 512-float waveform into a QList 30 times a second would be pure waste.
static ACTIVE_MODE: AtomicI32 = AtomicI32::new(0); static ACTIVE_MODE: AtomicI32 = AtomicI32::new(0);
// ---------------------------------------------------------------------------
// §4.2 (immersive-port contract): two-source enable + immersive marshal-all
// ---------------------------------------------------------------------------
/// Enable-source bits (§4.2a): the tap runs while EITHER consumer wants it.
/// The dock's bit is driven by the existing `set_enabled` call sites
/// (`viz_bridge.rs:137`, `main.rs:1518`); the immersive bit by
/// `QbzImmersive`'s open funnel.
pub(crate) const DOCK_BIT: u32 = 0b01;
pub(crate) const IMMERSIVE_BIT: u32 = 0b10;
static ENABLED_MASK: AtomicU32 = AtomicU32::new(0);
/// §4.2b: while the immersive overlay is open it owns the marshalling set —
/// bars AND energy AND waveform publish every frame, regardless of
/// `ACTIVE_MODE`. Dock mode-cycle writes while it is set still record
/// `ACTIVE_MODE` (and the dock's pref) but have no effect on the marshalled
/// set; the close edge restores `ACTIVE_MODE` from the pref anyway.
static MARSHAL_ALL: AtomicBool = AtomicBool::new(false);
/// A consumer of the FFT tap (§4.2a).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum VizSource {
Dock,
Immersive,
}
impl VizSource {
fn bit(self) -> u32 {
match self {
VizSource::Dock => DOCK_BIT,
VizSource::Immersive => IMMERSIVE_BIT,
}
}
}
fn mask_with(mask: u32, bit: u32, on: bool) -> u32 {
if on { mask | bit } else { mask & !bit }
}
fn mask_enabled(mask: u32) -> bool {
mask != 0
}
/// The three marshalled streams (the wake table's rows).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Stream {
Bars,
Waveform,
Energy,
}
/// Wake table for the drain signal: marshal-all wakes on EVERY stream;
/// otherwise exactly the ONE stream the dock's active mode renders.
fn stream_wakes(mode: i32, marshal_all: bool, stream: Stream) -> bool {
if marshal_all {
return true;
}
match stream {
Stream::Bars => !matches!(mode, 1 | 2),
Stream::Waveform => mode == 1,
Stream::Energy => mode == 2,
}
}
/// Signal the drain that a frame it cares about is waiting. `unpark` is a /// Signal the drain that a frame it cares about is waiting. `unpark` is a
/// single atomic swap when the drain is already awake, and the permit it /// single atomic swap when the drain is already awake, and the permit it
/// leaves behind is remembered by a later `park()`, so a wake can never be /// leaves behind is remembered by a later `park()`, so a wake can never be
@@ -122,11 +193,29 @@ fn to_qlist(values: &[f32]) -> QList<f32> {
list list
} }
/// Take the pending frame of the stream the visible mode renders (if any) and /// Take the pending frame(s) of the stream(s) the current marshalling set
/// hop it onto the Qt thread. Each cell's guard is released by the end of its /// renders and hop them onto the Qt thread. Each cell's guard is released by
/// `let` statement — the mutex is NEVER held across the `viz_bridge::ui` hop, /// the end of its `let` statement — the mutex is NEVER held across the
/// so the producer never blocks on the drain. /// `viz_bridge::ui` hop, so the producer never blocks on the drain.
fn publish_active(cells: &VizCells) { fn publish_active(cells: &VizCells) {
// §4.2b: immersive open -> publish ALL THREE streams each frame (the
// immersive panels consume bars + energy + waveform simultaneously, like
// the Slint immersive does). ACTIVE_MODE is irrelevant here.
if MARSHAL_ALL.load(Ordering::Relaxed) {
let bars = cells.bars.lock().unwrap().take();
if let Some(b) = bars {
viz_bridge::ui(move |mut v| v.as_mut().set_bars(to_qlist(&b)));
}
let energy = cells.energy.lock().unwrap().take();
if let Some(b) = energy {
viz_bridge::ui(move |mut v| v.as_mut().set_energy(to_qlist(&b)));
}
let waveform = cells.waveform.lock().unwrap().take();
if let Some(b) = waveform {
viz_bridge::ui(move |mut v| v.as_mut().set_waveform(to_qlist(b.as_ref())));
}
return;
}
match ACTIVE_MODE.load(Ordering::Relaxed) { match ACTIVE_MODE.load(Ordering::Relaxed) {
1 => { 1 => {
let frame = cells.waveform.lock().unwrap().take(); let frame = cells.waveform.lock().unwrap().take();
@@ -196,10 +285,37 @@ pub fn install(tap: VisualizerTap) {
log::info!("[qbz-qt][viz] producer + signal-driven drain installed (idle until enabled)"); log::info!("[qbz-qt][viz] producer + signal-driven drain installed (idle until enabled)");
} }
/// Capture gate — the `QbzViz.setEnabled` invokable. Toggles the tap, wakes the /// Capture gate — the `QbzViz.setEnabled` invokable. Drives the DOCK source
/// parked producer/drain, and (when turning off) leaves the last frame on /// bit (§4.2a): both existing call sites (`viz_bridge.rs:137`, `main.rs:1518`)
/// screen frozen, exactly like the Slint handler. /// stay dock-sourced; the effective enable only flips when the OR of the bits
/// does, so a dock toggle while immersive is open (or vice versa) never parks
/// a tap the other consumer still needs.
pub fn set_enabled(on: bool) { pub fn set_enabled(on: bool) {
set_enabled_source(VizSource::Dock, on);
}
/// §4.2a two-source enable: set one consumer's bit; the EFFECTIVE enable is
/// the OR of all bits. Only the effective edges unpark/park the FFT thread —
/// flipping one bit while another stays on changes nothing (trap 4: the
/// immersive close edge NEVER clears the dock's bit).
pub(crate) fn set_enabled_source(source: VizSource, on: bool) {
let bit = source.bit();
let prev = ENABLED_MASK.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |m| {
Some(mask_with(m, bit, on))
});
let Ok(prev) = prev else { return };
let was_enabled = mask_enabled(prev);
let now_enabled = mask_enabled(mask_with(prev, bit, on));
if was_enabled == now_enabled {
return;
}
apply_effective_enabled(now_enabled);
}
/// The effective enable flipped: toggle the tap, wake the parked
/// producer/drain, and (when turning off) leave the last frame on screen
/// frozen, exactly like the Slint handler.
fn apply_effective_enabled(on: bool) {
let Some(h) = HANDLES.get() else { let Some(h) = HANDLES.get() else {
return; return;
}; };
@@ -217,6 +333,31 @@ pub fn set_enabled(on: bool) {
// the loop and stays there, which is exactly the "costs nothing" state. // the loop and stays there, which is exactly the "costs nothing" state.
} }
/// §4.2b immersive open hook (QbzImmersive's open funnel): the immersive bit
/// goes ON and the marshalling set switches to all-three. MARSHAL_ALL is set
/// BEFORE the enable so the first publish after the wake is already the full
/// set.
pub(crate) fn immersive_opened() {
MARSHAL_ALL.store(true, Ordering::Relaxed);
set_enabled_source(VizSource::Immersive, true);
if ENABLED.load(Ordering::Relaxed) {
// The panels should not wait a producer tick for the streams the dock
// mode was not marshalling — same rationale as `set_mode`'s wake.
wake_drain();
}
}
/// §4.2b immersive close hook: the immersive bit goes OFF (the dock's bit is
/// untouched — trap 4/20), single-stream marshalling resumes, and
/// `ACTIVE_MODE` is restored from the dock's persisted pref (dock mode-cycles
/// while immersive was open already updated it via
/// `settings_qt::set_large_spectrum_mode`).
pub(crate) fn immersive_closed() {
set_enabled_source(VizSource::Immersive, false);
MARSHAL_ALL.store(false, Ordering::Relaxed);
set_mode(crate::settings_qt::large_spectrum_mode());
}
/// Point the drain at the stream the band is actually rendering. Called at /// Point the drain at the stream the band is actually rendering. Called at
/// install time (from the persisted pref) and on every mode cycle. /// install time (from the persisted pref) and on every mode cycle.
pub fn set_mode(mode: i32) { pub fn set_mode(mode: i32) {
@@ -243,3 +384,99 @@ pub fn set_paused(paused: bool) {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// §4.2a: the effective enable is the OR of the source bits.
#[test]
fn source_bits_or_to_the_effective_enable() {
let mut mask = 0;
assert!(!mask_enabled(mask));
mask = mask_with(mask, DOCK_BIT, true);
assert!(mask_enabled(mask));
// Immersive on top of dock: still enabled, ONE logical state.
mask = mask_with(mask, IMMERSIVE_BIT, true);
assert!(mask_enabled(mask));
assert_eq!(mask, DOCK_BIT | IMMERSIVE_BIT);
}
/// Trap 4/20: clearing the immersive bit NEVER clears the dock's bit, and
/// vice versa — the tap only parks when the LAST source lets go.
#[test]
fn closing_one_source_keeps_the_other() {
let both = DOCK_BIT | IMMERSIVE_BIT;
let after_immersive_close = mask_with(both, IMMERSIVE_BIT, false);
assert_eq!(after_immersive_close, DOCK_BIT);
assert!(mask_enabled(after_immersive_close));
let after_dock_off = mask_with(both, DOCK_BIT, false);
assert_eq!(after_dock_off, IMMERSIVE_BIT);
assert!(mask_enabled(after_dock_off));
let none = mask_with(after_immersive_close, DOCK_BIT, false);
assert!(!mask_enabled(none));
}
/// The pre-immersive wake table: exactly ONE stream per dock mode.
#[test]
fn single_stream_wake_table() {
for mode in 0..=2 {
let wakes: Vec<bool> = [Stream::Bars, Stream::Waveform, Stream::Energy]
.into_iter()
.map(|s| stream_wakes(mode, false, s))
.collect();
assert_eq!(wakes.iter().filter(|w| **w).count(), 1, "mode {mode}");
}
assert!(stream_wakes(0, false, Stream::Bars));
assert!(stream_wakes(1, false, Stream::Waveform));
assert!(stream_wakes(2, false, Stream::Energy));
}
/// §4.2b: marshal-all wakes on EVERY stream regardless of ACTIVE_MODE —
/// which is also why dock mode-cycle writes while immersive is open have
/// no effect on the marshalled set.
#[test]
fn marshal_all_wakes_every_stream() {
for mode in 0..=2 {
for s in [Stream::Bars, Stream::Waveform, Stream::Energy] {
assert!(stream_wakes(mode, true, s), "mode {mode} stream {s:?}");
}
}
}
/// The hooks against the REAL statics (kept in ONE test so parallel test
/// threads cannot race the shared atomics): open sets the immersive bit +
/// marshal-all; close clears both and restores ACTIVE_MODE from the
/// dock's persisted pref. HANDLES is unset here, so the effective-enable
/// application is a no-op and only the masks/flags move.
#[test]
fn immersive_hooks_switch_and_restore() {
// Dock was on (the common case: dock drives the tap, then immersive
// opens on top).
set_enabled(true);
assert_eq!(ENABLED_MASK.load(Ordering::Relaxed), DOCK_BIT);
immersive_opened();
assert!(MARSHAL_ALL.load(Ordering::Relaxed));
assert_eq!(ENABLED_MASK.load(Ordering::Relaxed), DOCK_BIT | IMMERSIVE_BIT);
// A dock mode-cycle while immersive is open records ACTIVE_MODE but
// the wake table stays all-three (marshal-all short-circuits it).
set_mode(2);
assert!(stream_wakes(2, MARSHAL_ALL.load(Ordering::Relaxed), Stream::Bars));
immersive_closed();
assert!(!MARSHAL_ALL.load(Ordering::Relaxed));
// The dock's bit survived the close (trap 4/20).
assert_eq!(ENABLED_MASK.load(Ordering::Relaxed), DOCK_BIT);
// ACTIVE_MODE was restored from the dock pref, not left at the cycle.
assert_eq!(
ACTIVE_MODE.load(Ordering::Relaxed),
crate::settings_qt::large_spectrum_mode()
);
// Leave the statics as found for any later test in the process.
set_enabled(false);
assert_eq!(ENABLED_MASK.load(Ordering::Relaxed), 0);
}
}