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:
10 files changed
+1691
-6
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user