feat(qt): immersive bridge, viz two-source enable, coverflow payload (B1)
km
This commit is contained in:
9 files changed
+1010
-21
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¤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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user