feat(qbz-slint): Qobuz Connect renderer + controller integration

Port the QConnect adapter to the Slint frontend so qbz acts as a full peer
(renderer + controller), inheriting the shared qconnect-app hardening.

- Device-info / join payloads + transport config (piece a).
- SlintQconnectEventSink: queue materialize / renderer-command apply /
  session-management via the shared qconnect-app orchestration; renderer
  ready-report on SetActive(true).
- SlintSessionLoopHost + service connect recipe (subscribe-before-spawn) +
  controller bootstrap + deferred renderer-join; connect/disconnect toggle
  wired in the now-playing bar flyout.
- Renderer sync: outbound RndrSrvrStateUpdated reports (position / current
  queue item / file+device audio quality) while qbz is the active local
  renderer, and inbound UI refresh (now-playing card + queue sidebar) after
  remote queue / state changes.
- Controller-side queue push: when the local queue changes while connected,
  push it to the session (QueueLoadTracks) with all-or-nothing admission
  (refuse mixed local/Plex queues; offline qobuz_download is eligible) and
  echo suppression against the last-applied / last-pushed queue.
- Error + lifecycle toasts; DEV diagnostics modal (live session status +
  selectable rolling event log) opened from the connect flyout.

cc
This commit is contained in:
vicrodh committed 2026-06-03 00:11:37 -06:00
1 parent 3861efa79f
commit 44876d01be
10 files changed
+1691 -6

No files matched your search

