feat(qbz-slint): add QConnect renderer engine over QbzCore (slice 6, phase S)

Phase S steps 9-10. Makes qbz-Slint a QConnect renderer: SlintRendererEngine
implements qconnect_app::QconnectRendererEngine over runtime.core(), so the Slint
frontend inherits the shared echo/cursor/materialize/shuffle orchestration in
qconnect_app::renderer instead of re-deriving it.

- Deps added to qbz-slint: qconnect-app, explicit qbz-player, futures-util, uuid.
- 16 trait forwards through runtime.core() (CoreError mapped to String) +
  current_output_format reading player().state.
- start_track_stream + the HTTP feeder (probe FLAC -> play_streaming_dynamic,
  fallback download -> play_data) ported from the Tauri CoreBridge impl. The
  protected bit-perfect seams are only called, never modified; the probe-derived
  sample_rate/channels/bit_depth flow straight through.
- TLS deviation: the crates-workspace reqwest ships rustls-tls (not native-tls),
  so .use_native_tls() is dropped. TLS is transport-only; audio bytes identical.

allow(dead_code) until the Slint QConnect service + event sink wire it (next
Phase S step). Verified: cargo check -p qbz-slint green (only a pre-existing
unrelated rename_folder dead-code warning).

cc
This commit is contained in:
vicrodh committed 2026-06-02 20:27:44 -06:00
1 parent 37cd1b154b
commit 88e72c3e61
4 files changed
+422

No files matched your search

