feat(qconnect-app): add QconnectRendererEngine trait + qbz-player dep (slice 6)

The renderer-engine seam: a 16-method trait both frontends implement with
zero-cost forwards to their QbzCore/Player, so the hard-won renderer
orchestration (echo-seek rejection, cursor align, queue materialize, shuffle
deferral, load dedup window) can move UP into qconnect-app and be written once
against the trait instead of re-derived per frontend (slice 6, Phase T 1-2).

- Adds qbz-player as a qconnect-app dep, for PlaybackState (read by the echo
  gate + should_reload; it is not in qbz-models and not re-exported). qbz-player
  does NOT depend on qconnect-app -> no dependency cycle.
- The two protected bit-perfect seams (play_data / play_streaming_dynamic) are
  reached ONLY through the high-level start_track_stream method; BufferWriter +
  reqwest + the detached feeder spawn stay impl-side and never cross the crate
  boundary. play_data / play_streaming_dynamic are never modified.

No impls, no logic moved yet -- pure foundation. qconnect-app 38 green, src-tauri
cargo check green.

cc
This commit is contained in:
vicrodh committed 2026-06-02 16:43:08 -06:00
1 parent 6b26ea0e5e
commit e5e8d4a853
5 files changed
+98

No files matched your search

+1
View File
@@ -5312,6 +5312,7 @@ dependencies = [
"log",
"prost",
"qbz-models",
"qbz-player",
"qconnect-core",
"qconnect-protocol",
"qconnect-transport-ws",
+5
View File
@@ -10,6 +10,11 @@ qconnect-core = { path = "../qconnect-core" }
qconnect-protocol = { path = "../qconnect-protocol" }
qconnect-transport-ws = { path = "../qconnect-transport-ws" }
qbz-models = { path = "../qbz-models" }
# Renderer-engine seam (slice 6): the trait carries qbz_player::PlaybackState
# (read by the echo gate + should_reload). qbz-player does NOT depend on
# qconnect-app, so there is no cycle. play_data/play_streaming_dynamic are
# reached ONLY through the trait's start_track_stream and are never modified.
qbz-player = { path = "../qbz-player" }
async-trait = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
+2
View File
@@ -6,6 +6,7 @@ mod app;
mod error;
mod events;
mod feature_flags;
mod renderer_engine;
pub mod session;
mod state;
pub mod startup;
@@ -38,6 +39,7 @@ pub use qconnect_core::{
RendererCommand, TrackOrigin,
};
pub use qconnect_protocol::{QueueCommandType, RendererReport, RendererReportType};
pub use renderer_engine::QconnectRendererEngine;
pub use state::QconnectRuntimeState;
pub use sync_state::{
ensure_session_renderer_state, sync_session_renderer_active_flags, QconnectRemoteSyncState,
@@ -0,0 +1,89 @@
//! Engine seam for QConnect renderer-side playback (slice 6).
//!
//! The hard-won renderer orchestration (echo-seek rejection, cursor align,
//! queue materialize, shuffle deferral, load dedup window) lives ABOVE this
//! trait in `qconnect-app` and is written ONLY against these methods plus
//! `QconnectRemoteSyncState`. It must never be re-derived per frontend.
//!
//! Implemented by:
//! - src-tauri: `CoreBridge` (forwards to `QbzCore` + `Player`)
//! - qbz-slint: a thin adapter over `runtime.core()` (`QbzCore` + `Player`)
//!
//! Errors are `String` (CoreBridge already returns `String`; the Slint impl maps
//! `CoreError::to_string()`). The two protected bit-perfect audio seams
//! (`play_data` / `play_streaming_dynamic`) are reached ONLY through
//! [`QconnectRendererEngine::start_track_stream`]; they are never modified, only
//! called, and the probe-derived sample_rate/channels/bit_depth must pass
//! straight through (defaulting them silently resamples hi-res).
use async_trait::async_trait;
use qbz_models::{Quality, QueueTrack, RepeatMode, Track};
use qbz_player::PlaybackState;
/// The renderer-side engine surface. Both frontends implement it with zero-cost
/// one-line forwards to their `QbzCore` / `Player`; the async/sync split mirrors
/// `QbzCore`/`CoreBridge` verbatim (queue/catalog are async; raw transport is
/// sync). Keep this MINIMAL — exactly these methods. A unified controller engine
/// is a separate trait; do not pollute this one.
#[async_trait]
pub trait QconnectRendererEngine: Send + Sync {
// ---- transport (sync on the engine) ----
fn resume(&self) -> Result<(), String>;
fn pause(&self) -> Result<(), String>;
fn stop(&self) -> Result<(), String>;
/// Seek in WHOLE SECONDS (matches `QbzCore::seek` / `CoreBridge::seek`).
fn seek(&self, position_secs: u64) -> Result<(), String>;
/// Volume as a 0.0–1.0 fraction (caller normalizes via `normalize_volume_to_fraction`).
fn set_volume(&self, fraction: f32) -> Result<(), String>;
/// Fresh, synchronous snapshot of the audio thread (track_id/position/...).
/// MUST NOT be cached/stale — the echo filter and the #387 seek-diff gate
/// depend on its freshness.
fn get_playback_state(&self) -> PlaybackState;
// ---- queue / mode (async) ----
async fn set_repeat_mode(&self, mode: RepeatMode);
async fn set_shuffle(&self, enabled: bool);
async fn get_all_queue_tracks(&self) -> (Vec<QueueTrack>, Option<usize>);
async fn set_queue(&self, tracks: Vec<QueueTrack>, start_index: Option<usize>);
/// The deferred-shuffle core: a real shuffle order, never an invented
/// identity order (Slint must call this, not fake it via `set_queue`).
async fn set_queue_with_order(
&self,
tracks: Vec<QueueTrack>,
start_index: Option<usize>,
shuffle_enabled: bool,
shuffle_order: Option<Vec<usize>>,
);
async fn clear_queue(&self, keep_current: bool);
async fn play_index(&self, index: usize) -> Option<QueueTrack>;
// ---- catalog (async) ----
async fn get_track(&self, track_id: u64) -> Result<Track, String>;
async fn get_tracks_batch(&self, track_ids: &[u64]) -> Result<Vec<Track>, String>;
// ---- protected audio seam, behind one high-level method ----
/// Resolve the stream URL at `quality`, probe the FLAC format, start a
/// progressive stream into the player and SPAWN the HTTP feeder; on a
/// streaming error, fall back to a full download + `play_data`. The impl owns
/// `reqwest`, the `BufferWriter`, and the detached spawn — none of which
/// crosses this crate boundary. `start_position_secs` is the seek target
/// (QConnect callers pass 0; resume is local-only).
///
/// This is the ONLY method that touches the protected bit-perfect seams
/// (`play_data` / `play_streaming_dynamic`). It MUST pass the probed
/// sample_rate/channels/bit_depth straight through — never default them, or
/// hi-res remote playback silently resamples.
async fn start_track_stream(
&self,
track_id: u64,
quality: Quality,
duration_secs: u64,
start_position_secs: u64,
) -> Result<(), String>;
// ---- report-back source (the single per-frontend "engine read") ----
/// The ACTUAL DAC output format `(sample_rate, bit_depth)` under bit-perfect
/// passthrough — read from `player().state`. Drives the file/device
/// audio-quality reports. Read-only; never device init.
fn current_output_format(&self) -> Option<(u32, u32)>;
}