diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 3ed180917..ef1740b05 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -51,7 +51,7 @@ fn main() { // (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain // modules and must NOT be listed — only files that declare a // #[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/LoginScreen.qml", "qml/Main.qml", diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml index 10af4029c..1ed873c84 100644 --- a/crates/qbz-qt/qml/Main.qml +++ b/crates/qbz-qt/qml/Main.qml @@ -117,6 +117,12 @@ ApplicationWindow { QbzQueue.boot() QbzHome.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() QbzLibrary.boot() QbzAlbum.boot() diff --git a/crates/qbz-qt/src/ambient_qt.rs b/crates/qbz-qt/src/ambient_qt.rs index c3361e224..68da0e73c 100644 --- a/crates/qbz-qt/src/ambient_qt.rs +++ b/crates/qbz-qt/src/ambient_qt.rs @@ -177,6 +177,47 @@ fn hex(c: (u8, u8, u8)) -> String { 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)] mod tests { use super::*; @@ -231,6 +272,54 @@ mod tests { assert!(dist < 30.0, "mono cover: secondary hue {sh} vs primary {ph}"); 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 bytes = std::fs::read(&path).ok()?; let img = image::load_from_memory(&bytes).ok()?; + let rgba = img.to_rgba8(); let tiny = image::imageops::resize( - &img.to_rgba8(), + &rgba, 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))) + // 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 .ok() .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}"); crate::shell_bridge::ui(move |mut b| { 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())); 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())); + } + }); } }); } diff --git a/crates/qbz-qt/src/immersive_bridge.rs b/crates/qbz-qt/src/immersive_bridge.rs new file mode 100644 index 000000000..aa33e6ce1 --- /dev/null +++ b/crates/qbz-qt/src/immersive_bridge.rs @@ -0,0 +1,353 @@ +//! QbzImmersive — immersive-mode domain bridge (viz_bridge.rs exemplar: +//! OnceLock, `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> = 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"); + } +} diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 7a8651529..1b2a58ef3 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -26,6 +26,10 @@ mod player_bridge; mod queue_bridge; mod home_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 library_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 }); } +/// 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) { let runtime = app(); spawn(async move { queue_qt::remove_upcoming(&runtime, index.max(0) as usize).await }); diff --git a/crates/qbz-qt/src/queue_bridge.rs b/crates/qbz-qt/src/queue_bridge.rs index a5690b237..ca8f2be10 100644 --- a/crates/qbz-qt/src/queue_bridge.rs +++ b/crates/qbz-qt/src/queue_bridge.rs @@ -38,6 +38,15 @@ pub mod qbz_queue { // + pagination + #442 section markers). Supersedes `queueModel`. #[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) ------------------------------------ // Kept OUT of `queue_json`: the countdown reformats every second and // the document is re-parsed by three QML files on every change, so @@ -63,6 +72,12 @@ pub mod qbz_queue { /// Row actions. #[qinvokable] 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] fn queue_remove_upcoming(self: Pin<&mut QbzQueue>, index: i32); #[qinvokable] @@ -113,6 +128,7 @@ type QListQVariant = QList; pub struct QbzQueueRust { queue_model: QListQVariant, queue_json: QString, + coverflow_json: QString, sleep_active: bool, sleep_remaining: QString, } @@ -122,6 +138,9 @@ impl Default for QbzQueueRust { Self { queue_model: QListQVariant::default(), 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_remaining: QString::default(), } @@ -161,6 +180,10 @@ impl qbz_queue::QbzQueue { 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) { crate::queue_remove_upcoming(index); } diff --git a/crates/qbz-qt/src/queue_qt.rs b/crates/qbz-qt/src/queue_qt.rs index 3b3e83335..48df37c44 100644 --- a/crates/qbz-qt/src/queue_qt.rs +++ b/crates/qbz-qt/src/queue_qt.rs @@ -11,9 +11,9 @@ //! 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` //! 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 -//! (PARITY-DEBT #31), so there is nothing to feed. +//! +//! The immersive coverflow's flat model (the old second owed port) is now +//! fed by `publish_coverflow` below (immersive-port contract §4.4). //! //! Row favorite seeds from the phase-5 library feed (Slint uses fav_cache — //! same truth). @@ -213,6 +213,112 @@ fn paginate(total: usize, requested_page: usize) -> (usize, usize, usize, usize) (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, usize) { + let mut flat: Vec = 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> = 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 = 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 /// display order (search filter + pagination applied) — the hoisted port of /// `crates/qbz/src/queue.rs::resolve_upcoming_index`, resolved once per action @@ -393,6 +499,9 @@ pub async fn publish(runtime: &Arc>) { .filter_map(|r| canonical_u64(&r.id)) .collect(); 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. // 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 @@ -445,6 +554,18 @@ pub async fn play_upcoming(runtime: &Arc>, 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>, 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>, page_index: usize) { 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"); @@ -718,3 +839,114 @@ pub async fn toggle_favorite( } 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(¤t), &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)); + } +} diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 8bb40696f..9389d2936 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -893,6 +893,22 @@ pub fn pref_str(key: &str, default: &str) -> 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::(&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 /// setter in this file funnels through, so they all inherit `update_prefs`' /// atomic rename and its refusal to rebuild an unparsable document. diff --git a/crates/qbz-qt/src/viz_qt.rs b/crates/qbz-qt/src/viz_qt.rs index f1ff4376e..847eca864 100644 --- a/crates/qbz-qt/src/viz_qt.rs +++ b/crates/qbz-qt/src/viz_qt.rs @@ -25,13 +25,15 @@ //! 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 //! 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 //! buffer. It touches none of the device/stream init (CLAUDE.md "Audio //! 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::thread::Thread; @@ -61,21 +63,24 @@ impl VizSink for QtVizSink { // The producer emits several variants per FFT frame (spectral, bars, // energy, transient, waveform). Waking the drain on each of them would // cost 3-4 wakeups per frame for one useful publish, so only the - // stream the visible mode renders signals. The other cells are still - // latched so a mode switch has a frame ready immediately. + // stream the visible mode renders signals — UNLESS the immersive + // 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 all = MARSHAL_ALL.load(Ordering::Relaxed); let wake = match frame { VizFrame::Viz16(b) => { *self.cells.bars.lock().unwrap() = Some(b); - !matches!(mode, 1 | 2) + stream_wakes(mode, all, Stream::Bars) } VizFrame::Wave256x2(b) => { *self.cells.waveform.lock().unwrap() = Some(b); - mode == 1 + stream_wakes(mode, all, Stream::Waveform) } VizFrame::Energy5(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 // producer computes the spectral ribbon only for the shader scenes). @@ -97,12 +102,78 @@ static HANDLES: OnceLock = OnceLock::new(); /// frame can never find itself with nowhere to signal. static DRAIN_THREAD: OnceLock = OnceLock::new(); /// 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); /// 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 /// 512-float waveform into a QList 30 times a second would be pure waste. 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 /// 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 @@ -122,11 +193,29 @@ fn to_qlist(values: &[f32]) -> QList { list } -/// Take the pending frame of the stream the visible mode renders (if any) and -/// hop it onto the Qt thread. Each cell's guard is released by the end of its -/// `let` statement — the mutex is NEVER held across the `viz_bridge::ui` hop, -/// so the producer never blocks on the drain. +/// Take the pending frame(s) of the stream(s) the current marshalling set +/// renders and hop them onto the Qt thread. Each cell's guard is released by +/// the end of its `let` statement — the mutex is NEVER held across the +/// `viz_bridge::ui` hop, so the producer never blocks on the drain. 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) { 1 => { 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)"); } -/// Capture gate — the `QbzViz.setEnabled` invokable. Toggles the tap, wakes the -/// parked producer/drain, and (when turning off) leaves the last frame on -/// screen frozen, exactly like the Slint handler. +/// Capture gate — the `QbzViz.setEnabled` invokable. Drives the DOCK source +/// bit (§4.2a): both existing call sites (`viz_bridge.rs:137`, `main.rs:1518`) +/// 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) { + 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 { return; }; @@ -217,6 +333,31 @@ pub fn set_enabled(on: bool) { // 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 /// install time (from the persisted pref) and on every mode cycle. 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 = [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); + } +}