+4
View File
@@ -5281,6 +5281,7 @@ dependencies = [
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
"env_logger", "env_logger",
"futures-util",
"image", "image",
"log", "log",
"open", "open",
@@ -5293,6 +5294,8 @@ dependencies = [
"qbz-library", "qbz-library",
"qbz-models", "qbz-models",
"qbz-offline-cache", "qbz-offline-cache",
"qbz-player",
"qconnect-app",
"reqwest", "reqwest",
"rfd", "rfd",
"rusqlite", "rusqlite",
@@ -5302,6 +5305,7 @@ dependencies = [
"slint-build", "slint-build",
"tokio", "tokio",
"urlencoding", "urlencoding",
"uuid",
] ]
[[package]] [[package]]
+4
View File
@@ -39,6 +39,10 @@ chrono = { workspace = true, features = ["unstable-locales"] }
qbz-app = { path = "../qbz-app" } qbz-app = { path = "../qbz-app" }
qbz-audio = { path = "../qbz-audio" } qbz-audio = { path = "../qbz-audio" }
qbz-core = { path = "../qbz-core" } qbz-core = { path = "../qbz-core" }
qbz-player = { path = "../qbz-player" }
qconnect-app = { path = "../qconnect-app" }
futures-util = { workspace = true }
uuid = { version = "1", features = ["v4"] }
qbz-library = { path = "../qbz-library" } qbz-library = { path = "../qbz-library" }
qbz-offline-cache = { path = "../qbz-offline-cache" } qbz-offline-cache = { path = "../qbz-offline-cache" }
qbz-models = { path = "../qbz-models" } qbz-models = { path = "../qbz-models" }
+1
View File
@@ -39,6 +39,7 @@ mod nav;
mod play_history; mod play_history;
mod strip_html; mod strip_html;
mod playback; mod playback;
mod qconnect_engine;
mod queue; mod queue;
mod drag; mod drag;
mod folders; mod folders;
+413
View File
@@ -0,0 +1,413 @@
//! Qobuz Connect renderer engine for the Slint frontend (slice 6, Phase S).
//!
//! Implements [`qconnect_app::QconnectRendererEngine`] over the Slint
//! `AppRuntime`'s `QbzCore` + `Player`, so qbz-Slint becomes a QConnect renderer
//! that inherits the shared echo/cursor/materialize/shuffle orchestration in
//! `qconnect_app::renderer` instead of re-deriving it.
//!
//! The protected bit-perfect seams (`play_streaming_dynamic` / `play_data`) and
//! the HTTP feeder live here, impl-side, exactly as the Tauri `CoreBridge` impl
//! does; the probe-derived sample_rate/channels/bit_depth flow STRAIGHT into
//! `play_streaming_dynamic` (never defaulted, or hi-res remote playback silently
//! resamples). The feeder body is a near-verbatim port of the Tauri
//! `track_loading.rs` feeder, with `bridge.player()` -> `self.core().player()`;
//! the only deviation is the TLS backend — the crates workspace `reqwest` ships
//! `rustls-tls` (not `native-tls`), so the `.use_native_tls()` calls are dropped.
//! TLS is transport encryption only; the decoded audio bytes are identical, so
//! bit-perfect is unaffected. (If the Qobuz streaming CDN ever presents a cert
//! rustls rejects, add `native-tls` to qbz-slint's reqwest features.)
//!
//! Wired by the Slint QConnect service + event sink (next Phase S step); until
//! that lands the constructor is unused.
#![allow(dead_code)]
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use qbz_app::shell::AppRuntime;
use qbz_core::QbzCore;
use qbz_models::{Quality, QueueTrack, RepeatMode, Track};
use qbz_player::{BufferWriter, PlaybackState};
use qconnect_app::QconnectRendererEngine;
use crate::adapter::SlintAdapter;
/// QConnect renderer engine backed by the Slint `AppRuntime`. Holds the shared
/// runtime and forwards every trait method through `runtime.core()`; the async
/// feeder spawns on the ambient tokio runtime (`start_track_stream` is always
/// awaited from a runtime task).
pub struct SlintRendererEngine {
runtime: Arc<AppRuntime<SlintAdapter>>,
}
impl SlintRendererEngine {
pub fn new(runtime: Arc<AppRuntime<SlintAdapter>>) -> Self {
Self { runtime }
}
fn core(&self) -> &Arc<QbzCore<SlintAdapter>> {
self.runtime.core()
}
}
#[async_trait]
impl QconnectRendererEngine for SlintRendererEngine {
// ---- transport (sync) ----
fn resume(&self) -> Result<(), String> {
self.core().resume().map_err(|err| err.to_string())
}
fn pause(&self) -> Result<(), String> {
self.core().pause().map_err(|err| err.to_string())
}
fn stop(&self) -> Result<(), String> {
self.core().stop().map_err(|err| err.to_string())
}
fn seek(&self, position_secs: u64) -> Result<(), String> {
self.core().seek(position_secs).map_err(|err| err.to_string())
}
fn set_volume(&self, fraction: f32) -> Result<(), String> {
self.core().set_volume(fraction).map_err(|err| err.to_string())
}
fn get_playback_state(&self) -> PlaybackState {
self.core().get_playback_state()
}
// ---- queue / mode (async) ----
async fn set_repeat_mode(&self, mode: RepeatMode) {
self.core().set_repeat_mode(mode).await
}
async fn set_shuffle(&self, enabled: bool) {
self.core().set_shuffle(enabled).await
}
async fn get_all_queue_tracks(&self) -> (Vec<QueueTrack>, Option<usize>) {
self.core().get_all_queue_tracks().await
}
async fn set_queue(&self, tracks: Vec<QueueTrack>, start_index: Option<usize>) {
self.core().set_queue(tracks, start_index).await
}
async fn set_queue_with_order(
&self,
tracks: Vec<QueueTrack>,
start_index: Option<usize>,
shuffle_enabled: bool,
shuffle_order: Option<Vec<usize>>,
) {
self.core()
.set_queue_with_order(tracks, start_index, shuffle_enabled, shuffle_order)
.await
}
async fn clear_queue(&self, keep_current: bool) {
self.core().clear_queue(keep_current).await
}
async fn play_index(&self, index: usize) -> Option<QueueTrack> {
self.core().play_index(index).await
}
// ---- catalog (async) ----
async fn get_track(&self, track_id: u64) -> Result<Track, String> {
self.core()
.get_track(track_id)
.await
.map_err(|err| err.to_string())
}
async fn get_tracks_batch(&self, track_ids: &[u64]) -> Result<Vec<Track>, String> {
self.core()
.get_tracks_batch(track_ids)
.await
.map_err(|err| err.to_string())
}
// ---- protected audio seam (the only protected touch) ----
async fn start_track_stream(
&self,
track_id: u64,
quality: Quality,
duration_secs: u64,
start_position_secs: u64,
) -> Result<(), String> {
let stream_url = self
.core()
.get_stream_url(track_id, quality)
.await
.map_err(|err| format!("resolve stream url for remote track {track_id}: {err}"))?;
match stream_remote_track_into_player(
self.core(),
track_id,
duration_secs,
start_position_secs,
&stream_url.url,
)
.await
{
Ok(()) => Ok(()),
Err(stream_err) => {
log::warn!(
"[QConnect] Streaming handoff unavailable for track {}: {}. Falling back to full download.",
track_id,
stream_err
);
let audio_data = download_remote_audio(&stream_url.url).await?;
self.core()
.player()
.play_data(audio_data, track_id)
.map_err(|err| format!("play remote track {track_id}: {err}"))?;
Ok(())
}
}
}
fn current_output_format(&self) -> Option<(u32, u32)> {
let player = self.core().player();
Some((
player.state.get_sample_rate(),
player.state.get_bit_depth(),
))
}
}
// ===================== HTTP streaming feeder =====================
//
// reqwest + BufferWriter bound, so it stays frontend-side and never crosses the
// qconnect-app boundary. Verbatim port of the Tauri `track_loading.rs` feeder.
struct QconnectRemoteStreamInfo {
content_length: u64,
sample_rate: u32,
channels: u16,
bit_depth: u32,
speed_mbps: f64,
}
async fn stream_remote_track_into_player(
core: &QbzCore<SlintAdapter>,
track_id: u64,
duration_secs: u64,
start_position_secs: u64,
url: &str,
) -> Result<(), String> {
let stream_info = probe_remote_stream_info(url).await?;
log::info!(
"[QConnect/STREAMING] Track {} - {:.2} MB, {}Hz, {} ch, {}-bit, {:.1} MB/s",
track_id,
stream_info.content_length as f64 / (1024.0 * 1024.0),
stream_info.sample_rate,
stream_info.channels,
stream_info.bit_depth,
stream_info.speed_mbps
);
let writer = core
.player()
.play_streaming_dynamic(
track_id,
stream_info.sample_rate,
stream_info.channels,
stream_info.bit_depth,
stream_info.content_length,
stream_info.speed_mbps,
duration_secs,
start_position_secs, // QConnect callers pass 0; resume is local-only
)
.map_err(|err| format!("start streaming remote track {track_id}: {err}"))?;
let url = url.to_string();
let content_length = stream_info.content_length;
tokio::spawn(async move {
if let Err(err) =
download_and_stream_remote_track(&url, writer, track_id, content_length).await
{
log::error!(
"[QConnect/STREAMING] Track {} failed while streaming: {}",
track_id,
err
);
}
});
Ok(())
}
async fn probe_remote_stream_info(url: &str) -> Result<QconnectRemoteStreamInfo, String> {
use std::time::Instant;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|err| format!("create stream probe client: {err}"))?;
let head_response = client
.head(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|err| format!("probe HEAD request failed: {err}"))?;
if !head_response.status().is_success() {
return Err(format!(
"probe HEAD request failed with status {}",
head_response.status()
));
}
let content_length = head_response
.headers()
.get("content-length")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.ok_or_else(|| "probe missing content-length header".to_string())?;
let start_time = Instant::now();
let range_response = client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.header("Range", "bytes=0-65535")
.send()
.await
.map_err(|err| format!("probe range request failed: {err}"))?;
if !range_response.status().is_success() {
return Err(format!(
"probe range request failed with status {}",
range_response.status()
));
}
let initial_bytes = range_response
.bytes()
.await
.map_err(|err| format!("read probe bytes failed: {err}"))?;
let elapsed = start_time.elapsed();
let speed_mbps = if elapsed.as_secs_f64() > 0.0 {
(initial_bytes.len() as f64 / elapsed.as_secs_f64()) / (1024.0 * 1024.0)
} else {
10.0
};
let (sample_rate, channels, bit_depth) =
if initial_bytes.len() >= 26 && initial_bytes.starts_with(b"fLaC") {
let sample_rate = ((initial_bytes[18] as u32) << 12)
| ((initial_bytes[19] as u32) << 4)
| ((initial_bytes[20] as u32) >> 4);
let channels = ((initial_bytes[20] >> 1) & 0x07) + 1;
let bit_depth = ((initial_bytes[20] & 0x01) << 4) | ((initial_bytes[21] >> 4) & 0x0F);
(sample_rate, channels as u16, (bit_depth + 1) as u32)
} else {
log::warn!("[QConnect/STREAMING] Non-FLAC probe for remote handoff, using defaults");
(44_100, 2, 16)
};
Ok(QconnectRemoteStreamInfo {
content_length,
sample_rate,
channels,
bit_depth,
speed_mbps,
})
}
async fn download_and_stream_remote_track(
url: &str,
writer: BufferWriter,
track_id: u64,
content_length: u64,
) -> Result<(), String> {
use futures_util::StreamExt;
use std::time::Instant;
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(300))
.build()
.map_err(|err| format!("create remote streaming client: {err}"))?;
let response = client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|err| format!("start remote streaming request failed: {err}"))?;
if !response.status().is_success() {
return Err(format!(
"remote streaming request failed with status {}",
response.status()
));
}
let mut bytes_received = 0u64;
let mut stream = response.bytes_stream();
let start_time = Instant::now();
let mut last_log_time = Instant::now();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|err| format!("remote streaming chunk failed: {err}"))?;
bytes_received += chunk.len() as u64;
if let Err(err) = writer.push_chunk(&chunk) {
log::error!(
"[QConnect/STREAMING] Failed to push chunk for track {}: {}",
track_id,
err
);
}
let now = Instant::now();
if now.duration_since(last_log_time) >= Duration::from_secs(2) && content_length > 0 {
let progress = (bytes_received as f64 / content_length as f64) * 100.0;
let avg_speed =
(bytes_received as f64 / start_time.elapsed().as_secs_f64()) / (1024.0 * 1024.0);
log::info!(
"[QConnect/STREAMING] Track {} {:.1}% ({:.2}/{:.2} MB) @ {:.2} MB/s",
track_id,
progress,
bytes_received as f64 / (1024.0 * 1024.0),
content_length as f64 / (1024.0 * 1024.0),
avg_speed
);
last_log_time = now;
}
}
if let Err(err) = writer.complete() {
log::error!(
"[QConnect/STREAMING] Failed to mark stream complete for track {}: {}",
track_id,
err
);
}
log::info!(
"[QConnect/STREAMING] Track {} complete: {:.2} MB in {:.1}s",
track_id,
bytes_received as f64 / (1024.0 * 1024.0),
start_time.elapsed().as_secs_f64()
);
Ok(())
}
async fn download_remote_audio(url: &str) -> Result<Vec<u8>, String> {
let response = reqwest::Client::new()
.get(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|err| format!("download remote audio request failed: {err}"))?;
if !response.status().is_success() {
return Err(format!(
"download remote audio failed with status {}",
response.status()
));
}
let bytes = response
.bytes()
.await
.map_err(|err| format!("read remote audio bytes failed: {err}"))?;
Ok(bytes.to_vec())
}