feat(qbz-qt): phase 2 — app shell with 1:1 Slint visual parity

HeaderBar (42px, sidebar cycle, back/forward, centered search replica,
offline badge + flyout, app menu), three-state Sidebar (open 240 / mini
64 / closed 0, 160ms animation, nav rows with exact msgids), NowPlaying
bar SMALL (3px three-color seekbar, art+title+time left, transport
center, quality/volume/queue-toggle right, exact empty state), Queue
panel (300px animated column, Queue/History tabs, exact empty states).
Nav history stack + NowPlayingModel with empty-state defaults behind
new bridge properties/invokables (transport log-and-noop until
playback lands). Icons as pre-baked tinted SVG variants (ColorOverlay
is broken on the software path); Inter fonts bundled; full dark theme
tokens ported from the Slint foundation files.

km
This commit is contained in:
vicrodh committed 2026-07-27 12:56:38 -06:00
1 parent df7eb3588e
commit 744bce3819
189 files changed
+3206 -135

No files matched your search

+205 -11
View File
@@ -1,22 +1,28 @@
//! The single Qt-side bridge object for the POC.
//!
//! One `QbzBridge` QObject is registered as a QML SINGLETON (`QbzBridge.*`
//! in QML). All session/login/offline state the QML needs lives in its
//! properties; user actions come in as invokables. Invokable bodies NEVER
//! block the Qt thread — they enqueue work onto the process-global tokio
//! runtime (see `main.rs`) and the async results hop back here through
//! `CxxQtThread::queue` (the cxx-qt analogue of Slint's
//! in QML). All session/login/offline/shell state the QML needs lives in
//! its properties; user actions come in as invokables. Invokable bodies
//! NEVER block the Qt thread — they enqueue work onto the process-global
//! tokio runtime (see `main.rs`) and the async results hop back here
//! through `CxxQtThread::queue` (the cxx-qt analogue of Slint's
//! `upgrade_in_event_loop`).
//!
//! `#[auto_cxx_name]` on the extern blocks keeps Rust names snake_case while
//! QML/C++ see camelCase (`login_phase` -> `loginPhase`), matching the
//! property names of the Slint `LoginState`/`OfflineState` globals.
//! `#[auto_cxx_name]` on the extern blocks keeps Rust names snake_case
//! while QML/C++ see camelCase (`login_phase` -> `loginPhase`), matching
//! the property names of the Slint `LoginState`/`OfflineState` globals.
#[cxx_qt::bridge]
pub mod qbz_bridge {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
include!("cxx-qt-lib/qvariant.h");
type QVariant = cxx_qt_lib::QVariant;
include!("cxx-qt-lib/qlist.h");
type QList_QVariant = cxx_qt_lib::QList<cxx_qt_lib::QVariant>;
}
#[auto_cxx_name]
@@ -48,6 +54,45 @@ pub mod qbz_bridge {
// Shell session header:
#[qproperty(QString, session_user_name)]
#[qproperty(QString, session_subscription)]
// --- Shell chrome (Slint ShellState) ----------------------------
// Three-state sidebar: 0 = open (240px), 1 = mini (64px), 2 = closed.
#[qproperty(i32, sidebar_state)]
#[qproperty(bool, queue_open)]
// Content view id — only "home" exists (phase 3 adds the rest).
#[qproperty(QString, current_view)]
// Nav history (src/nav_qt.rs):
#[qproperty(bool, can_back)]
#[qproperty(bool, can_forward)]
// --- Now playing (Slint NowPlayingState; np_ prefix) ------------
// POC: fed by a static NowPlayingModel (src/now_playing.rs) with
// empty-state defaults; phase 4 swaps the data source for the real
// player poll.
#[qproperty(bool, np_has_track)]
#[qproperty(QString, np_title)]
#[qproperty(QString, np_artist)]
#[qproperty(QString, np_artwork_path)]
#[qproperty(i32, np_elapsed_secs)]
#[qproperty(i32, np_duration_secs)]
#[qproperty(f32, np_progress)]
#[qproperty(f32, np_cache_progress)]
#[qproperty(bool, np_playing)]
#[qproperty(bool, np_loading)]
#[qproperty(f32, np_volume)]
#[qproperty(bool, np_muted)]
#[qproperty(bool, np_shuffle)]
// 0 off / 1 all / 2 one.
#[qproperty(i32, np_repeat_mode)]
// "hires" | "mp3" | "lossless" | "cd" (AudioStamp tier mapping).
#[qproperty(QString, np_quality_tier)]
// e.g. "24-bit / 96 kHz" (AudioStamp detail line).
#[qproperty(QString, np_quality_label)]
// --- Queue panel -------------------------------------------------
// POC: empty until phase 4 (QML shows the empty states).
#[qproperty(QList_QVariant, queue_model)]
type QbzBridge = super::QbzBridgeRust;
/// Called once from Main.qml's Component.onCompleted: registers the
@@ -68,7 +113,7 @@ pub mod qbz_bridge {
#[qinvokable]
fn start_offline(self: Pin<&mut QbzBridge>);
/// Recovery banner "Sign in again": same browser flow.
/// Recovery badge "Sign in": same browser flow.
#[qinvokable]
fn recovery_login(self: Pin<&mut QbzBridge>);
@@ -85,6 +130,39 @@ pub mod qbz_bridge {
/// the Slint `@tr()` calls use.
#[qinvokable]
fn tr(self: &QbzBridge, msgid: QString) -> QString;
// --- Shell chrome -------------------------------------------------
/// Header panel-left button: cycle the sidebar open -> mini ->
/// closed -> open (Slint `ShellState.cycle-sidebar()`).
#[qinvokable]
fn cycle_sidebar(self: Pin<&mut QbzBridge>);
/// NPB queue button / queue panel close.
#[qinvokable]
fn toggle_queue(self: Pin<&mut QbzBridge>);
/// Header history buttons.
#[qinvokable]
fn navigate_back(self: Pin<&mut QbzBridge>);
#[qinvokable]
fn navigate_forward(self: Pin<&mut QbzBridge>);
// --- Transport (phase 4 wires the player; POC log-and-noop, except
// the pure-UI toggles which mutate the NowPlayingModel) -----------
#[qinvokable]
fn toggle_play(self: Pin<&mut QbzBridge>);
#[qinvokable]
fn next(self: Pin<&mut QbzBridge>);
#[qinvokable]
fn previous(self: Pin<&mut QbzBridge>);
#[qinvokable]
fn seek(self: Pin<&mut QbzBridge>, frac: f32);
#[qinvokable]
fn set_volume(self: Pin<&mut QbzBridge>, volume: f32);
#[qinvokable]
fn toggle_mute(self: Pin<&mut QbzBridge>);
#[qinvokable]
fn toggle_shuffle(self: Pin<&mut QbzBridge>);
#[qinvokable]
fn cycle_repeat(self: Pin<&mut QbzBridge>);
}
impl cxx_qt::Threading for QbzBridge {}
@@ -93,12 +171,13 @@ pub mod qbz_bridge {
use core::pin::Pin;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
use cxx_qt_lib::{QList, QString, QVariant};
type QListQVariant = QList<QVariant>;
/// Rust side of the bridge. All fields are driven exclusively through the
/// generated `set_*` methods on the Qt thread; the struct itself is plain
/// storage (as required by cxx-qt's Default-constructed qobjects).
#[derive(Default)]
pub struct QbzBridgeRust {
screen: QString,
login_phase: i32,
@@ -113,6 +192,71 @@ pub struct QbzBridgeRust {
offline_session: bool,
session_user_name: QString,
session_subscription: QString,
sidebar_state: i32,
queue_open: bool,
current_view: QString,
can_back: bool,
can_forward: bool,
np_has_track: bool,
np_title: QString,
np_artist: QString,
np_artwork_path: QString,
np_elapsed_secs: i32,
np_duration_secs: i32,
np_progress: f32,
np_cache_progress: f32,
np_playing: bool,
np_loading: bool,
np_volume: f32,
np_muted: bool,
np_shuffle: bool,
np_repeat_mode: i32,
np_quality_tier: QString,
np_quality_label: QString,
queue_model: QListQVariant,
}
impl Default for QbzBridgeRust {
fn default() -> Self {
Self {
screen: QString::from("splash"),
current_view: QString::from("home"),
// A derive would zero these — the model's sane defaults instead.
np_volume: 1.0,
np_quality_tier: QString::from("cd"),
login_phase: 0,
login_error: QString::default(),
restore_error: QString::default(),
offline: false,
offline_mode: 0,
connectivity: 0,
captive_portal: false,
has_previous_session: false,
show_recovery_banner: false,
offline_session: false,
session_user_name: QString::default(),
session_subscription: QString::default(),
sidebar_state: 0,
queue_open: false,
can_back: false,
can_forward: false,
np_has_track: false,
np_title: QString::default(),
np_artist: QString::default(),
np_artwork_path: QString::default(),
np_elapsed_secs: 0,
np_duration_secs: 0,
np_progress: 0.0,
np_cache_progress: 0.0,
np_playing: false,
np_loading: false,
np_muted: false,
np_shuffle: false,
np_repeat_mode: 0,
np_quality_label: QString::default(),
queue_model: QListQVariant::default(),
}
}
}
impl qbz_bridge::QbzBridge {
@@ -155,4 +299,54 @@ impl qbz_bridge::QbzBridge {
pub fn tr(&self, msgid: QString) -> QString {
QString::from(&qbz_i18n::t(&msgid.to_string()))
}
pub fn cycle_sidebar(mut self: Pin<&mut Self>) {
let next = (self.sidebar_state() + 1) % 3;
self.as_mut().set_sidebar_state(next);
}
pub fn toggle_queue(mut self: Pin<&mut Self>) {
let next = !self.queue_open();
self.as_mut().set_queue_open(next);
}
pub fn navigate_back(self: Pin<&mut Self>) {
crate::nav_qt::back();
}
pub fn navigate_forward(self: Pin<&mut Self>) {
crate::nav_qt::forward();
}
pub fn toggle_play(self: Pin<&mut Self>) {
crate::now_playing::toggle_play();
}
pub fn next(self: Pin<&mut Self>) {
crate::now_playing::next();
}
pub fn previous(self: Pin<&mut Self>) {
crate::now_playing::previous();
}
pub fn seek(self: Pin<&mut Self>, frac: f32) {
crate::now_playing::seek(frac);
}
pub fn set_volume(self: Pin<&mut Self>, volume: f32) {
crate::now_playing::set_volume(volume);
}
pub fn toggle_mute(self: Pin<&mut Self>) {
crate::now_playing::toggle_mute();
}
pub fn toggle_shuffle(self: Pin<&mut Self>) {
crate::now_playing::toggle_shuffle();
}
pub fn cycle_repeat(self: Pin<&mut Self>) {
crate::now_playing::cycle_repeat();
}
}