feat(qt): QbzMini bridge, mini queue projection and mini prefs

Block B1 of the miniplayer/tray contract. No UI: this lands the state the
five surfaces will bind to, so B2 can mount a window against a bridge that
is already correct.

src/mini_bridge.rs (new) — the QbzMini singleton. Six properties (open,
surface, backgroundBlur, miniQueueJson, miniWidth, miniHeight), boot(), and
the two custom-WRITE funnels. `open` mirrors into a static AtomicBool with
is_open() beside it, because the hotkeys pipeline runs on another singleton
and cannot read this QObject's properties.

src/mini_qt.rs (new) — the plain projection module: mini_queue_doc (current
track first, then the FULL unfiltered upcoming, no page cap, no art field),
the row-to-upcoming-index mapping, the geometry guards, and the pref seeds.
Five unit tests, three of them the contract's UT-1/UT-2/UT-3.

src/queue_qt.rs — display_title and fmt_duration widened to pub(crate) so
mini_qt reuses them instead of growing a third private copy; new
publish_mini_queue with its own identical-document guard, published from THE
queue funnel beside publish_coverflow. No second queue fetch.

src/settings_qt.rs — pref_f32, the reader with a default that the two
geometry keys need. Also reattaches three doc lines that sat on
read_pref_f32 while describing save_pref, which had none.

src/main.rs, build.rs, qml/Main.qml — the registrations: two mod lines
(mini_qt is a plain module and is deliberately absent from rust_files), the
bridge in rust_files, and QbzMini.boot() after QbzSession.boot().

Two deviations worth naming. Contract row A-9 asks for an invokable
set_surface, but row A-3 fixes a property named surface whose generated
setter is also set_surface — one QObject cannot carry both, so the funnel
sits on the property. QML assigns `QbzMini.surface = n`; there is no
setSurface member. And the surface write persists and re-kicks the queue
refresh unconditionally, 1:1 with the reference, suppressing only the
change notification when nothing moved.

cc
This commit is contained in:
vicrodh committed 2026-08-04 02:28:57 -06:00
1 parent 00eaa00027
commit 0d6e7d3498
7 files changed
+685 -8

No files matched your search

+51 -3
View File
@@ -112,15 +112,17 @@ static VIEW: Mutex<ViewState> = Mutex::new(ViewState {
page: 0,
});
/// queue.rs `display_title` (version suffix).
fn display_title(track: &QueueTrack) -> String {
/// queue.rs `display_title` (version suffix). `pub(crate)` for `mini_qt`'s row
/// projection, which must not grow a private copy of it.
pub(crate) fn display_title(track: &QueueTrack) -> String {
match track.version.as_deref().filter(|v| !v.is_empty()) {
Some(version) => format!("{} ({version})", track.title),
None => track.title.clone(),
}
}
fn fmt_duration(secs: u64) -> String {
/// `pub(crate)` for the same reason as `display_title`.
pub(crate) fn fmt_duration(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
@@ -319,6 +321,49 @@ fn publish_coverflow(state: &qbz_models::QueueState) {
});
}
/// Last miniplayer queue document handed to Qt — the mini's OWN
/// identical-document guard, same discipline as `LAST_COVERFLOW_JSON` above.
/// The mini queue surface is scrollable, and a post that carries an unchanged
/// document resets its scroll for nothing (miniplayer/tray contract §7-M9).
///
/// **Correcting §7-M9's stated reason, which does not hold in this tree:** it
/// says the mini is fed by "a pump that ticks every second". `publish` is not
/// per-tick — its three call sites inside `start_poll_loop` are all edge-gated
/// (`playback_qt.rs`: the peer-track-id edge, the track-id edge, and the
/// stop-after arm), plus the explicit mutation sites. So the guard earns its
/// place on the mutation path (a reorder or a remove that leaves the mini
/// document identical), not on a 1 Hz republish that does not exist.
///
/// **Known asymmetry with its sibling, stated rather than hidden (§8.1):**
/// `publish_coverflow` has `COVERFLOW_CACHE` and skips the serialize itself
/// when the queue sequence is unchanged; this one serializes first and compares
/// after. Accepted here because `publish` is edge-triggered, so the cost is one
/// build per real queue change, not per frame. If `publish` ever becomes
/// unconditional, this is the function that needs the cache.
static LAST_MINI_QUEUE_JSON: Mutex<String> = Mutex::new(String::new());
/// Build + publish the miniplayer's navigable queue document
/// (`{"currentId":"…","rows":[…]}` — `mini_qt::mini_queue_doc`).
///
/// Its own function rather than an inline block, because the guard's early
/// `return` aborts whatever encloses it: inlined into `publish`, an unchanged
/// mini document would skip the panel's own post below and silently freeze the
/// queue panel. Runs on EVERY publish BEFORE the queueJson dedup, for the same
/// reason `publish_coverflow` does — a change past the current page can leave
/// queueJson byte-identical while the mini model moved.
fn publish_mini_queue(state: &qbz_models::QueueState) {
let doc = crate::mini_qt::mini_queue_doc(state);
let mut last = LAST_MINI_QUEUE_JSON.lock().unwrap();
if *last == doc {
return;
}
last.clear();
last.push_str(&doc);
crate::mini_bridge::ui(move |mut b| {
b.as_mut().set_mini_queue_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
@@ -502,6 +547,9 @@ pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
// The coverflow doc publishes on EVERY publish, BEFORE the queueJson
// dedup below — see publish_coverflow for why.
publish_coverflow(&state);
// The miniplayer's navigable model rides the same funnel and the same
// before-the-dedup placement, with its own guard (see publish_mini_queue).
publish_mini_queue(&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