From 44876d01bededa3e19c25d366f9ac88e314a883e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:11:37 -0600 Subject: [PATCH] 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 --- crates/qbz-slint/src/main.rs | 48 + crates/qbz-slint/src/playback.rs | 49 +- crates/qbz-slint/src/qconnect_event_sink.rs | 463 ++++++++++ crates/qbz-slint/src/qconnect_service.rs | 837 ++++++++++++++++++ crates/qbz-slint/src/qconnect_transport.rs | 85 ++ crates/qbz-slint/ui/app.slint | 2 +- crates/qbz-slint/ui/shell/AppShell.slint | 7 + crates/qbz-slint/ui/shell/PlayerBar.slint | 46 +- .../qbz-slint/ui/shell/QconnectDevModal.slint | 139 +++ crates/qbz-slint/ui/state.slint | 21 + 10 files changed, 1691 insertions(+), 6 deletions(-) create mode 100644 crates/qbz-slint/src/qconnect_event_sink.rs create mode 100644 crates/qbz-slint/src/qconnect_service.rs create mode 100644 crates/qbz-slint/ui/shell/QconnectDevModal.slint diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 16e0cb8d7..5db002946 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -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> { }); } + // 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::() + .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::() + .set_qconnect_connected(connected); + }); + }); + }); + } + { + let weak = window.as_weak(); + window + .global::() + .on_clear(move || { + qconnect_service::dev_clear(&weak); + }); + } + // Transport — wired through the NowPlayingState global callbacks. { let runtime = app_runtime.clone(); diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index 7a39e7529..46897bd2b 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -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) { +pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak) { 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; diff --git a/crates/qbz-slint/src/qconnect_event_sink.rs b/crates/qbz-slint/src/qconnect_event_sink.rs new file mode 100644 index 000000000..cb9614315 --- /dev/null +++ b/crates/qbz-slint/src/qconnect_event_sink.rs @@ -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; + +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>, + /// THE shared remote-sync accumulator (one Mutex, shared with `QconnectApp`). + sync_state: Arc>, + /// 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>>, + /// Window handle for future UI surfacing (toasts, badge state). Unused until + /// the QConnect UI-polish step. + #[allow(dead_code)] + window: slint::Weak, +} + +impl SlintQconnectEventSink { + pub fn new( + engine: SlintRendererEngine, + runtime: Arc>, + sync_state: Arc>, + window: slint::Weak, + ) -> 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::>() + .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) { + 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:?}"), + } +} diff --git a/crates/qbz-slint/src/qconnect_service.rs b/crates/qbz-slint/src/qconnect_service.rs new file mode 100644 index 000000000..863ae271c --- /dev/null +++ b/crates/qbz-slint/src/qconnect_service.rs @@ -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>; + +/// 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> = 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) -> Arc { + SERVICE + .get_or_init(|| Arc::new(SlintQconnectService::new(runtime, window))) + .clone() +} + +/// The initialized QConnect service, if shell setup has run. +pub fn service() -> Option> { + 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::OnceLock::new(); +static DEV_START: std::sync::OnceLock = std::sync::OnceLock::new(); +const DEV_LOG_CAP: usize = 150; + +fn dev_log_text(push: Option, 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::>().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, 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::().set_log_text(text.into()); + }); +} + +/// Replace the DEV status block (session topology / renderer roles / queue). +pub fn dev_set_status(weak: &slint::Weak, status: String) { + let _ = weak.upgrade_in_event_loop(move |w| { + use slint::ComponentHandle; + w.global::() + .set_status(status.into()); + }); +} + +/// Clear the DEV event log (wired to `QconnectDevState.clear()`). +pub fn dev_clear(weak: &slint::Weak) { + let text = dev_log_text(None, true); + let _ = weak.upgrade_in_event_loop(move |w| { + use slint::ComponentHandle; + w.global::().set_log_text(text.into()); + }); +} + +struct SlintQconnectRuntime { + app: Arc, + #[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>, +} + +#[derive(Default)] +struct SlintQconnectInner { + runtime: Option, + last_error: Option, + 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>, + 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>, + runtime: Runtime, + window: slint::Weak, + #[allow(dead_code)] // wired to the device-name settings UI in a later step + custom_device_name: Arc>>, + /// 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>>, +} + +impl SlintQconnectService { + pub fn new(runtime: Runtime, window: slint::Weak) -> 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, + 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 = 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 = 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 = + 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 = 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, + sync_state: Arc>, + inner: Arc>, + sink: Arc, + 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 { + 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, + sync_state: &Arc>, + track_id: u64, +) -> (Option, Option) { + 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, + custom_device_name: Option, +) -> 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, + sync_state: &Arc>, + 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()); + } +} diff --git a/crates/qbz-slint/src/qconnect_transport.rs b/crates/qbz-slint/src/qconnect_transport.rs index 425e61f23..4e458253d 100644 --- a/crates/qbz-slint/src/qconnect_transport.rs +++ b/crates/qbz-slint/src/qconnect_transport.rs @@ -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, + pub max_audio_quality: Option, + pub volume_remote_control: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct QconnectDeviceInfoPayload { + pub device_uuid: Option, + pub friendly_name: Option, + pub brand: Option, + pub model: Option, + pub serial_number: Option, + pub device_type: Option, + pub capabilities: Option, + pub software_version: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct QconnectJoinSessionRequest { + pub session_uuid: Option, + pub device_info: Option, +} + +/// 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 { qbz_app::qconnect_identity::qconnect_settings_db_path() } diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 0b0b78e8e..d0047e942 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -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`. diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index 3dc9ffac1..71fea4cb4 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -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 { } diff --git a/crates/qbz-slint/ui/shell/PlayerBar.slint b/crates/qbz-slint/ui/shell/PlayerBar.slint index 6446a9a28..c0565e66e 100644 --- a/crates/qbz-slint/ui/shell/PlayerBar.slint +++ b/crates/qbz-slint/ui/shell/PlayerBar.slint @@ -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; + } + } } } } diff --git a/crates/qbz-slint/ui/shell/QconnectDevModal.slint b/crates/qbz-slint/ui/shell/QconnectDevModal.slint new file mode 100644 index 000000000..038c0249c --- /dev/null +++ b/crates/qbz-slint/ui/shell/QconnectDevModal.slint @@ -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; + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 091bbb9e0..f5bd56745 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -1942,6 +1942,10 @@ export global NowPlayingState { // off and are wired by the QConnect epic; the bar respects them now. in property is-remote: false; in property 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 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 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 open: false; + in property 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 log-text; + callback clear(); } // Single global hover-tooltip channel. Each hoverable control writes its text