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

+1 -1
View File
@@ -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/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_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", "src/kiosk_nav_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/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_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", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs"],
qml_files: &[
"qml/LoginScreen.qml",
"qml/Main.qml",
+6
View File
@@ -190,6 +190,12 @@ ApplicationWindow {
// importer makes (the whole progress panel, the log, the summary) is
// dropped on the floor with nothing logged on either side.
QbzPlaylistImport.boot()
// Miniplayer (2026-08-03 miniplayer/tray contract, block B1). Booted
// like every other domain singleton, after QbzSession: `boot()` is what
// registers the Qt-thread hop, and without it `mini_bridge::ui()` is a
// SILENT no-op — the mini queue document would never reach QML and
// nothing would be logged on either side.
QbzMini.boot()
// Seed the maximized latch ONCE, imperatively — see its declaration for
// why it must not be a binding on the bridge property.
+9
View File
@@ -80,6 +80,10 @@ mod qconnect_bridge;
// QbzKioskNav singleton over the state machine in kiosk_nav_qt.rs. It has
// no boot() — nothing in Rust ever publishes into it (see its header).
mod kiosk_nav_bridge;
// Miniplayer (2026-08-03 miniplayer/tray contract, block B1): the QbzMini
// singleton — the port of the Slint MiniPlayerState global. Its controller
// half is `mini_qt` below, which is a PLAIN module.
mod mini_bridge;
mod kiosk_nav_qt;
// The kiosk profile itself (the same contract, §8): env/pref resolution, the
// live Kiosk <-> Desktop toggle, and the boot decisions that follow from it.
@@ -220,6 +224,11 @@ mod qconnect_transport_qt;
// §11.5 wiring (init_service + spawns) lives in `on_session_entered` below.
mod qconnect_event_sink_qt;
mod qconnect_qt;
// Miniplayer controller (the same contract, block B1, §4.4.1/§4.7): the queue
// projection, the prefs seeds and the row/geometry arithmetic behind QbzMini.
// Plain module — it declares no #[cxx_qt::bridge], so it must NOT appear in
// build.rs's rust_files.
mod mini_qt;
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
+223
View File
@@ -0,0 +1,223 @@
//! QbzMini — miniplayer domain bridge (the immersive_bridge.rs exemplar:
//! `OnceLock<CxxQtThread>`, `ui()` no-op pre-boot, `boot()` invokable, `impl
//! Default` for construction-seeded values, a custom-WRITE property funnel with
//! an `AtomicBool` mirror beside it).
//!
//! Port of the Slint `MiniPlayerState` global per the 2026-08-03
//! miniplayer/tray contract (`qbz-nix-docs/qt-frontend/2026-08-03-miniplayer-tray-port/00-CONTRACT.md`,
//! rows A-1 … A-9). The controller half — the queue projection, the prefs and
//! the pure arithmetic — is `mini_qt.rs`.
//!
//! **There is no mirror layer and there must never be one** (contract §3.2 /
//! §13-D1). Slint needs ~155 lines of `d.set_x(s.get_x())` because its globals
//! are per-component-tree; in Qt every bridge is a `#[qml_singleton]` in the ONE
//! `QQmlApplicationEngine`, so a second `Window` reads the same `QbzPlayer` /
//! `QbzQueue` / `QbzLyrics` / `QbzShell` objects the shell reads.
//!
//! `surface` and `open` are CUSTOM WRITES. QML reaches them by assignment
//! (`QbzMini.surface = 3`), and the funnel — not the call site — owns the
//! persist, the `OPEN` mirror and the surface-3 queue refresh, so no write path
//! can skip them.
use std::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
#[cxx_qt::bridge]
pub mod qbz_mini {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// Miniplayer-mode flag. CUSTOM WRITE (the `set_open` funnel below), so
// every write — QML's exit paths and the Rust verbs alike — passes
// through one setter that also stores the `OPEN` mirror (contract A-2,
// the exact shape of immersive_bridge.rs's `open`).
#[qproperty(bool, open, READ, WRITE = set_open, NOTIFY)]
// 0 micro · 1 compact · 2 artwork · 3 queue · 4 lyrics — the
// MINI_VIEW_VALUES order minus "remember" (settings_qt.rs:1077).
// CUSTOM WRITE, and it is the ONLY compilable reading of contract A-9:
// that row asks for `#[qinvokable] fn set_surface`, but A-3 fixes the
// property name `surface`, whose cxx-qt-generated setter is ALSO
// `set_surface`/`setSurface` — one QObject cannot carry both. The Rust
// signature is byte-identical to A-9's; only the attribute differs, and
// routing it through the property is strictly stronger because a plain
// assignment cannot bypass the persist. QML therefore writes
// `QbzMini.surface = n` and MUST NOT call `QbzMini.setSurface(n)` —
// that member does not exist on the object.
#[qproperty(i32, surface, READ, WRITE = set_surface, NOTIFY)]
// The static artwork-derived card background (`mini_background_blur`).
// Written by `toggle_background_blur` (contract A-10).
#[qproperty(bool, background_blur)]
// The navigable queue: the current track first, then the FULL
// unfiltered upcoming list. Built by `mini_qt::mini_queue_doc` and
// published from the ONE queue funnel (`queue_qt::publish`), never from
// a second fetch (§7-M1). Full-shape default, never "{}" (trap 15).
#[qproperty(QString, mini_queue_json)]
// THE PERSISTED EXPANDED GEOMETRY, exposed to QML (contract A-6b).
// The `Window` is QML-created and Rust cannot reach a QQuickWindow, so
// QML owns the sizing and needs the numbers; `save_geometry` writes the
// clamped pair back here, so a condensed->expanded switch inside one
// session restores the size the user last dragged without a re-read.
// Seeded at CONSTRUCTION, not at boot(): the size expression is a
// binding and the first frame is already too late for a post-boot push.
#[qproperty(f32, mini_width)]
#[qproperty(f32, mini_height)]
type QbzMini = super::QbzMiniRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY domain
/// singleton; only QbzSession.boot also fires crate::on_boot).
/// **Without it every `mini_bridge::ui()` is a SILENT no-op** — the
/// bridge never errors, it just never repaints.
#[qinvokable]
fn boot(self: Pin<&mut QbzMini>);
}
// The custom WRITE targets. NOT qinvokables — QML reaches them by writing
// `QbzMini.open` / `QbzMini.surface`, never by name. (cxx-qt custom-setter
// pattern: the property's generated setter is replaced by this method,
// which owns the store + notify + the side effects.)
unsafe extern "RustQt" {
fn set_open(self: Pin<&mut QbzMini>, value: bool);
fn set_surface(self: Pin<&mut QbzMini>, surface: i32);
}
impl cxx_qt::Threading for QbzMini {}
}
use qbz_mini::QbzMini;
/// Rust side of the mini bridge (plain storage, phase-1 pattern).
pub struct QbzMiniRust {
open: bool,
surface: i32,
background_blur: bool,
mini_queue_json: QString,
mini_width: f32,
mini_height: f32,
}
impl Default for QbzMiniRust {
fn default() -> Self {
// Read here, not in boot(): QML singletons instantiate lazily and the
// window's size + surface bindings evaluate on its first frame, which
// can precede QbzMini.boot(). A post-boot seed would leave the first
// frame decided against cxx-qt's derived zeroes (trap 31).
Self {
open: false,
surface: crate::mini_qt::initial_surface(),
background_blur: crate::mini_qt::initial_background_blur(),
mini_queue_json: QString::from(crate::mini_qt::MINI_QUEUE_EMPTY),
mini_width: crate::mini_qt::initial_width(),
mini_height: crate::mini_qt::initial_height(),
}
}
}
// ---------------------------------------------------------------------------
// The UI hop (immersive_bridge.rs pattern)
// ---------------------------------------------------------------------------
static QT_THREAD: OnceLock<CxxQtThread<QbzMini>> = OnceLock::new();
/// Queue a mini-bridge mutation onto the Qt event loop (no-op before boot
/// registers the thread).
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzMini>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
/// Rust-side mirror of the `open` property, written inside `apply_open` — the
/// funnel EVERY write passes through — so it is exact. Read by callers that run
/// on ANOTHER singleton or on a worker thread and therefore cannot reach this
/// QObject's properties: the hotkeys pipeline's mini/main context split and the
/// surface-3 queue refresh below (contract A-2).
static OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub(crate) fn is_open() -> bool {
OPEN.load(std::sync::atomic::Ordering::SeqCst)
}
/// The single funnel behind every `open` write: store, mirror, THEN notify.
///
/// The mirror is stored BEFORE the notify on purpose. `open_changed()` emits
/// synchronously, so any QML `onOpenChanged` handler runs inside that call —
/// and anything it reaches that asks `is_open()` (the queue refresh below does)
/// would read the stale value if the store came last. `immersive_bridge.rs`'s
/// `apply_open` has the same two lines in the other order; the window there is
/// currently unreachable, so it is left alone rather than widened into this
/// commit.
fn apply_open(mut this: Pin<&mut QbzMini>, value: bool) {
use cxx_qt::CxxQtType as _;
if this.open == value {
return;
}
this.as_mut().rust_mut().open = value;
OPEN.store(value, std::sync::atomic::Ordering::SeqCst);
this.as_mut().open_changed();
}
/// The single funnel behind every `surface` write: store, notify, persist, and
/// — on the queue surface — kick the queue funnel.
///
/// The persist and the refresh run on EVERY write, including a same-value one,
/// which is 1:1 with the reference (`miniplayer.rs:302-315` has no equality
/// guard: it saves the pref and re-kicks `spawn_queue_refresh` unconditionally).
/// Only the change NOTIFICATION is suppressed when nothing moved, which is
/// ordinary QML property semantics and not a behaviour the reference has an
/// analogue for. Re-asserting surface 3 therefore still refreshes the queue.
fn apply_surface(mut this: Pin<&mut QbzMini>, surface: i32) {
use cxx_qt::CxxQtType as _;
if this.surface != surface {
this.as_mut().rust_mut().surface = surface;
this.as_mut().surface_changed();
}
crate::mini_qt::save_surface(surface);
if surface == 3 {
refresh_queue();
}
}
/// Port of `miniplayer.rs`'s `spawn_queue_refresh` (`:631-641`): gated on the
/// mini being open, and routed through THE queue funnel — `queue_qt::publish`,
/// the one place that reads queue state — never through a second fetch of its
/// own (§7-M1). The funnel's identical-document guards make it a no-op when
/// nothing moved, so the cost of a redundant call is one build, no Qt hop.
fn refresh_queue() {
if !is_open() {
return;
}
let runtime = crate::app();
crate::spawn(async move { crate::queue_qt::publish(&runtime).await });
}
impl qbz_mini::QbzMini {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] mini Qt thread already registered");
}
}
/// The custom WRITE target of the `open` property.
pub fn set_open(self: Pin<&mut Self>, value: bool) {
apply_open(self, value);
}
/// The custom WRITE target of the `surface` property (contract A-9):
/// sets the property, persists `mini_surface`, and refreshes the queue on
/// surface 3.
pub fn set_surface(self: Pin<&mut Self>, surface: i32) {
apply_surface(self, surface);
}
}
+372
View File
@@ -0,0 +1,372 @@
//! Miniplayer controller — the Slint-free half of `crates/qbz/src/miniplayer.rs`
//! that is pure data: the navigable queue projection, the prefs seeds and the
//! two pieces of arithmetic the window's verbs need (the resize guards and the
//! queue-row -> upcoming-index mapping).
//!
//! Port per the 2026-08-03 miniplayer/tray contract (§4.4.1 / §4.7, rows A-17
//! and A-18). **Plain module, NOT a bridge** — it declares no
//! `#[cxx_qt::bridge]`, so it must NOT appear in `build.rs`'s `rust_files`.
//! The QML-facing object is `QbzMini` in `mini_bridge.rs`.
//!
//! Two things this file deliberately does NOT do:
//!
//! - It never fetches queue state. `mini_queue_doc` is a pure projection of the
//! `QueueState` the ONE existing funnel (`queue_qt::publish`) already read —
//! contract §7-M1, "no second poll loop and no second queue fetch".
//! - It never re-implements a row helper. Titles go through
//! `queue_qt::display_title` and durations through `queue_qt::fmt_duration`
//! (both widened to `pub(crate)` by contract row A-36); a private copy here
//! would be the third-copy failure `settings_qt.rs`'s accessor block exists
//! to prevent.
use qbz_models::{QueueState, QueueTrack};
use serde::Serialize;
// --- ui_prefs.json keys (co-owned with the shipping Slint app; the typed
// struct that declares them is `crates/qbz/src/ui_prefs.rs:552-565`). Every
// write goes through `settings_qt::save_pref`'s additive single-key patch —
// a read-serialize-write of a typed struct would drop every key the Qt side
// does not know (§15 trap 16).
const KEY_SURFACE: &str = "mini_surface";
const KEY_WIDTH: &str = "mini_width";
const KEY_HEIGHT: &str = "mini_height";
const KEY_BACKGROUND_BLUR: &str = "mini_background_blur";
const KEY_DEFAULT_VIEW: &str = "mini_default_view";
/// Defaults, from `crates/qbz/src/ui_prefs.rs:677-688`.
const DEFAULT_SURFACE: i32 = 2;
const DEFAULT_WIDTH: f32 = 380.0;
const DEFAULT_HEIGHT: f32 = 540.0;
/// Resize-persistence guards and floors (`miniplayer.rs:455`, `:459`, `:461-462`).
/// **Note the two different numbers**: 320 is the mid-transition-frame filter,
/// 420 is the stored floor. Both are behavioural (§15 trap 17).
const EXPANDED_SURFACE_FLOOR: i32 = 2;
const TRANSITION_HEIGHT_FILTER: f32 = 320.0;
const MIN_PERSISTED_WIDTH: f32 = 340.0;
const MIN_PERSISTED_HEIGHT: f32 = 420.0;
/// Full-shape default for `QbzMini.miniQueueJson` — never `"{}"`, because QML
/// reads `.rows.length` in the pre-publish frame (trap 15; the coverflow
/// precedent is `queue_bridge.rs`'s `{"index":0,"tracks":[]}`).
pub(crate) const MINI_QUEUE_EMPTY: &str = r#"{"currentId":"","rows":[]}"#;
// ---------------------------------------------------------------------------
// The queue projection (contract §4.4.1, row A-17)
// ---------------------------------------------------------------------------
/// One mini queue row. Deliberately NO artwork field: the reference passes an
/// empty prior-image map (`crates/qbz/src/queue.rs:1032-1033`) and the rows
/// render a POSITION NUMBER instead — "the mini must not have MORE than the
/// main" (§15 trap 11).
#[derive(Serialize)]
struct MiniQueueRow {
/// Decimal string, like every other projection in this crate
/// (`QueueRow.id`, `CoverflowTrack.id`) — QML compares it against
/// `currentId` as a string.
id: String,
title: String,
artist: String,
/// Pre-formatted `m:ss` (`queue_qt::fmt_duration`).
duration: String,
/// The model field is `parental_warning`; the document key is `explicit`,
/// mirroring `QueueRow.explicit` and the reference's `item.explicit`.
explicit: bool,
}
#[derive(Serialize)]
struct MiniQueueDoc {
/// The row-highlight key: the reference compares `item.id` against
/// `MiniPlayerState.current-track-id`, NOT a per-row `playing` flag
/// (`MiniQueueSurface.slint:42`). `""` when there is no current track.
#[serde(rename = "currentId")]
current_id: String,
rows: Vec<MiniQueueRow>,
}
fn row_from(track: &QueueTrack) -> MiniQueueRow {
MiniQueueRow {
id: track.id.to_string(),
title: crate::queue_qt::display_title(track),
artist: track.artist.clone(),
duration: crate::queue_qt::fmt_duration(track.duration_secs),
explicit: track.parental_warning,
}
}
/// Build the miniplayer's self-contained NAVIGABLE queue document: the current
/// track first (only if there IS one), then the FULL upcoming list — not capped
/// at 20, not paginated, not de-duplicated.
///
/// 1:1 with `crate::queue::mini_queue_items` (`crates/qbz/src/queue.rs:1031-1042`).
/// The reference's own doc comment claims a de-duplication it does not perform
/// (`state.slint:4509-4510`); the port copies the CODE, not the comment
/// (contract §12-P8).
pub(crate) fn mini_queue_doc(state: &QueueState) -> String {
let mut rows: Vec<MiniQueueRow> = Vec::with_capacity(1 + state.upcoming.len());
if let Some(t) = state.current_track.as_ref() {
rows.push(row_from(t));
}
for t in state.upcoming.iter() {
rows.push(row_from(t));
}
let doc = MiniQueueDoc {
current_id: state
.current_track
.as_ref()
.map(|t| t.id.to_string())
.unwrap_or_default(),
rows,
};
serde_json::to_string(&doc).unwrap_or_else(|_| MINI_QUEUE_EMPTY.to_string())
}
// ---------------------------------------------------------------------------
// Row activation arithmetic (contract §4.4.3 — the off-by-one this port FIXES)
// ---------------------------------------------------------------------------
/// What a mini queue row activation resolves to, once the remote-first ladder
/// has declined it. Kept separate from the invokable so the mapping is
/// testable without a runtime (UT-2 is the lock on the §4.4.3 fix).
// Consumed by `QbzMini.queue_play` (contract row A-13), which lands with the
// queue surface rather than with this projection.
#[allow(dead_code)]
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum MiniRowTarget {
/// Row 0 WITH a current track: resume if paused, never restart
/// (`miniplayer.rs:379-388`).
Resume,
/// A 0-based index into the UNFILTERED `upcoming` list — the domain
/// `queue_qt::play_upcoming_flat` takes (`queue_bridge.rs:75-78`).
/// NEVER the page-local `queue_play_upcoming` (§15 trap 10).
Upcoming(i32),
}
/// Map a mini queue row index onto its action.
///
/// **The reference is wrong here and this port fixes it (§4.4.3).**
/// `mini_queue_items` pushes the current track only `if let Some(t)`, but the
/// reference's activation treats `idx == 0` as the current track
/// unconditionally — so with no current track, clicking row 0 (which IS
/// `upcoming[0]`) merely toggles play. Here the shift is driven by whether the
/// document actually carries a current row.
// Same consumer as `MiniRowTarget` (contract row A-13).
#[allow(dead_code)]
pub(crate) fn queue_row_target(has_current: bool, row: i32) -> MiniRowTarget {
if has_current {
if row == 0 {
MiniRowTarget::Resume
} else {
MiniRowTarget::Upcoming(row - 1)
}
} else {
MiniRowTarget::Upcoming(row)
}
}
// ---------------------------------------------------------------------------
// Geometry (contract §4.7)
// ---------------------------------------------------------------------------
/// The resize-persist decision, as a pure function so the two guards and the
/// two floors are testable without a window (UT-3).
///
/// `None` = do not persist. `Some((w, h))` = the clamped pair to store.
/// Port of `miniplayer.rs:455-463`: persist only while an EXPANDED surface is
/// shown (micro/compact sizes are constants), ignore sub-320 heights (a
/// mid-transition frame), and clamp what is stored to 340 x 420.
// Consumed by `QbzMini.save_geometry` (contract row A-14), which lands with the
// window's resize debounce rather than with this projection.
#[allow(dead_code)]
pub(crate) fn geometry_to_persist(surface: i32, width: f32, height: f32) -> Option<(f32, f32)> {
if surface < EXPANDED_SURFACE_FLOOR || height < TRANSITION_HEIGHT_FILTER {
return None;
}
Some((
width.max(MIN_PERSISTED_WIDTH),
height.max(MIN_PERSISTED_HEIGHT),
))
}
// ---------------------------------------------------------------------------
// Prefs (contract §4.7) — the seeds `QbzMiniRust::Default` reads and the
// writes the invokables make. All through the `settings_qt` accessors.
// ---------------------------------------------------------------------------
/// The surface to open on: the per-user default-view pref, or the last
/// persisted surface when that pref is `"remember"`. Pure half of
/// `resolve_initial_surface` (`miniplayer.rs:110-119`), split out so the
/// mapping is testable without touching the filesystem.
fn surface_for_default_view(default_view: &str, last_surface: i32) -> i32 {
match default_view {
"micro" => 0,
"compact" => 1,
"artwork" => 2,
"queue" => 3,
"lyrics" => 4,
// "remember" / unknown
_ => last_surface,
}
}
pub(crate) fn initial_surface() -> i32 {
surface_for_default_view(
&crate::settings_qt::pref_str(KEY_DEFAULT_VIEW, "remember"),
crate::settings_qt::pref_i32(KEY_SURFACE, DEFAULT_SURFACE),
)
}
pub(crate) fn initial_background_blur() -> bool {
crate::settings_qt::pref_bool(KEY_BACKGROUND_BLUR, false)
}
pub(crate) fn initial_width() -> f32 {
crate::settings_qt::pref_f32(KEY_WIDTH, DEFAULT_WIDTH)
}
pub(crate) fn initial_height() -> f32 {
crate::settings_qt::pref_f32(KEY_HEIGHT, DEFAULT_HEIGHT)
}
/// Persist the last surface. A JSON NUMBER, not a string: `ui_prefs.json` is
/// co-owned with the Slint app, whose typed struct declares `mini_surface` as
/// i32 (`ui_prefs.rs:553-554`) — a string write would poison its whole-document
/// load.
pub(crate) fn save_surface(surface: i32) {
crate::settings_qt::save_pref(KEY_SURFACE, serde_json::Value::Number(surface.into()));
}
#[cfg(test)]
mod tests {
use super::*;
use qbz_models::RepeatMode;
/// QueueTrack has no Default impl — every field is spelled out
/// (`queue_qt.rs`'s own test helper carries the same note).
fn track(id: u64, title: &str, version: Option<&str>, secs: u64, explicit: bool) -> QueueTrack {
QueueTrack {
id,
title: title.to_string(),
version: version.map(str::to_string),
artist: format!("artist-{id}"),
album: String::new(),
album_version: None,
duration_secs: secs,
artwork_url: None,
hires: false,
bit_depth: None,
sample_rate: None,
is_local: false,
album_id: None,
artist_id: None,
streamable: true,
source: None,
parental_warning: explicit,
source_item_id_hint: None,
context_kind: None,
context_id: None,
}
}
/// QueueState has no Default impl either.
fn state(current: Option<QueueTrack>, upcoming: Vec<QueueTrack>) -> QueueState {
QueueState {
current_index: current.as_ref().map(|_| 0),
total_tracks: upcoming.len() + usize::from(current.is_some()),
current_track: current,
upcoming,
history: Vec::new(),
shuffle: false,
repeat: RepeatMode::Off,
stop_after_track_id: None,
manual_next_count: 0,
}
}
/// UT-1 (contract §11).
#[test]
fn mini_queue_doc_is_current_first_then_full_upcoming() {
// 1 current + 60 upcoming: no 40-row cap (PAGE_SIZE is the panel's, not
// the mini's), current first, currentId set.
let upcoming: Vec<QueueTrack> = (1..=60)
.map(|i| track(100 + i, &format!("up-{i}"), None, 187, false))
.collect();
let doc: serde_json::Value =
serde_json::from_str(&mini_queue_doc(&state(
Some(track(7, "Now", Some("Remastered"), 187, true)),
upcoming.clone(),
)))
.unwrap();
let rows = doc["rows"].as_array().unwrap();
assert_eq!(rows.len(), 61);
assert_eq!(rows[0]["id"], "7");
assert_eq!(doc["currentId"], "7");
// Titles go through display_title (version suffix).
assert_eq!(rows[0]["title"], "Now (Remastered)");
// Durations go through fmt_duration.
assert_eq!(rows[0]["duration"], "3:07");
// explicit is carried (from QueueTrack::parental_warning).
assert_eq!(rows[0]["explicit"], true);
assert_eq!(rows[1]["id"], "101");
assert_eq!(rows[1]["explicit"], false);
// No current track: 60 rows and an EMPTY currentId.
let doc: serde_json::Value =
serde_json::from_str(&mini_queue_doc(&state(None, upcoming))).unwrap();
assert_eq!(doc["rows"].as_array().unwrap().len(), 60);
assert_eq!(doc["currentId"], "");
}
#[test]
fn mini_queue_empty_default_is_full_shape() {
// QML reads `.rows.length` in the pre-publish frame — "{}" would throw.
let doc: serde_json::Value = serde_json::from_str(MINI_QUEUE_EMPTY).unwrap();
assert!(doc.get("rows").is_some_and(|r| r.is_array()));
assert_eq!(doc["currentId"], "");
// An empty queue serializes to exactly the default document.
assert_eq!(mini_queue_doc(&state(None, Vec::new())), MINI_QUEUE_EMPTY);
}
/// UT-2 (contract §11) — the lock on the §4.4.3 off-by-one fix.
#[test]
fn mini_queue_row_maps_to_upcoming_index() {
// With a current track: row 0 resumes, row n plays upcoming[n - 1].
assert_eq!(queue_row_target(true, 0), MiniRowTarget::Resume);
assert_eq!(queue_row_target(true, 1), MiniRowTarget::Upcoming(0));
assert_eq!(queue_row_target(true, 7), MiniRowTarget::Upcoming(6));
// WITHOUT one, row 0 IS upcoming[0] — the reference toggles play here.
assert_eq!(queue_row_target(false, 0), MiniRowTarget::Upcoming(0));
assert_eq!(queue_row_target(false, 1), MiniRowTarget::Upcoming(1));
assert_eq!(queue_row_target(false, 7), MiniRowTarget::Upcoming(7));
}
/// UT-3 (contract §11).
#[test]
fn mini_geometry_guards_and_floors() {
// Micro and compact are constant sizes — never persisted.
assert_eq!(geometry_to_persist(0, 380.0, 57.0), None);
assert_eq!(geometry_to_persist(1, 380.0, 178.0), None);
assert_eq!(geometry_to_persist(0, 900.0, 900.0), None);
// Expanded, but a mid-transition frame: 319 is filtered, 320 is not.
assert_eq!(geometry_to_persist(2, 400.0, 319.0), None);
assert_eq!(geometry_to_persist(2, 400.0, 320.0), Some((400.0, 420.0)));
// The stored pair is clamped to (max(w, 340), max(h, 420)) — note the
// floor (420) is NOT the transition filter (320).
assert_eq!(geometry_to_persist(2, 300.0, 400.0), Some((340.0, 420.0)));
assert_eq!(geometry_to_persist(4, 500.0, 700.0), Some((500.0, 700.0)));
}
#[test]
fn default_view_pref_maps_to_the_slint_surface_table() {
// miniplayer.rs:110-119 — "remember"/unknown fall back to the last
// persisted surface; every pinned key wins over it.
assert_eq!(surface_for_default_view("micro", 3), 0);
assert_eq!(surface_for_default_view("compact", 3), 1);
assert_eq!(surface_for_default_view("artwork", 3), 2);
assert_eq!(surface_for_default_view("queue", 0), 3);
assert_eq!(surface_for_default_view("lyrics", 0), 4);
assert_eq!(surface_for_default_view("remember", 1), 1);
assert_eq!(surface_for_default_view("", 4), 4);
}
}
+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
+23 -4
View File
@@ -923,13 +923,29 @@ pub fn pref_json(key: &str) -> Option<serde_json::Value> {
.and_then(|v| v.get(key).cloned())
}
/// 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.
/// f32 reader WITH a default — same additive single-key discipline as
/// `pref_bool`/`pref_str`/`pref_i32`. Added for the miniplayer geometry keys
/// (`mini_width`/`mini_height`), which the Slint app declares as f32 and
/// therefore writes as JSON numbers (`crates/qbz/src/ui_prefs.rs:556-559`).
/// `read_pref_f32` below returns an `Option` and has no default, so folding a
/// default into each call site would be the third private copy of one read the
/// accessor block exists to prevent (see that function's own comment).
pub fn pref_f32(key: &str, default: f32) -> f32 {
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_f64))
.map(|n| n as f32)
.unwrap_or(default)
}
/// Read one ui_prefs key as an f32. The crate had no public reader — theme_qt
/// kept a private one — so the volume restore would otherwise have grown a
/// third private copy of the same read (the exact duplication the write
/// discipline above exists to prevent).
/// discipline below exists to prevent).
pub(crate) fn read_pref_f32(key: &str) -> Option<f32> {
let path = prefs_path()?;
let text = std::fs::read_to_string(path).ok()?;
@@ -937,6 +953,9 @@ pub(crate) fn read_pref_f32(key: &str) -> Option<f32> {
doc.get(key)?.as_f64().map(|v| v as f32)
}
/// 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.
pub fn save_pref(key: &str, value: serde_json::Value) {
update_prefs(|doc| {
doc.insert(key.to_string(), value);