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:
7 files changed
+685
-8
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user