+48
View File
@@ -40,6 +40,8 @@ mod play_history;
mod strip_html;
mod playback;
mod qconnect_engine;
mod qconnect_event_sink;
mod qconnect_service;
mod qconnect_transport;
mod queue;
mod drag;
@@ -2926,6 +2928,52 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Qobuz Connect — initialize the service singleton and wire the bar's
// connect/disconnect toggle. The session loop + renderer engine inherit the
// shared qconnect-app hardening (watchdog/takeover/resync). Transport
// `*_if_remote` routing + device picker land in the QConnect UI-polish step.
{
let _ = qconnect_service::init_service(app_runtime.clone(), window.as_weak());
let handle = tokio_rt.handle().clone();
let weak = window.as_weak();
window
.global::<NowPlayingState>()
.on_qconnect_toggle(move || {
let Some(svc) = qconnect_service::service() else {
return;
};
let weak = weak.clone();
handle.spawn(async move {
let connected = if svc.is_running().await {
if let Err(err) = svc.disconnect().await {
log::warn!("[QConnect] disconnect failed: {err}");
}
false
} else {
match svc.connect().await {
Ok(()) => true,
Err(err) => {
log::warn!("[QConnect] connect failed: {err}");
false
}
}
};
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<NowPlayingState>()
.set_qconnect_connected(connected);
});
});
});
}
{
let weak = window.as_weak();
window
.global::<QconnectDevState>()
.on_clear(move || {
qconnect_service::dev_clear(&weak);
});
}
// Transport — wired through the NowPlayingState global callbacks.
{
let runtime = app_runtime.clone();
+47 -2
View File
@@ -15,6 +15,7 @@ use std::sync::{Arc, OnceLock};
use qbz_app::shell::AppRuntime;
use qbz_models::{Quality, QueueTrack, RepeatMode, Track};
use qconnect_app::renderer::{PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING};
use slint::{ComponentHandle, Model, ModelRc};
use crate::adapter::SlintAdapter;
@@ -37,7 +38,7 @@ pub fn set_queue_controller(controller: QueueController) {
/// Refresh the Queue sidebar from the current core queue state. No-op
/// before the controller is registered. `with_favorites` re-pulls the
/// favorite-track cache as well (used after a fresh play starts).
fn refresh_sidebar(with_favorites: bool) {
pub(crate) fn refresh_sidebar(with_favorites: bool) {
if let Some(controller) = QUEUE_CONTROLLER.get() {
if with_favorites {
controller.refresh_with_favorites();
@@ -493,7 +494,7 @@ fn fmt_remaining(position: u64, duration: u64) -> String {
/// Push the now-playing values for the current queue track onto
/// `NowPlayingState`. Called when a new track starts so the song card
/// updates immediately (the poll loop only refreshes position/progress).
async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
let state = runtime.core().get_queue_state().await;
let Some(track) = state.current_track else {
let _ = weak.upgrade_in_event_loop(|w| {
@@ -1953,6 +1954,14 @@ pub fn start_poll_loop(
// 450ms ticker does not re-request it every tick.
let mut gapless_requested_for: u64 = 0;
// QConnect renderer-report throttle: the official client reports
// RndrSrvrStateUpdated ~every 2s while playing PLUS immediately on a
// transition (track / play-state change). At a 450ms tick, ~4 ticks ≈ 2s.
let mut last_reported_track_id: u64 = 0;
let mut last_reported_playing = false;
let mut report_tick: u64 = 0;
const QCONNECT_REPORT_EVERY_N_TICKS: u64 = 4;
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(450));
loop {
ticker.tick().await;
@@ -2103,6 +2112,42 @@ pub fn start_poll_loop(
np.set_volume(volume.clamp(0.0, 1.0));
});
// --- QConnect: outbound renderer state report -----------------
// When QBZ is the ACTIVE LOCAL renderer (controlled by a remote
// controller like the iOS app), report our playback state so the
// controller's seek bar + current-track follow. Mirrors the official
// client: position/duration in MILLISECONDS, ~2s periodic while
// playing + an immediate report on every transition. The service
// self-gates on is_local_renderer_active (no-op when we're a peer
// controller or not connected) and resolves the queue_item_ids.
report_tick = report_tick.wrapping_add(1);
if track_id != 0 {
let transition =
track_id != last_reported_track_id || is_playing != last_reported_playing;
let periodic = is_playing && report_tick % QCONNECT_REPORT_EVERY_N_TICKS == 0;
if transition || periodic {
if let Some(svc) = crate::qconnect_service::service() {
let playing_state = if is_playing {
PLAYING_STATE_PLAYING
} else {
PLAYING_STATE_PAUSED
};
let position_ms = (position as i64) * 1000;
let duration_ms = (duration as i64) * 1000;
svc.report_playback_state(playing_state, position_ms, duration_ms, track_id)
.await;
// On a track change, also reconcile the session queue: if the
// user started a new album/playlist on QBZ, push it so the
// controller (iOS) follows. Self-gates + echo-suppresses.
if transition {
svc.sync_local_queue_if_changed().await;
}
}
last_reported_track_id = track_id;
last_reported_playing = is_playing;
}
}
if track_id != 0 {
last_track_id = track_id;
seen_position = position;
+463
View File
@@ -0,0 +1,463 @@
//! Slint `QconnectEventSink` (piece b, Phase S).
//!
//! Receives `QconnectAppEvent`s from the qconnect-app crate and dispatches them
//! into the Slint `SlintRendererEngine` (the renderer seam), the shared
//! `QconnectRemoteSyncState` accumulator, and — eventually — the Slint UI.
//!
//! This mirrors the Tauri `src-tauri/src/qconnect/event_sink.rs` arm-for-arm,
//! but routes through the frontend-agnostic `qconnect_app::renderer` orchestration
//! (materialize / apply / loop-mode / cursor-align) and the relocated projection
//! helpers (`build_session_renderer_snapshot` / `cache_renderer_snapshot`) instead
//! of the Tauri `CoreBridge` wrappers. The session-management critical section is
//! delegated to `QconnectApp::apply_session_management_event`; only the post-lock
//! work the returned `SessionApplyOutcome` asks for runs here.
//!
//! UI surfacing (the `is-remote`/badge state, the six toasts, the auto-skip on
//! PlaybackError) is intentionally deferred to the QConnect UI-polish step — those
//! arms log a TODO for now. The renderer-critical arms (QueueUpdated,
//! RendererCommandApplied, SessionManagementEvent, RendererUpdated) are fully
//! wired so this device functions AS a renderer.
use std::sync::{Arc, OnceLock, Weak};
use async_trait::async_trait;
use qbz_app::shell::AppRuntime;
use qconnect_app::{
build_session_renderer_snapshot, cache_renderer_snapshot, is_peer_renderer_active, QconnectApp,
QconnectAppEvent, QconnectEventSink, QconnectRemoteSyncState, QconnectRendererEngine,
RendererCommand, RendererReport, RendererReportType,
};
use qconnect_transport_ws::NativeWsTransport;
use serde_json::Value;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::adapter::SlintAdapter;
use crate::qconnect_engine::SlintRendererEngine;
use crate::qconnect_transport::{resolve_local_identity, BUFFER_STATE_OK};
use crate::AppWindow;
/// Concrete `QconnectApp` type used by the Slint adapter.
pub type SlintQconnectApp = QconnectApp<NativeWsTransport, SlintQconnectEventSink>;
pub struct SlintQconnectEventSink {
/// Renderer seam — forwards the `qconnect_app::renderer` orchestration onto
/// `runtime.core()` + the protected player.
engine: SlintRendererEngine,
/// Shared runtime — used to refresh the Slint now-playing card + queue
/// sidebar from the (remotely-mutated) core state after inbound events.
runtime: Arc<AppRuntime<SlintAdapter>>,
/// THE shared remote-sync accumulator (one Mutex, shared with `QconnectApp`).
sync_state: Arc<Mutex<QconnectRemoteSyncState>>,
/// Late-bound weak handle to the owning app, wired via `set_app` after the
/// app is built FROM this sink. Used to emit renderer reports (e.g.
/// is_active=true after SetActive(true)) and to drive the session-apply +
/// freeze/watchdog without an ownership cycle.
app: Arc<OnceLock<Weak<SlintQconnectApp>>>,
/// Window handle for future UI surfacing (toasts, badge state). Unused until
/// the QConnect UI-polish step.
#[allow(dead_code)]
window: slint::Weak<AppWindow>,
}
impl SlintQconnectEventSink {
pub fn new(
engine: SlintRendererEngine,
runtime: Arc<AppRuntime<SlintAdapter>>,
sync_state: Arc<Mutex<QconnectRemoteSyncState>>,
window: slint::Weak<AppWindow>,
) -> Self {
Self {
engine,
runtime,
sync_state,
app: Arc::new(OnceLock::new()),
window,
}
}
/// Rebuild the DEV-modal status block (session topology / renderer roles /
/// queue) from the live sync state + app snapshot, and push it to the modal.
async fn refresh_dev_status(&self) {
let Some(app) = self.app.get().and_then(Weak::upgrade) else {
return;
};
let queue = app.queue_state_snapshot().await;
let status = {
let st = self.sync_state.lock().await;
let session = &st.session;
let role = match (session.active_renderer_id, session.local_renderer_id) {
(Some(a), Some(l)) if a == l => "renderer (this device active)",
(Some(_), Some(_)) => "controller (peer active)",
(Some(_), None) => "joined (local id pending)",
_ => "no active renderer",
};
let renderers = if session.renderers.is_empty() {
" (none)".to_string()
} else {
session
.renderers
.iter()
.map(|r| {
let local = if Some(r.renderer_id) == session.local_renderer_id {
" LOCAL"
} else {
""
};
let active = if Some(r.renderer_id) == session.active_renderer_id {
" ACTIVE"
} else {
""
};
format!(
" #{} {}{}{}",
r.renderer_id,
r.friendly_name.clone().unwrap_or_default(),
local,
active
)
})
.collect::<Vec<_>>()
.join("\n")
};
format!(
"Role: {role}\nsession_uuid: {}\nactive_renderer_id: {:?} local_renderer_id: {:?}\nqueue: v{}.{} items={} autoplay={}\nrenderers:\n{}",
session
.session_uuid
.clone()
.unwrap_or_else(|| "-".to_string()),
session.active_renderer_id,
session.local_renderer_id,
queue.version.major,
queue.version.minor,
queue.queue_items.len(),
queue.autoplay_items.len(),
renderers,
)
};
crate::qconnect_service::dev_set_status(&self.window, status);
}
/// Refresh the Slint now-playing card + queue sidebar from the current core
/// state. The inbound renderer orchestration (materialize / apply) mutates
/// the core player+queue but does NOT touch the UI; without this the card +
/// queue stay on whatever was loaded at connect time while the audio follows
/// the remote controller. Reads core `current_track()` so it is authoritative
/// regardless of the order QueueUpdated / SetState arrive in.
async fn refresh_local_ui(&self) {
crate::playback::refresh_now_playing_meta(&self.runtime, &self.window).await;
crate::playback::refresh_sidebar(true);
}
/// Wire the owning app after construction. Idempotent (OnceLock).
pub fn set_app(&self, app: &Arc<SlintQconnectApp>) {
let _ = self.app.set(Arc::downgrade(app));
}
/// Emit a StateUpdated report announcing this renderer is now active. Sent
/// after SetActive(true) is applied so the controller learns we are ready.
async fn report_active_renderer_ready(&self) {
let Some(app) = self.app.get().and_then(Weak::upgrade) else {
return;
};
let queue_version = app.queue_state_snapshot().await.version;
let report = RendererReport::new(
RendererReportType::RndrSrvrStateUpdated,
Uuid::new_v4().to_string(),
queue_version,
serde_json::json!({
"is_active": true,
"buffer_state": BUFFER_STATE_OK,
"queue_version": {
"major": queue_version.major,
"minor": queue_version.minor
}
}),
);
if let Err(err) = app.send_renderer_report_command(report).await {
log::warn!("[QConnect] Failed to report active-renderer-ready: {err}");
}
}
/// Apply a server session-management event by delegating the locked critical
/// section to qconnect-app, then running the post-lock renderer-engine work
/// the returned `SessionApplyOutcome` asks for. Mirrors the Tauri
/// `apply_session_management_event`; the post-lock ordering (loop mode ->
/// local-playback handoff -> projection -> freeze -> watchdog) is identical.
async fn apply_session_management_event(&self, message_type: &str, payload: &Value) {
let Some(app) = self.app.get().and_then(Weak::upgrade) else {
return;
};
let identity = resolve_local_identity();
let outcome = app
.apply_session_management_event(message_type, payload, &identity)
.await;
if let Some(loop_mode) = outcome.apply_loop_mode {
if let Err(err) = qconnect_app::renderer::apply_remote_loop_mode(&self.engine, loop_mode)
.await
{
log::warn!("[QConnect] Failed to apply remote loop mode: {err}");
}
}
if outcome.sync_local_playback {
self.sync_local_playback_for_renderer_ownership().await;
}
if let Some(renderer_id) = outcome.remote_projection_renderer_id {
self.sync_active_renderer_projection(renderer_id).await;
}
if let Some(renderer_id) = outcome.disconnected_renderer_id {
app.freeze_active_renderer_projection(
renderer_id,
QconnectAppEvent::RendererDisconnected { renderer_id },
)
.await;
}
if let Some((renderer_id, generation)) = outcome.watchdog_arm {
app.arm_renderer_watchdog(renderer_id, generation);
}
}
/// When an active PEER renderer now owns playback, stop our local playback so
/// the two don't double-play. Mirrors the Tauri helper, with the engine seam
/// in place of `CoreBridge`.
async fn sync_local_playback_for_renderer_ownership(&self) {
let peer_renderer_active = {
let state = self.sync_state.lock().await;
is_peer_renderer_active(&state.session)
};
if !peer_renderer_active {
return;
}
let playback_state = self.engine.get_playback_state();
if playback_state.track_id == 0 {
return;
}
log::info!(
"[QConnect] Stopping local playback because active renderer is a peer (track_id={})",
playback_state.track_id
);
if let Err(err) = self.engine.stop() {
log::warn!("[QConnect] Failed to stop local playback after renderer handoff: {err}");
}
}
/// Refresh the cached projection for the active renderer and, when a peer owns
/// playback, align the local queue cursor to the peer's current track (so the
/// controller view + a later takeover land on the right track). Mirrors the
/// Tauri helper.
async fn sync_active_renderer_projection(&self, renderer_id: i32) {
let (queue_state, renderer_state, session_loop_mode, should_align_engine) = {
let state = self.sync_state.lock().await;
let Some(active_renderer_id) = state.session.active_renderer_id else {
return;
};
if active_renderer_id != renderer_id {
return;
}
(
state.last_remote_queue_state.clone(),
state
.session_renderer_states
.get(&active_renderer_id)
.cloned(),
state.session_loop_mode,
state.session.local_renderer_id != Some(active_renderer_id),
)
};
let (Some(queue_state), Some(renderer_state)) = (queue_state, renderer_state) else {
return;
};
let renderer_snapshot =
build_session_renderer_snapshot(&queue_state, Some(&renderer_state), session_loop_mode);
{
let mut state = self.sync_state.lock().await;
cache_renderer_snapshot(&mut state, &renderer_snapshot);
}
if !should_align_engine {
return;
}
let Some(current_track) = renderer_snapshot.current_track.as_ref() else {
return;
};
if let Err(err) =
qconnect_app::renderer::align_queue_cursor(&self.engine, current_track.track_id).await
{
log::warn!("[QConnect] Failed to sync peer renderer cursor into engine: {err}");
}
}
}
#[async_trait]
impl QconnectEventSink for SlintQconnectEventSink {
async fn on_event(&self, event: QconnectAppEvent) {
match &event {
QconnectAppEvent::SessionManagementEvent {
message_type,
payload,
} => {
log::info!(
"[QConnect] Session management: {} payload={}",
message_type,
serde_json::to_string(payload).unwrap_or_else(|_| "?".to_string())
);
self.apply_session_management_event(message_type, payload)
.await;
}
QconnectAppEvent::RendererUpdated(renderer_state) => {
log::info!(
"[QConnect] Renderer updated: playing_state={:?} volume={:?} position={:?}",
renderer_state.playing_state,
renderer_state.volume,
renderer_state.current_position_ms,
);
let mut sync_state = self.sync_state.lock().await;
cache_renderer_snapshot(&mut sync_state, renderer_state);
}
QconnectAppEvent::QueueUpdated(queue_state) => {
log::debug!(
"[QConnect] QueueUpdated: items={} shuffle_mode={} version={}.{}",
queue_state.queue_items.len(),
queue_state.shuffle_mode,
queue_state.version.major,
queue_state.version.minor,
);
{
let mut sync_state = self.sync_state.lock().await;
sync_state.last_remote_queue_state = Some(queue_state.clone());
}
if let Err(err) = qconnect_app::renderer::materialize_remote_queue(
&self.engine,
&self.sync_state,
queue_state,
)
.await
{
log::warn!("[QConnect] Failed to materialize remote queue: {err}");
}
// Reflect the remote queue change in the QBZ UI (queue sidebar +
// now-playing card). materialize already set the core queue +
// cursor; this just pushes it to Slint.
self.refresh_local_ui().await;
}
QconnectAppEvent::RendererCommandApplied { command, state } => {
log::info!("[QConnect] Renderer command applied: {:?}", command);
let became_active =
matches!(command, RendererCommand::SetActive { active: true });
if let Err(err) = qconnect_app::renderer::apply_renderer_command(
&self.engine,
&self.sync_state,
command,
state,
)
.await
{
log::warn!("[QConnect] Failed to apply renderer command: {err}");
} else if became_active {
self.report_active_renderer_ready().await;
}
// A SetState changes the current track / play-state — reflect it
// in the QBZ now-playing card + queue cursor highlight. (Other
// commands — volume/mute/shuffle/loop — don't move the track, so
// skip the card/art refresh.)
if matches!(command, RendererCommand::SetState { .. }) {
self.refresh_local_ui().await;
}
}
QconnectAppEvent::RendererUnreachable { renderer_id } => {
log::warn!("[QConnect] Renderer {renderer_id} unreachable");
crate::toast::error_weak(&self.window, "Qobuz Connect renderer unreachable");
}
QconnectAppEvent::RendererDisconnected { renderer_id } => {
log::warn!("[QConnect] Renderer {renderer_id} disconnected");
crate::toast::error_weak(&self.window, "Qobuz Connect renderer disconnected");
}
QconnectAppEvent::PlaybackError {
queue_item_id,
error_type,
..
} => {
// TODO(slint-qconnect-ui): when QBZ is the controller, auto-skip the
// current item. For now surface the failure.
log::warn!(
"[QConnect] Playback error on queue_item {queue_item_id}: {error_type:?}"
);
crate::toast::error_weak(&self.window, "Track unavailable on Qobuz Connect");
}
QconnectAppEvent::ResyncComplete => {
log::info!("[QConnect] Post-reconnect resync complete");
}
QconnectAppEvent::LifecycleChanged { state } => {
// TODO(slint-qconnect-ui): drive the connect badge state.
log::info!("[QConnect] Lifecycle -> {state:?} (UI badge TODO)");
}
QconnectAppEvent::Diagnostic {
channel, level, ..
} => {
log::debug!("[QConnect] diagnostic {channel} [{level}]");
}
_ => {}
}
// DEV diagnostics: log every event (with a relative timestamp) + refresh
// the live status block, so the QconnectDevModal reflects QC state at
// runtime without a rebuild.
crate::qconnect_service::dev_push_event(&self.window, dev_event_line(&event));
self.refresh_dev_status().await;
// TODO(slint-qconnect-ui): route the event into NowPlayingState (is-remote,
// cast-target, volume-locked) + the toast surface, mirroring the Tauri
// blanket `qconnect:event` emit the Svelte UI consumes.
}
}
/// Format a QConnect event into a one-line DEV-log entry. Big payloads
/// (QueueUpdated / SessionManagement) are summarized; the rest use Debug.
fn dev_event_line(event: &QconnectAppEvent) -> String {
match event {
QconnectAppEvent::SessionManagementEvent { message_type, .. } => {
format!("SESSION {message_type}")
}
QconnectAppEvent::QueueUpdated(q) => format!(
"QueueUpdated v{}.{} items={} shuffle={}",
q.version.major,
q.version.minor,
q.queue_items.len(),
q.shuffle_mode
),
QconnectAppEvent::RendererUpdated(r) => format!(
"RendererUpdated playing={:?} pos={:?}ms vol={:?}",
r.playing_state, r.current_position_ms, r.volume
),
QconnectAppEvent::RendererCommandApplied { command, .. } => format!("Cmd {command:?}"),
QconnectAppEvent::RendererUnreachable { renderer_id } => {
format!("RendererUnreachable #{renderer_id}")
}
QconnectAppEvent::RendererDisconnected { renderer_id } => {
format!("RendererDisconnected #{renderer_id}")
}
QconnectAppEvent::PlaybackError {
queue_item_id,
error_type,
..
} => format!("PlaybackError qid={queue_item_id} {error_type:?}"),
QconnectAppEvent::LifecycleChanged { state } => format!("Lifecycle {state:?}"),
QconnectAppEvent::ResyncComplete => "ResyncComplete".to_string(),
QconnectAppEvent::TransportConnected => "TransportConnected".to_string(),
QconnectAppEvent::TransportDisconnected => "TransportDisconnected".to_string(),
QconnectAppEvent::Diagnostic { channel, level, .. } => format!("diag {channel} [{level}]"),
other => format!("{other:?}"),
}
}
+837
View File
@@ -0,0 +1,837 @@
//! Slint QConnect service (pieces c + d, Phase S).
//!
//! `SlintQconnectService` is the connect-flow facade for the Slint frontend: it
//! owns the connection lifecycle and reproduces the Tauri
//! `QconnectServiceState::connect` recipe (build transport -> one shared
//! sync-state Mutex -> sink -> `QconnectApp::new` -> `set_app` -> `connect` ->
//! subscribe transport events BEFORE the spawn -> spawn `run_session_loop`), plus
//! controller bootstrap (JoinSession + AskForQueueState) and the deferred
//! renderer-join.
//!
//! `SlintSessionLoopHost` implements the frontend-agnostic
//! `qconnect_app::SessionLoopHost` so the shared session loop drives lifecycle,
//! reconnect bootstrap/resync, deferred renderer-join, and reconnect-exhausted
//! teardown through this adapter — exactly as `TauriSessionLoopHost` does.
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qconnect_app::renderer::PLAYING_STATE_STOPPED;
use qconnect_app::{
is_local_renderer_active, QconnectApp, QconnectAppEvent, QconnectEventSink,
QconnectFileAudioQualitySnapshot, QconnectLifecycleState, QconnectRemoteSyncState,
QueueCommandType, RendererReport, RendererReportType, SessionLoopHost,
};
use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig};
use serde_json::json;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::adapter::SlintAdapter;
use crate::qconnect_engine::SlintRendererEngine;
use crate::qconnect_event_sink::{SlintQconnectApp, SlintQconnectEventSink};
use crate::qconnect_transport::{
default_qconnect_device_info, default_qconnect_device_info_with_name, load_persisted_device_name,
resolve_transport_config, QconnectJoinSessionRequest, AUDIO_QUALITY_HIRES_LEVEL2,
BUFFER_STATE_OK,
};
use crate::AppWindow;
type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// Process-wide QConnect service singleton (one per app, like the playback
/// QueueController). Initialized once at shell setup; the connect trigger + the
/// future `*_if_remote` transport routing reach it through `service()`.
static SERVICE: std::sync::OnceLock<Arc<SlintQconnectService>> = std::sync::OnceLock::new();
/// Initialize the QConnect service singleton (idempotent — a second call returns
/// the existing instance, ignoring the new args).
pub fn init_service(runtime: Runtime, window: slint::Weak<AppWindow>) -> Arc<SlintQconnectService> {
SERVICE
.get_or_init(|| Arc::new(SlintQconnectService::new(runtime, window)))
.clone()
}
/// The initialized QConnect service, if shell setup has run.
pub fn service() -> Option<Arc<SlintQconnectService>> {
SERVICE.get().cloned()
}
// ---- DEV diagnostics (QconnectDevModal) ------------------------------------
// A rolling, runtime-inspectable event log + live status block, so QConnect can
// be debugged WITHOUT a rebuild (Slint builds are slow). Populated by the event
// sink; rendered by `ui/shell/QconnectDevModal.slint`.
static DEV_LOG: std::sync::OnceLock<std::sync::Mutex<std::collections::VecDeque<String>>> =
std::sync::OnceLock::new();
static DEV_START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
const DEV_LOG_CAP: usize = 150;
fn dev_log_text(push: Option<String>, clear: bool) -> String {
let buf = DEV_LOG.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
let mut guard = buf.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if clear {
guard.clear();
}
if let Some(line) = push {
guard.push_front(line);
while guard.len() > DEV_LOG_CAP {
guard.pop_back();
}
}
guard.iter().cloned().collect::<Vec<_>>().join("\n")
}
/// Append a formatted event line (with a relative timestamp) to the DEV log and
/// push the joined text to the modal. Called for every inbound QConnect event.
pub fn dev_push_event(weak: &slint::Weak<AppWindow>, line: String) {
let start = DEV_START.get_or_init(std::time::Instant::now);
let ms = start.elapsed().as_millis();
let text = dev_log_text(Some(format!("[{ms}ms] {line}")), false);
let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
w.global::<crate::QconnectDevState>().set_log_text(text.into());
});
}
/// Replace the DEV status block (session topology / renderer roles / queue).
pub fn dev_set_status(weak: &slint::Weak<AppWindow>, status: String) {
let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
w.global::<crate::QconnectDevState>()
.set_status(status.into());
});
}
/// Clear the DEV event log (wired to `QconnectDevState.clear()`).
pub fn dev_clear(weak: &slint::Weak<AppWindow>) {
let text = dev_log_text(None, true);
let _ = weak.upgrade_in_event_loop(move |w| {
use slint::ComponentHandle;
w.global::<crate::QconnectDevState>().set_log_text(text.into());
});
}
struct SlintQconnectRuntime {
app: Arc<SlintQconnectApp>,
#[allow(dead_code)] // retained for status/endpoint reporting in the UI step
config: WsTransportConfig,
event_loop: tokio::task::JoinHandle<()>,
#[allow(dead_code)] // shared with the app + sink; kept for future snapshot queries
sync_state: Arc<Mutex<QconnectRemoteSyncState>>,
}
#[derive(Default)]
struct SlintQconnectInner {
runtime: Option<SlintQconnectRuntime>,
last_error: Option<String>,
lifecycle_state: QconnectLifecycleState,
}
/// Dedup + gate a lifecycle transition: only emit while a runtime is alive and
/// the state actually changes. Mirrors the Tauri `update_lifecycle_state_if_running`.
async fn update_lifecycle_state_if_running(
inner: &Arc<Mutex<SlintQconnectInner>>,
sink: &SlintQconnectEventSink,
next: QconnectLifecycleState,
) {
let mut guard = inner.lock().await;
if guard.runtime.is_none() {
return;
}
if guard.lifecycle_state == next {
return;
}
guard.lifecycle_state = next;
drop(guard);
sink.on_event(QconnectAppEvent::LifecycleChanged { state: next })
.await;
}
pub struct SlintQconnectService {
inner: Arc<Mutex<SlintQconnectInner>>,
runtime: Runtime,
window: slint::Weak<AppWindow>,
#[allow(dead_code)] // wired to the device-name settings UI in a later step
custom_device_name: Arc<tokio::sync::RwLock<Option<String>>>,
/// Track-id list of the queue we last pushed to the session (controller-side
/// queue sync). Guards against re-pushing the same queue before the cloud's
/// echo `QueueUpdated` lands (which would otherwise double-push on the next
/// track tick). Cleared on disconnect.
last_pushed_queue_ids: Mutex<Option<Vec<u64>>>,
}
impl SlintQconnectService {
pub fn new(runtime: Runtime, window: slint::Weak<AppWindow>) -> Self {
let saved_name = load_persisted_device_name();
Self {
inner: Arc::new(Mutex::new(SlintQconnectInner::default())),
runtime,
window,
custom_device_name: Arc::new(tokio::sync::RwLock::new(saved_name)),
last_pushed_queue_ids: Mutex::new(None),
}
}
pub async fn is_running(&self) -> bool {
self.inner.lock().await.runtime.is_some()
}
/// Report this device's playback state to the cloud while QBZ is the ACTIVE
/// LOCAL renderer (driven by the playback poll loop). Mirrors the Tauri
/// `v2_qconnect_report_playback_state` essentials: self-gates on
/// is_local_renderer_active (no-op when not connected, or when a PEER owns
/// playback), resolves the current/next queue_item_id from the playing track
/// (the frontend doesn't track qids), sends a RndrSrvrStateUpdated, and keeps
/// the app's renderer position in sync. `position_ms`/`duration_ms` are in
/// MILLISECONDS (the QConnect protocol unit).
pub async fn report_playback_state(
&self,
playing_state: i32,
position_ms: i64,
duration_ms: i64,
track_id: u64,
) {
let (app, sync_state) = {
let guard = self.inner.lock().await;
match guard.runtime.as_ref() {
Some(runtime) => (Arc::clone(&runtime.app), Arc::clone(&runtime.sync_state)),
None => return,
}
};
// Only report when WE are the active renderer. When a peer renderer owns
// playback (QBZ is acting as a controller) the renderer reports come from
// the peer, not us.
{
let state = sync_state.lock().await;
if !is_local_renderer_active(&state.session) {
return;
}
}
let (current_qid, next_qid) =
resolve_queue_item_ids_by_track_id(&app, &sync_state, track_id).await;
let queue_version = app.queue_state_snapshot().await.version;
let report = RendererReport::new(
RendererReportType::RndrSrvrStateUpdated,
Uuid::new_v4().to_string(),
queue_version,
json!({
"playing_state": playing_state,
"buffer_state": BUFFER_STATE_OK,
"current_position": position_ms,
"duration": duration_ms,
"current_queue_item_id": current_qid,
"next_queue_item_id": next_qid,
"queue_version": {
"major": queue_version.major,
"minor": queue_version.minor
}
}),
);
if let Err(err) = app.send_renderer_report_command(report).await {
log::warn!("[QConnect] Failed to report playback state: {err}");
}
if position_ms >= 0 {
app.update_renderer_position(position_ms as u64).await;
}
// Report the live output format so the controller shows the correct
// quality badge (CD / Hi-Res). Reads the player's current output
// (sample_rate/bit_depth); channels default to stereo. Both reports dedup
// internally in qconnect-app, so calling them every report tick is cheap.
let player = self.runtime.core().player();
let sample_rate = player.state.get_sample_rate();
let bit_depth = player.state.get_bit_depth();
if let Some(snapshot) =
build_file_audio_quality_snapshot(sample_rate, bit_depth, QCONNECT_RENDERER_CHANNELS)
{
if let Err(err) = app
.report_file_audio_quality_if_changed(queue_version, snapshot)
.await
{
log::warn!("[QConnect] Failed to report file audio quality: {err}");
}
if let Err(err) = app
.report_device_audio_quality_if_changed(
queue_version,
snapshot.sampling_rate,
snapshot.bit_depth,
snapshot.nb_channels,
)
.await
{
log::warn!("[QConnect] Failed to report device audio quality: {err}");
}
}
}
/// Establish the QConnect session. Gated on an initialized API client (the
/// qws/createToken discovery needs it). Idempotent: a second call while a
/// runtime is alive is a no-op (the UI toggle stays on).
pub async fn connect(&self) -> Result<(), String> {
if !self.runtime.core().is_api_initialized().await {
return Err("Qobuz API is not initialized; cannot start Qobuz Connect".to_string());
}
// Claim the connect slot ATOMICALLY before the transport-config await, so
// two concurrent connect()s can't both build a runtime (the second would
// overwrite + leak the first's transport + event-loop task). A live
// runtime OR an in-flight `Connecting` both short-circuit to a no-op —
// the re-check the adversarial review flagged (`runtime.is_some()` only)
// had a TOCTOU window across the await; the `Connecting` sentinel closes it.
{
let mut guard = self.inner.lock().await;
if guard.runtime.is_some()
|| guard.lifecycle_state == QconnectLifecycleState::Connecting
{
log::info!(
"[QConnect] connect() called while already {:?}; no-op",
guard.lifecycle_state
);
return Ok(());
}
guard.lifecycle_state = QconnectLifecycleState::Connecting;
guard.last_error = None;
}
let config = match resolve_transport_config(&self.runtime).await {
Ok(config) => config,
Err(err) => {
// Release the Connecting claim so a later retry can proceed.
let mut guard = self.inner.lock().await;
if guard.runtime.is_none() {
guard.lifecycle_state = QconnectLifecycleState::Off;
}
return Err(err);
}
};
let transport = Arc::new(NativeWsTransport::new());
let sync_state = Arc::new(Mutex::new(QconnectRemoteSyncState::default()));
let engine = SlintRendererEngine::new(Arc::clone(&self.runtime));
let sink = Arc::new(SlintQconnectEventSink::new(
engine,
Arc::clone(&self.runtime),
Arc::clone(&sync_state),
self.window.clone(),
));
let app = Arc::new(QconnectApp::new(
Arc::clone(&transport) as Arc<NativeWsTransport>,
Arc::clone(&sink),
Arc::clone(&sync_state),
));
// Wire the owning app into the sink so it can emit reports (e.g.
// is_active=true after SetActive(true)) and drive session-apply.
sink.set_app(&app);
if let Err(err) = app.connect(config.clone()).await {
let mut guard = self.inner.lock().await;
guard.lifecycle_state = QconnectLifecycleState::Off;
let msg = format!("qconnect transport connect failed: {err}");
guard.last_error = Some(msg.clone());
return Err(msg);
}
// Subscribe to transport events SYNCHRONOUSLY here — after connect()
// returns and BEFORE the spawn / any further await — so the receiver is
// live before the WS handshake emits Connected / Subscribed /
// SessionEstablished / SESSION_STATE. tokio broadcast has no replay; a
// receiver created inside the spawned loop would race + drop those.
let transport_rx = app.subscribe_transport_events();
let idle_retry_active = config.reconnect_idle_retry_ms > 0;
let host: Arc<dyn SessionLoopHost> = Arc::new(SlintSessionLoopHost {
app: Arc::clone(&app),
sync_state: Arc::clone(&sync_state),
inner: Arc::clone(&self.inner),
sink: Arc::clone(&sink),
runtime: Arc::clone(&self.runtime),
});
let app_for_loop = Arc::clone(&app);
let event_loop = tokio::spawn(async move {
app_for_loop
.run_session_loop(host, transport_rx, idle_retry_active)
.await;
});
let runtime_app = Arc::clone(&app);
{
let mut guard = self.inner.lock().await;
guard.last_error = None;
guard.runtime = Some(SlintQconnectRuntime {
app,
config,
event_loop,
sync_state,
});
}
let custom_name = self.custom_device_name.read().await.clone();
if let Err(err) = bootstrap_remote_presence(&runtime_app, custom_name).await {
let _ = self.disconnect().await;
let mut guard = self.inner.lock().await;
guard.last_error = Some(format!("qconnect bootstrap failed: {err}"));
return Err(format!("qconnect bootstrap failed: {err}"));
}
Ok(())
}
pub async fn disconnect(&self) -> Result<(), String> {
let runtime = {
let mut guard = self.inner.lock().await;
// Always force Off — "disable QConnect" must succeed regardless of the
// current lifecycle (issue #358). The transport shutdown + the loop
// abort tear down any in-flight reconnect.
guard.lifecycle_state = QconnectLifecycleState::Off;
guard.runtime.take()
};
*self.last_pushed_queue_ids.lock().await = None;
if let Some(runtime) = runtime {
if let Err(err) = runtime.app.disconnect().await {
let mut guard = self.inner.lock().await;
guard.last_error = Some(format!("qconnect disconnect failed: {err}"));
}
runtime.event_loop.abort();
}
Ok(())
}
/// Controller-side queue sync: when the LOCAL queue differs from the session
/// queue (the user started a new album/playlist on QBZ while connected), push
/// it to the session so the controller (e.g. the iOS app) sees it. Called from
/// the playback poll loop on each track transition.
///
/// Echo-safe by construction: the inbound materialize path never calls this,
/// and we skip when the local queue already equals the cloud's last-applied
/// queue OR the last queue we pushed. Admission mirrors the webplayer's
/// `assessQconnectQueueSync` — all-or-nothing: a queue containing any local /
/// Plex track is refused whole (a renderer can only play Qobuz catalog ids;
/// offline qobuz_download IS eligible — its id is the real Qobuz id).
pub async fn sync_local_queue_if_changed(&self) {
let (app, sync_state) = {
let guard = self.inner.lock().await;
match guard.runtime.as_ref() {
Some(runtime) => (Arc::clone(&runtime.app), Arc::clone(&runtime.sync_state)),
None => return,
}
};
// Only push while WE are the active renderer (the user is driving QBZ).
{
let state = sync_state.lock().await;
if !is_local_renderer_active(&state.session) {
return;
}
}
let (tracks, current_index) = self.runtime.core().get_all_queue_tracks().await;
if tracks.is_empty() {
return;
}
let ordered_ids: Vec<u64> = tracks.iter().map(|track| track.id).collect();
// Echo-suppress: skip when this is the cloud's current queue (materialized
// inbound) so our own adoption / a remote queue change never bounces back.
{
let state = sync_state.lock().await;
if let Some(applied) = &state.last_applied_queue_state {
let applied_ids: Vec<u64> =
applied.queue_items.iter().map(|item| item.track_id).collect();
if applied_ids == ordered_ids {
return;
}
}
}
// ...and skip when we already pushed this exact queue (cloud echo pending).
{
let pushed = self.last_pushed_queue_ids.lock().await;
if pushed.as_deref() == Some(ordered_ids.as_slice()) {
return;
}
}
// Admission: refuse the whole push if any track isn't Qobuz-castable.
let all_eligible = tracks.iter().all(|track| {
let source = track
.source
.as_deref()
.unwrap_or("qobuz")
.to_ascii_lowercase();
source != "local" && source != "plex" && track.id > 0
});
if !all_eligible {
log::info!("[QConnect] Local queue has non-Qobuz tracks; not casting to Connect");
crate::toast::error_weak(&self.window, "Mixed queue — not cast to Qobuz Connect");
dev_push_event(&self.window, "-> queue push REFUSED (mixed/non-Qobuz)".to_string());
// Remember it so we don't re-toast on every track tick within this queue.
*self.last_pushed_queue_ids.lock().await = Some(ordered_ids);
return;
}
let count = ordered_ids.len();
let track_ids: Vec<i64> = ordered_ids.iter().map(|id| *id as i64).collect();
let start_index = current_index.unwrap_or(0);
let payload = json!({
"track_ids": track_ids,
"queue_position": start_index,
"shuffle_mode": false,
"shuffle_pivot_index": start_index,
"context_uuid": Uuid::new_v4().to_string(),
"autoplay_reset": true,
"autoplay_loading": false,
});
let command = app
.build_queue_command(QueueCommandType::CtrlSrvrQueueLoadTracks, payload)
.await;
match app.send_queue_command(command).await {
Ok(_) => {
log::info!(
"[QConnect] Pushed local queue to Connect ({count} tracks, start={start_index})"
);
dev_push_event(
&self.window,
format!("-> QueueLoadTracks {count} tracks start={start_index}"),
);
*self.last_pushed_queue_ids.lock().await = Some(ordered_ids);
}
Err(err) => log::warn!("[QConnect] Failed to push local queue: {err}"),
}
}
}
/// Slint-side implementation of the shared session-loop seams (piece c). Holds
/// the handles the loop reaches back into: the app (renderer join + state reads),
/// the shared sync accumulator, the service inner (lifecycle gating + teardown),
/// the sink (lifecycle emit), and the runtime (track duration read for the join).
struct SlintSessionLoopHost {
app: Arc<SlintQconnectApp>,
sync_state: Arc<Mutex<QconnectRemoteSyncState>>,
inner: Arc<Mutex<SlintQconnectInner>>,
sink: Arc<SlintQconnectEventSink>,
runtime: Runtime,
}
#[async_trait::async_trait]
impl SessionLoopHost for SlintSessionLoopHost {
async fn update_lifecycle(&self, state: QconnectLifecycleState) {
update_lifecycle_state_if_running(&self.inner, &self.sink, state).await;
}
async fn bootstrap_after_reconnect(&self) {
if let Err(err) = bootstrap_remote_presence(&self.app, None).await {
log::error!("[QConnect] Re-bootstrap after reconnect failed: {err}");
}
}
async fn deferred_renderer_join(&self, session_uuid: String, reason: i32) {
deferred_renderer_join(
&self.app,
&self.sync_state,
&self.runtime,
&session_uuid,
reason,
)
.await;
}
async fn on_reconnect_exhausted(
&self,
attempts: u32,
last_reason: String,
idle_retry_active: bool,
) -> bool {
{
let mut guard = self.inner.lock().await;
guard.lifecycle_state = QconnectLifecycleState::Exhausted;
guard.last_error = Some(format!(
"Reconnect attempts exhausted ({attempts}): {last_reason}"
));
if !idle_retry_active {
// Legacy terminate path: drop the runtime so a fresh connect()
// succeeds. Dropping it detaches this task's own JoinHandle (fine
// — the loop breaks right after) (#358).
guard.runtime = None;
}
}
// TODO(slint-qconnect-ui): surface the Exhausted lifecycle on the badge.
log::warn!("[QConnect] Reconnect exhausted ({attempts}): {last_reason}");
!idle_retry_active
}
async fn on_loop_error(&self, message: String) {
// TODO(slint-qconnect-ui): surface as a toast (Tauri emits qconnect:error).
log::error!("[QConnect] session loop error: {message}");
}
}
const QCONNECT_RENDERER_CHANNELS: i32 = 2;
const AUDIO_QUALITY_UNKNOWN: i32 = 0;
const AUDIO_QUALITY_MP3: i32 = 1;
const AUDIO_QUALITY_CD: i32 = 2;
const AUDIO_QUALITY_HIRES_L1: i32 = 3;
const AUDIO_QUALITY_HIRES_L2: i32 = 4;
const AUDIO_QUALITY_HIRES_L3: i32 = 5;
/// Classify a (sample_rate, bit_depth) output into the QConnect AudioQuality
/// level. Pure mirror of the Tauri `classify_qconnect_audio_quality`.
fn classify_audio_quality(sample_rate: u32, bit_depth: u32) -> i32 {
if sample_rate == 0 || bit_depth == 0 {
AUDIO_QUALITY_UNKNOWN
} else if sample_rate >= 384_000 {
AUDIO_QUALITY_HIRES_L3
} else if sample_rate >= 192_000 {
AUDIO_QUALITY_HIRES_L2
} else if bit_depth > 16 || sample_rate > 48_000 {
AUDIO_QUALITY_HIRES_L1
} else if sample_rate >= 44_100 {
AUDIO_QUALITY_CD
} else {
AUDIO_QUALITY_MP3
}
}
/// Build a file-audio-quality snapshot from the live output format, or None when
/// the format isn't known yet. Pure mirror of the Tauri
/// `build_qconnect_file_audio_quality_snapshot`.
fn build_file_audio_quality_snapshot(
sample_rate: u32,
bit_depth: u32,
nb_channels: i32,
) -> Option<QconnectFileAudioQualitySnapshot> {
if sample_rate == 0 || bit_depth == 0 {
return None;
}
Some(QconnectFileAudioQualitySnapshot {
sampling_rate: sample_rate as i32,
bit_depth: bit_depth as i32,
nb_channels,
audio_quality: classify_audio_quality(sample_rate, bit_depth),
})
}
/// Resolve the current + next `queue_item_id` for a playing `track_id` from the
/// cloud queue snapshot, caching the result into the sync accumulator. Mirrors
/// the Tauri `resolve_queue_item_ids_by_track_id`. Used by the renderer report so
/// the controller can map our playback to its queue rows.
async fn resolve_queue_item_ids_by_track_id(
app: &Arc<SlintQconnectApp>,
sync_state: &Arc<Mutex<QconnectRemoteSyncState>>,
track_id: u64,
) -> (Option<u64>, Option<u64>) {
let queue = app.queue_state_snapshot().await;
let (current_qid, next_qid, next_track_id) =
qconnect_app::queue_resolution::resolve_queue_item_ids_from_queue_state(&queue, track_id);
if let Some(current_qid) = current_qid {
let mut state = sync_state.lock().await;
state.last_renderer_queue_item_id = Some(current_qid);
state.last_renderer_next_queue_item_id = next_qid;
state.last_renderer_track_id = Some(track_id);
state.last_renderer_next_track_id = next_track_id;
(Some(current_qid), next_qid)
} else {
(None, None)
}
}
/// Controller-side bootstrap: JoinSession (works without a session_uuid) then ask
/// for the current queue state. The renderer-side join is deferred until the
/// server sends SESSION_STATE with a session_uuid (handled in the session loop).
/// Mirrors the Tauri `bootstrap_remote_presence`.
async fn bootstrap_remote_presence(
app: &Arc<SlintQconnectApp>,
custom_device_name: Option<String>,
) -> Result<(), String> {
let device_info = default_qconnect_device_info_with_name(custom_device_name.as_deref());
let join_payload = serde_json::to_value(QconnectJoinSessionRequest {
session_uuid: None,
device_info: Some(device_info),
})
.map_err(|err| format!("serialize join_session bootstrap payload: {err}"))?;
let join_command = app
.build_queue_command(QueueCommandType::CtrlSrvrJoinSession, join_payload)
.await;
let join_action_uuid = app
.send_queue_command(join_command)
.await
.map_err(|err| format!("send bootstrap ctrl_srvr_join_session failed: {err}"))?;
// JoinSession responds with session/renderer controller events not part of
// queue reducer correlation. Drop the pending slot so queue ops aren't blocked.
app.clear_pending_if_matches(&join_action_uuid).await;
let ask_queue_command = app
.build_queue_command(QueueCommandType::CtrlSrvrAskForQueueState, json!({}))
.await;
let ask_action_uuid = app
.send_queue_command(ask_queue_command)
.await
.map_err(|err| format!("send bootstrap ask_for_queue_state failed: {err}"))?;
app.clear_pending_if_matches(&ask_action_uuid).await;
log::info!(
"[QConnect] Bootstrap complete: controller joined, queue state requested. Renderer join deferred until session_uuid received."
);
Ok(())
}
/// Deferred renderer join: called from the session loop when SESSION_STATE with a
/// session_uuid arrives. Idempotent per uuid (P1-8). Mirrors the Tauri
/// `deferred_renderer_join`, reading the current track duration via
/// `runtime.core().get_track` instead of the Tauri CoreBridge.
async fn deferred_renderer_join(
app: &Arc<SlintQconnectApp>,
sync_state: &Arc<Mutex<QconnectRemoteSyncState>>,
runtime: &Runtime,
session_uuid: &str,
join_reason: i32,
) {
let already_joined = {
let st = sync_state.lock().await;
st.last_joined_session_uuid.as_deref() == Some(session_uuid)
};
if already_joined {
log::info!(
"[QConnect] Deferred join skipped (already joined session_uuid={session_uuid}); re-asking renderer state"
);
if let Err(err) = app.ask_for_active_renderer_state().await {
log::warn!("[QConnect] Idempotent-join AskForRendererState failed: {err}");
}
return;
}
let device_info = default_qconnect_device_info();
let queue_version_ref = app.queue_state_snapshot().await.version;
log::info!("[QConnect] Deferred renderer join with session_uuid={session_uuid}");
// 1. Renderer JoinSession with session_uuid.
let renderer_join_payload = json!({
"session_uuid": session_uuid,
"device_info": serde_json::to_value(&device_info).unwrap_or_default(),
"is_active": true,
"reason": join_reason,
"initial_state": {
"playing_state": PLAYING_STATE_STOPPED,
"buffer_state": BUFFER_STATE_OK,
"current_position": 0,
"duration": 0,
"queue_version": {
"major": queue_version_ref.major,
"minor": queue_version_ref.minor
}
}
});
let renderer_join_report = RendererReport::new(
RendererReportType::RndrSrvrJoinSession,
Uuid::new_v4().to_string(),
queue_version_ref,
renderer_join_payload,
);
if let Err(err) = app.send_renderer_report_command(renderer_join_report).await {
log::error!("[QConnect] Deferred renderer join failed: {err}");
return;
}
// 2. Initial StateUpdated report. At join time (e.g. reconnect mid-playback)
// we may already have a current track, so resolve the real duration + current/
// next queue_item_ids instead of hardcoding nulls.
let renderer = app.renderer_state_snapshot().await;
let queue = app.queue_state_snapshot().await;
let current_track_id = renderer.current_track.as_ref().map(|item| item.track_id);
let (current_qid, next_qid, _) = current_track_id
.map(|tid| {
qconnect_app::queue_resolution::resolve_queue_item_ids_from_queue_state(&queue, tid)
})
.unwrap_or((None, None, None));
let duration_secs = match current_track_id {
Some(track_id) => runtime
.core()
.get_track(track_id)
.await
.map(|track| u64::from(track.duration))
.unwrap_or(0),
None => 0,
};
let mut state_report_payload = json!({
"playing_state": PLAYING_STATE_STOPPED,
"buffer_state": BUFFER_STATE_OK,
"current_position": 0,
"duration": duration_secs,
"queue_version": {
"major": queue_version_ref.major,
"minor": queue_version_ref.minor
}
});
if let Some(qid) = current_qid {
state_report_payload["current_queue_item_id"] = json!(qid);
}
if let Some(qid) = next_qid {
state_report_payload["next_queue_item_id"] = json!(qid);
}
let state_report = RendererReport::new(
RendererReportType::RndrSrvrStateUpdated,
Uuid::new_v4().to_string(),
queue_version_ref,
state_report_payload,
);
if let Err(err) = app.send_renderer_report_command(state_report).await {
log::error!("[QConnect] Deferred renderer state report failed: {err}");
}
// 3. Report volume and max audio quality.
let volume_report = RendererReport::new(
RendererReportType::RndrSrvrVolumeChanged,
Uuid::new_v4().to_string(),
queue_version_ref,
json!({ "volume": 100 }),
);
if let Err(err) = app.send_renderer_report_command(volume_report).await {
log::error!("[QConnect] Deferred renderer volume report failed: {err}");
}
let max_quality_report = RendererReport::new(
RendererReportType::RndrSrvrMaxAudioQualityChanged,
Uuid::new_v4().to_string(),
queue_version_ref,
json!({ "max_audio_quality": AUDIO_QUALITY_HIRES_LEVEL2 }),
);
if let Err(err) = app.send_renderer_report_command(max_quality_report).await {
log::error!("[QConnect] Deferred renderer max quality report failed: {err}");
}
log::info!("[QConnect] Deferred renderer join complete");
// Re-request session state so the server sends an updated renderer list
// (including ourselves). Without this, the UI may not see QBZ as a renderer
// until the next reconnect cycle.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let refresh_command = app
.build_queue_command(QueueCommandType::CtrlSrvrAskForQueueState, json!({}))
.await;
if let Ok(action_uuid) = app.send_queue_command(refresh_command).await {
app.clear_pending_if_matches(&action_uuid).await;
log::info!("[QConnect] Re-requested session state after renderer join");
}
// Resync the active renderer's full state too, so a reconnect rejoin restores
// renderer state.
if let Err(err) = app.ask_for_active_renderer_state().await {
log::warn!("[QConnect] Post-join AskForRendererState failed: {err}");
}
// Record this session_uuid so a subsequent SESSION_STATE with the same uuid
// takes the idempotent fast-path above.
{
let mut st = sync_state.lock().await;
st.last_joined_session_uuid = Some(session_uuid.to_string());
}
}
@@ -15,6 +15,7 @@ use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qconnect_transport_ws::WsTransportConfig;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::adapter::SlintAdapter;
@@ -101,6 +102,90 @@ pub fn resolve_qconnect_friendly_name(custom_name: Option<&str>) -> String {
.unwrap_or_else(resolve_default_qconnect_device_name)
}
// ---------------------------------------------------------------------------
// Device-info + join payload shapes (piece a). Mirror the Tauri adapter
// `types.rs` `QconnectDeviceInfoPayload` / `QconnectDeviceCapabilitiesPayload` /
// `QconnectJoinSessionRequest` exactly so the wire frames the server sees are
// byte-identical between the two frontends. Built from the device-identity
// resolvers above; the uuid delegates to the shared `qbz_app::qconnect_identity`
// so a device keeps ONE identity across both frontends.
// ---------------------------------------------------------------------------
// AudioQuality wire levels: 1=mp3, 4=hires_l2(192k). Capabilities advertise the
// device's min/max decode support; VOLUME_REMOTE_CONTROL_ALLOWED(2) means a
// controller may set our volume.
pub const AUDIO_QUALITY_MP3: i32 = 1;
pub const AUDIO_QUALITY_HIRES_LEVEL2: i32 = 4;
const VOLUME_REMOTE_CONTROL_ALLOWED: i32 = 2;
/// Renderer buffer-state wire value for OK/ready (mirrors the Tauri adapter).
pub const BUFFER_STATE_OK: i32 = 2;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QconnectDeviceCapabilitiesPayload {
pub min_audio_quality: Option<i32>,
pub max_audio_quality: Option<i32>,
pub volume_remote_control: Option<i32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QconnectDeviceInfoPayload {
pub device_uuid: Option<String>,
pub friendly_name: Option<String>,
pub brand: Option<String>,
pub model: Option<String>,
pub serial_number: Option<String>,
pub device_type: Option<i32>,
pub capabilities: Option<QconnectDeviceCapabilitiesPayload>,
pub software_version: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QconnectJoinSessionRequest {
pub session_uuid: Option<String>,
pub device_info: Option<QconnectDeviceInfoPayload>,
}
/// Build the device-info payload with the default friendly name.
pub fn default_qconnect_device_info() -> QconnectDeviceInfoPayload {
default_qconnect_device_info_with_name(None)
}
/// Build the device-info payload, overriding the friendly name when a custom
/// device name is set. Mirrors the Tauri `default_qconnect_device_info_with_name`.
pub fn default_qconnect_device_info_with_name(
custom_name: Option<&str>,
) -> QconnectDeviceInfoPayload {
QconnectDeviceInfoPayload {
device_uuid: Some(resolve_qconnect_device_uuid()),
friendly_name: Some(resolve_qconnect_friendly_name(custom_name)),
brand: Some(resolve_qconnect_device_brand()),
model: Some(resolve_qconnect_device_model()),
serial_number: None,
device_type: Some(resolve_qconnect_device_type()),
capabilities: Some(QconnectDeviceCapabilitiesPayload {
min_audio_quality: Some(AUDIO_QUALITY_MP3),
max_audio_quality: Some(AUDIO_QUALITY_HIRES_LEVEL2),
volume_remote_control: Some(VOLUME_REMOTE_CONTROL_ALLOWED),
}),
software_version: Some(resolve_qconnect_software_version()),
}
}
/// Resolve THIS device's identity for injection into the frontend-agnostic
/// session-apply logic in qconnect-app (`apply_session_management_event` takes a
/// `LocalIdentity` so the crate never depends on the persistence layer). Mirrors
/// the Tauri `resolve_local_identity`.
pub fn resolve_local_identity() -> qconnect_app::LocalIdentity {
let info = default_qconnect_device_info();
qconnect_app::LocalIdentity {
device_uuid: info.device_uuid.unwrap_or_default(),
friendly_name: info.friendly_name,
brand: info.brand,
model: info.model,
device_type: info.device_type,
}
}
fn qconnect_settings_db_path() -> Option<std::path::PathBuf> {
qbz_app::qconnect_identity::qconnect_settings_db_path()
}
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
+7
View File
@@ -22,6 +22,7 @@ import { CreateFolderModal } from "../primitives/CreateFolderModal.slint";
import { FolderEditModal } from "../primitives/FolderEditModal.slint";
import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint";
import { TagEditorModal } from "../primitives/TagEditorModal.slint";
import { QconnectDevModal } from "QconnectDevModal.slint";
import { DragGhost } from "../primitives/DragGhost.slint";
import { Toast } from "../primitives/Toast.slint";
import { PlayerBar } from "PlayerBar.slint";
@@ -340,6 +341,12 @@ export component AppShell inherits Rectangle {
// Global "Tag editor" modal (local album metadata).
TagEditorModal { }
// Qobuz Connect DEV diagnostics modal (opened from the connect flyout).
QconnectDevModal {
width: 100%;
height: 100%;
}
// Floating track-drag ghost (follows the cursor, on top of all).
DragGhost { }
+43 -3
View File
@@ -19,7 +19,7 @@ import { IconButton, PlayButton } from "../primitives/BarControls.slint";
import { SeekBar } from "SeekBar.slint";
import { SongCard } from "SongCard.slint";
import { TransportControls } from "TransportControls.slint";
import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState } from "../state.slint";
import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState, QconnectDevState } from "../state.slint";
// IconButton + PlayButton now live in primitives/BarControls.slint (shared
// with TransportControls). ConnectButton + ToggleRow stay local (bar-only).
@@ -352,11 +352,11 @@ export component PlayerBar inherits Rectangle {
width: 8px;
height: 8px;
border-radius: 4px;
background: NowPlayingState.is-remote ? #22c55e : Theme.text-muted;
background: NowPlayingState.qconnect-connected ? #22c55e : Theme.text-muted;
}
}
Text {
text: NowPlayingState.is-remote
text: NowPlayingState.qconnect-connected
? @tr("Connected")
: @tr("Disconnected");
color: Theme.text-secondary;
@@ -388,6 +388,46 @@ export component PlayerBar inherits Rectangle {
}
}
}
// Connect / Disconnect toggle.
Rectangle {
height: 34px;
border-radius: Radius.sm;
background: connect-ta.pressed
? Theme.accent-pressed
: connect-ta.has-hover ? Theme.accent-hover : Theme.accent;
connect-ta := TouchArea {
clicked => {
NowPlayingState.qconnect-toggle();
qconnect-menu.close();
}
}
Text {
text: NowPlayingState.qconnect-connected
? @tr("Disconnect")
: @tr("Connect");
color: Theme.accent-text;
font-size: 13px;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// DEV: open the QConnect diagnostics modal.
diag-ta := TouchArea {
height: 22px;
mouse-cursor: pointer;
clicked => {
QconnectDevState.open = true;
qconnect-menu.close();
}
Text {
text: "Diagnostics";
color: diag-ta.has-hover ? Theme.text-secondary : Theme.text-muted;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
@@ -0,0 +1,139 @@
// Qobuz Connect — DEV diagnostics modal.
//
// Opened from the connect flyout's "Diagnostics" button. Shows a live status
// block (session topology / active+local renderer / queue version) and a rolling
// event log with relative timestamps. The Rust side (qconnect_service +
// qconnect_event_sink) populates QconnectDevState.status / .log. Backdrop or the
// X closes it (open is in-out, toggled directly here).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { QconnectDevState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TextEdit } from "std-widgets.slint";
export component QconnectDevModal inherits Rectangle {
visible: QconnectDevState.open;
if QconnectDevState.open: Rectangle {
background: #000000bf;
TouchArea {
mouse-cursor: default;
clicked => {
QconnectDevState.open = false;
}
}
Rectangle {
width: Math.min(root.width - 80px, 600px);
height: Math.min(root.height - 80px, 640px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
// Swallow clicks so they don't reach the backdrop.
TouchArea { }
VerticalLayout {
padding: 20px;
spacing: 14px;
// Header.
HorizontalLayout {
Text {
text: "Qobuz Connect — Diagnostics";
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
close-x := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
QconnectDevState.open = false;
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
// Live status block.
Rectangle {
background: Theme.surface-elevated;
border-radius: Radius.sm;
VerticalLayout {
padding: 12px;
Text {
text: QconnectDevState.status == ""
? "Not connected."
: QconnectDevState.status;
color: Theme.text-secondary;
font-size: 12px;
wrap: word-wrap;
}
}
}
// Event-log header + clear.
HorizontalLayout {
Text {
text: "Event log";
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
Rectangle {
width: clr.preferred-width + 22px;
height: 26px;
border-radius: Radius.sm;
background: clear-ta.has-hover ? Theme.surface-card : transparent;
border-width: 1px;
border-color: Theme.border-subtle;
clr := Text {
text: "Clear";
color: Theme.text-secondary;
font-size: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
clear-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
QconnectDevState.clear();
}
}
}
}
// Rolling event log (newest first) — read-only TextEdit so the
// lines are selectable + copyable (Ctrl+C), and it scrolls.
Rectangle {
vertical-stretch: 1;
TextEdit {
width: 100%;
height: 100%;
read-only: true;
wrap: word-wrap;
font-size: 11px;
text: QconnectDevState.log-text;
}
}
}
}
}
}
+21
View File
@@ -1942,6 +1942,10 @@ export global NowPlayingState {
// off and are wired by the QConnect epic; the bar respects them now.
in property <bool> is-remote: false;
in property <bool> volume-locked: false;
// Whether THIS device currently has a Qobuz Connect session running (drives
// the connect flyout's Connect/Disconnect button). Distinct from `is-remote`,
// which means a PEER renderer owns playback. Set by the connect callback.
in property <bool> qconnect-connected: false;
// Name of the active Qobuz Connect renderer (shown under the cast button
// when remote). Empty when local. Populated by the QConnect epic.
in property <string> cast-target;
@@ -1967,6 +1971,23 @@ export global NowPlayingState {
callback toggle-mute();
callback toggle-shuffle();
callback cycle-repeat();
// Qobuz Connect — toggle the session on/off from the bar's connect flyout.
// Wired in main.rs to SlintQconnectService::connect/disconnect.
callback qconnect-toggle();
}
// Qobuz Connect DEV diagnostics — live session topology + a rolling event log,
// rendered by QconnectDevModal (opened from the connect flyout). Lets us inspect
// QC behavior at runtime WITHOUT a rebuild (Slint builds are slow). `open` is
// in-out so the flyout button + the modal backdrop toggle it directly.
export global QconnectDevState {
in-out property <bool> open: false;
in property <string> status;
// Joined log text (newest first, one entry per line). A single string so the
// modal can render it in a read-only TextEdit (selectable + copyable).
in property <string> log-text;
callback clear();
}
// Single global hover-tooltip channel. Each hoverable control writes its text