refactor(qconnect): relocate pure session/liveness fns to shared crates (slice 0)
Move the frontend-agnostic pure functions and enums out of the Tauri
qconnect adapter into the shared crates, with zero behavior change. The
Tauri side keeps thin re-exports so all existing call sites compile
unchanged.
Into qconnect-app (new `session` module):
- should_arm_renderer_watchdog
- compute_connection_state + ServerActiveState + ConnectionDecision
- deferred_join_reason, should_reask_queue_state
- RendererStatus + from_wire
- quality_from_max_audio_quality
- QCONNECT_RENDERER_LOST_TIMEOUT_MS + JoinSession reason consts
Into qconnect-core (next to evaluate_remote_queue_admission):
- validate_track_origins_for_admission (pure over TrackOrigin); the
Tauri command wrapper stays thin and only maps wire origins to core
origins.
The 6 pure-fn unit tests move into the destination crates' test
modules. R5 honored: compute_connection_state matrix cells are relocated
verbatim, not changed.
cc
This commit is contained in:
14 files changed
+378
-269
No files matched your search
Generated
+1
@@ -5310,6 +5310,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"log",
|
"log",
|
||||||
"prost",
|
"prost",
|
||||||
|
"qbz-models",
|
||||||
"qconnect-core",
|
"qconnect-core",
|
||||||
"qconnect-protocol",
|
"qconnect-protocol",
|
||||||
"qconnect-transport-ws",
|
"qconnect-transport-ws",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ description = "UI-agnostic Qobuz Connect application adapter"
|
|||||||
qconnect-core = { path = "../qconnect-core" }
|
qconnect-core = { path = "../qconnect-core" }
|
||||||
qconnect-protocol = { path = "../qconnect-protocol" }
|
qconnect-protocol = { path = "../qconnect-protocol" }
|
||||||
qconnect-transport-ws = { path = "../qconnect-transport-ws" }
|
qconnect-transport-ws = { path = "../qconnect-transport-ws" }
|
||||||
|
qbz-models = { path = "../qbz-models" }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ mod app;
|
|||||||
mod error;
|
mod error;
|
||||||
mod events;
|
mod events;
|
||||||
mod feature_flags;
|
mod feature_flags;
|
||||||
|
pub mod session;
|
||||||
mod state;
|
mod state;
|
||||||
pub mod startup;
|
pub mod startup;
|
||||||
|
|
||||||
@@ -13,13 +14,20 @@ pub use app::QconnectApp;
|
|||||||
pub use startup::{compute_effective_startup, QconnectStartupMode};
|
pub use startup::{compute_effective_startup, QconnectStartupMode};
|
||||||
pub use error::QconnectAppError;
|
pub use error::QconnectAppError;
|
||||||
pub use events::{NoOpEventSink, QconnectAppEvent, QconnectEventSink};
|
pub use events::{NoOpEventSink, QconnectAppEvent, QconnectEventSink};
|
||||||
|
pub use session::{
|
||||||
|
compute_connection_state, deferred_join_reason, quality_from_max_audio_quality,
|
||||||
|
should_arm_renderer_watchdog, should_reask_queue_state, ConnectionDecision, RendererStatus,
|
||||||
|
ServerActiveState, JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION,
|
||||||
|
QCONNECT_RENDERER_LOST_TIMEOUT_MS,
|
||||||
|
};
|
||||||
pub use feature_flags::{
|
pub use feature_flags::{
|
||||||
QBZ_QCONNECT_PANEL_SWITCH, QBZ_QCONNECT_QUEUE_MODEL, QBZ_QCONNECT_STRICT_DOMAIN_ISOLATION,
|
QBZ_QCONNECT_PANEL_SWITCH, QBZ_QCONNECT_QUEUE_MODEL, QBZ_QCONNECT_STRICT_DOMAIN_ISOLATION,
|
||||||
QBZ_QCONNECT_TRANSPORT,
|
QBZ_QCONNECT_TRANSPORT,
|
||||||
};
|
};
|
||||||
pub use qconnect_core::{
|
pub use qconnect_core::{
|
||||||
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, HandoffIntent,
|
evaluate_remote_queue_admission, resolve_handoff_intent, validate_track_origins_for_admission,
|
||||||
QConnectQueueState, QConnectRendererState, QueueVersion, RendererCommand, TrackOrigin,
|
AdmissionDecision, HandoffIntent, QConnectQueueState, QConnectRendererState, QueueVersion,
|
||||||
|
RendererCommand, TrackOrigin,
|
||||||
};
|
};
|
||||||
pub use qconnect_protocol::{QueueCommandType, RendererReport, RendererReportType};
|
pub use qconnect_protocol::{QueueCommandType, RendererReport, RendererReportType};
|
||||||
pub use state::QconnectRuntimeState;
|
pub use state::QconnectRuntimeState;
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
//! Frontend-agnostic session/liveness primitives for Qobuz Connect.
|
||||||
|
//!
|
||||||
|
//! These are pure functions and pure data enums relocated out of the Tauri
|
||||||
|
//! adapter so that both the shipping Tauri adapter and a future Slint adapter
|
||||||
|
//! can share the exact same session-arbitration / liveness logic. Behavior is
|
||||||
|
//! byte-for-byte identical to the prior Tauri-side definitions.
|
||||||
|
|
||||||
|
use qbz_models::Quality;
|
||||||
|
|
||||||
|
/// Playing-state wire value for PLAYING. Mirrors the Tauri adapter's
|
||||||
|
/// `PLAYING_STATE_PLAYING` (the renderer reports playing_state == 2 while
|
||||||
|
/// actively playing). Kept private to the moved liveness predicate.
|
||||||
|
const PLAYING_STATE_PLAYING: i32 = 2;
|
||||||
|
|
||||||
|
/// JoinSession `reason` wire values (proto tag 3): a first join from a fresh
|
||||||
|
/// runtime is a controller request, a join after a transport drop carries the
|
||||||
|
/// reconnection reason so the server treats it as session continuity rather
|
||||||
|
/// than a brand-new controller (P1-2).
|
||||||
|
pub const JOIN_SESSION_REASON_CONTROLLER_REQUEST: i32 = 1;
|
||||||
|
pub const JOIN_SESSION_REASON_RECONNECTION: i32 = 2;
|
||||||
|
|
||||||
|
/// Official "renderer LOST" silence budget. A *playing* active peer renderer
|
||||||
|
/// that sends no RENDERER_STATE_UPDATED for this long is considered
|
||||||
|
/// unreachable (webplayer arms setTimeout(...,12e3) on onPlayerStateUpdated
|
||||||
|
/// while playingState==PLAY). See 05-sync-status-queue.md §1.
|
||||||
|
pub const QCONNECT_RENDERER_LOST_TIMEOUT_MS: u64 = 12_000;
|
||||||
|
|
||||||
|
/// Pure arming predicate for the renderer-liveness watchdog: arm only while the
|
||||||
|
/// active renderer is a peer AND its reported playing_state is PLAYING.
|
||||||
|
pub fn should_arm_renderer_watchdog(playing_state: Option<i32>, is_active_peer: bool) -> bool {
|
||||||
|
is_active_peer && playing_state == Some(PLAYING_STATE_PLAYING)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's view of who currently owns the active-renderer slot in a
|
||||||
|
/// SESSION_STATE frame, classified relative to us (P1-3).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ServerActiveState {
|
||||||
|
/// No renderer is active in the session.
|
||||||
|
None,
|
||||||
|
/// We are the active renderer.
|
||||||
|
Me,
|
||||||
|
/// Another renderer is active and reports PLAYING.
|
||||||
|
OtherPlaying,
|
||||||
|
/// Another renderer is active and reports a non-playing state.
|
||||||
|
OtherPaused,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of takeover arbitration on a SESSION_STATE frame (P1-3).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ConnectionDecision {
|
||||||
|
/// We should consider ourselves the active renderer.
|
||||||
|
pub should_be_active: bool,
|
||||||
|
/// We should emit CtrlSrvrSetActiveRenderer to claim the slot.
|
||||||
|
pub should_set_active_renderer: bool,
|
||||||
|
/// We should push our queue + state to the session.
|
||||||
|
pub should_set_queue: bool,
|
||||||
|
/// We should ask the renderer for its current state.
|
||||||
|
pub should_ask_queue: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure takeover arbitration ported from the web controller's
|
||||||
|
/// `computeConnectionState`. This is a REDUCED matrix: it captures the
|
||||||
|
/// shape and named outputs faithfully from the appendix prose, but the
|
||||||
|
/// exact web truth-table cell values were not dumped verbatim. The
|
||||||
|
/// `OtherPaused` vs `OtherPlaying` divergence and repeat-mode
|
||||||
|
/// reconciliation are pending a decompiled-bundle cross-check; repeat-mode
|
||||||
|
/// reconciliation is intentionally OUT of P1-3 scope (it overlaps the
|
||||||
|
/// existing `session_loop_mode` handling in `event_sink.rs`).
|
||||||
|
pub fn compute_connection_state(
|
||||||
|
was_active: bool,
|
||||||
|
was_playing: bool,
|
||||||
|
server: ServerActiveState,
|
||||||
|
queue_equal: bool,
|
||||||
|
) -> ConnectionDecision {
|
||||||
|
use ServerActiveState::*;
|
||||||
|
match server {
|
||||||
|
None => ConnectionDecision {
|
||||||
|
should_be_active: was_active,
|
||||||
|
should_set_active_renderer: was_active,
|
||||||
|
should_set_queue: was_active,
|
||||||
|
should_ask_queue: false,
|
||||||
|
},
|
||||||
|
Me => ConnectionDecision {
|
||||||
|
should_be_active: true,
|
||||||
|
should_set_active_renderer: false,
|
||||||
|
should_set_queue: !queue_equal && (was_active || was_playing),
|
||||||
|
should_ask_queue: queue_equal,
|
||||||
|
},
|
||||||
|
OtherPlaying | OtherPaused => ConnectionDecision {
|
||||||
|
should_be_active: false,
|
||||||
|
should_set_active_renderer: false,
|
||||||
|
should_set_queue: false,
|
||||||
|
should_ask_queue: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure selector for the JoinSession `reason`: a post-drop rejoin carries
|
||||||
|
/// RECONNECTION, the first join from a fresh runtime carries CONTROLLER_REQUEST
|
||||||
|
/// (P1-2).
|
||||||
|
pub fn deferred_join_reason(has_disconnected: bool) -> i32 {
|
||||||
|
if has_disconnected {
|
||||||
|
JOIN_SESSION_REASON_RECONNECTION
|
||||||
|
} else {
|
||||||
|
JOIN_SESSION_REASON_CONTROLLER_REQUEST
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure predicate (P1-8): keep re-asking for queue state after a Lagged
|
||||||
|
/// broadcast drop until the session_uuid is confirmed or the attempt budget is
|
||||||
|
/// spent. Stops immediately once the session_uuid is known.
|
||||||
|
pub fn should_reask_queue_state(
|
||||||
|
session_uuid_known: bool,
|
||||||
|
attempts: u32,
|
||||||
|
max_attempts: u32,
|
||||||
|
) -> bool {
|
||||||
|
!session_uuid_known && attempts < max_attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire renderer status from `MESSAGE_TYPE_SRVR_CTRL_RENDERER_STATE_UPDATED`
|
||||||
|
/// (`status` field, decoded at qconnect-protocol decoder.rs:801).
|
||||||
|
/// Wire enum: UNKNOWN=0, ACTIVE_CONNECTED=1, ACTIVE_DISCONNECTED=2, INACTIVE=3.
|
||||||
|
/// Per the official client's `Ya` collapse, UNKNOWN and any UNRECOGNIZED value
|
||||||
|
/// map to INACTIVE.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RendererStatus {
|
||||||
|
ActiveConnected,
|
||||||
|
ActiveDisconnected,
|
||||||
|
Inactive,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RendererStatus {
|
||||||
|
pub fn from_wire(value: Option<i64>) -> Self {
|
||||||
|
match value {
|
||||||
|
Some(1) => Self::ActiveConnected,
|
||||||
|
Some(2) => Self::ActiveDisconnected,
|
||||||
|
// 0 (UNKNOWN), 3 (INACTIVE), any UNRECOGNIZED value, and a missing
|
||||||
|
// field all collapse to INACTIVE.
|
||||||
|
_ => Self::Inactive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a QConnect `max_audio_quality` level to a qbz `Quality`.
|
||||||
|
/// QConnect levels: 0/1 ~ MP3, 2 ~ CD/Lossless, 3 ~ Hi-Res (<=96kHz),
|
||||||
|
/// 4 ~ Hi-Res (>96kHz), 5/None ~ uncapped. The qbz `Quality` enum only has
|
||||||
|
/// four variants (Mp3, Lossless, HiRes, UltraHiRes), so 4 and uncapped both
|
||||||
|
/// resolve to UltraHiRes.
|
||||||
|
pub fn quality_from_max_audio_quality(level: Option<i32>) -> Quality {
|
||||||
|
match level {
|
||||||
|
Some(l) if l <= 1 => Quality::Mp3,
|
||||||
|
Some(2) => Quality::Lossless,
|
||||||
|
Some(3) => Quality::HiRes,
|
||||||
|
Some(4) => Quality::UltraHiRes,
|
||||||
|
_ => Quality::UltraHiRes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deferred_join_reason_is_reconnection_only_after_a_drop() {
|
||||||
|
assert_eq!(
|
||||||
|
deferred_join_reason(false),
|
||||||
|
JOIN_SESSION_REASON_CONTROLLER_REQUEST
|
||||||
|
);
|
||||||
|
assert_eq!(deferred_join_reason(true), JOIN_SESSION_REASON_RECONNECTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reask_queue_state_stops_once_session_uuid_known_or_budget_spent() {
|
||||||
|
assert!(should_reask_queue_state(false, 0, 5));
|
||||||
|
assert!(should_reask_queue_state(false, 4, 5));
|
||||||
|
assert!(!should_reask_queue_state(false, 5, 5));
|
||||||
|
assert!(!should_reask_queue_state(true, 0, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_connection_state_matrix() {
|
||||||
|
use ServerActiveState::*;
|
||||||
|
let d = compute_connection_state(true, true, None, false);
|
||||||
|
assert!(
|
||||||
|
d.should_be_active
|
||||||
|
&& d.should_set_active_renderer
|
||||||
|
&& d.should_set_queue
|
||||||
|
&& !d.should_ask_queue
|
||||||
|
);
|
||||||
|
let d = compute_connection_state(true, false, Me, false);
|
||||||
|
assert!(d.should_be_active && !d.should_set_active_renderer && d.should_set_queue);
|
||||||
|
let d = compute_connection_state(false, false, Me, true);
|
||||||
|
assert!(d.should_ask_queue && !d.should_set_queue && !d.should_set_active_renderer);
|
||||||
|
let d = compute_connection_state(false, false, OtherPlaying, false);
|
||||||
|
assert!(!d.should_be_active && !d.should_set_active_renderer && d.should_ask_queue);
|
||||||
|
let d = compute_connection_state(false, false, None, false);
|
||||||
|
assert!(
|
||||||
|
!d.should_be_active
|
||||||
|
&& !d.should_set_active_renderer
|
||||||
|
&& !d.should_set_queue
|
||||||
|
&& !d.should_ask_queue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renderer_status_from_wire_maps_known_values() {
|
||||||
|
assert_eq!(RendererStatus::from_wire(Some(0)), RendererStatus::Inactive); // UNKNOWN collapses
|
||||||
|
assert_eq!(
|
||||||
|
RendererStatus::from_wire(Some(1)),
|
||||||
|
RendererStatus::ActiveConnected
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
RendererStatus::from_wire(Some(2)),
|
||||||
|
RendererStatus::ActiveDisconnected
|
||||||
|
);
|
||||||
|
assert_eq!(RendererStatus::from_wire(Some(3)), RendererStatus::Inactive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renderer_status_from_wire_collapses_unknown_and_missing_to_inactive() {
|
||||||
|
assert_eq!(RendererStatus::from_wire(Some(99)), RendererStatus::Inactive); // UNRECOGNIZED
|
||||||
|
assert_eq!(RendererStatus::from_wire(None), RendererStatus::Inactive); // absent field
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watchdog_arms_only_for_playing_active_peer() {
|
||||||
|
const PLAYING_STATE_UNKNOWN: i32 = 0;
|
||||||
|
const PLAYING_STATE_STOPPED: i32 = 1;
|
||||||
|
const PLAYING_STATE_PAUSED: i32 = 3;
|
||||||
|
// Arm: playing AND active peer.
|
||||||
|
assert!(should_arm_renderer_watchdog(
|
||||||
|
Some(PLAYING_STATE_PLAYING),
|
||||||
|
true
|
||||||
|
));
|
||||||
|
// Do not arm when paused/stopped/unknown even if active peer.
|
||||||
|
assert!(!should_arm_renderer_watchdog(
|
||||||
|
Some(PLAYING_STATE_PAUSED),
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_arm_renderer_watchdog(
|
||||||
|
Some(PLAYING_STATE_STOPPED),
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_arm_renderer_watchdog(
|
||||||
|
Some(PLAYING_STATE_UNKNOWN),
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!should_arm_renderer_watchdog(None, true));
|
||||||
|
// Do not arm when not an active peer (e.g. local renderer is active).
|
||||||
|
assert!(!should_arm_renderer_watchdog(
|
||||||
|
Some(PLAYING_STATE_PLAYING),
|
||||||
|
false
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quality_from_max_audio_quality_maps_levels() {
|
||||||
|
// qbz Quality has four variants: Mp3, Lossless (CD), HiRes (<=96kHz),
|
||||||
|
// UltraHiRes (>96kHz). QConnect levels collapse onto these.
|
||||||
|
assert_eq!(quality_from_max_audio_quality(Some(0)), Quality::Mp3);
|
||||||
|
assert_eq!(quality_from_max_audio_quality(Some(1)), Quality::Mp3);
|
||||||
|
assert_eq!(quality_from_max_audio_quality(Some(2)), Quality::Lossless);
|
||||||
|
assert_eq!(quality_from_max_audio_quality(Some(3)), Quality::HiRes);
|
||||||
|
assert_eq!(quality_from_max_audio_quality(Some(4)), Quality::UltraHiRes);
|
||||||
|
assert_eq!(quality_from_max_audio_quality(Some(5)), Quality::UltraHiRes);
|
||||||
|
assert_eq!(quality_from_max_audio_quality(None), Quality::UltraHiRes);
|
||||||
|
assert_eq!(quality_from_max_audio_quality(Some(99)), Quality::UltraHiRes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,11 +62,50 @@ pub fn resolve_handoff_intent(origin: TrackOrigin) -> HandoffIntent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Server-side backstop: re-evaluate EVERY track's origin, independent of any
|
||||||
|
/// command-level `origin`. A bare `track_id` cannot prove "Qobuz vs local/Plex"
|
||||||
|
/// on its own, so the frontend ships per-track origins and the gate re-validates
|
||||||
|
/// each one here. An empty list is blocked: we cannot prove the queue is
|
||||||
|
/// all-Qobuz, so we refuse rather than trust the command-level origin.
|
||||||
|
pub fn validate_track_origins_for_admission(origins: &[TrackOrigin]) -> AdmissionDecision {
|
||||||
|
if origins.is_empty() {
|
||||||
|
return AdmissionDecision::block("empty_track_origins_blocked");
|
||||||
|
}
|
||||||
|
for origin in origins {
|
||||||
|
let decision = evaluate_remote_queue_admission(*origin);
|
||||||
|
if !decision.accepted {
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdmissionDecision::allow("all_track_origins_qobuz")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use qbz_models::PlaybackSource;
|
use qbz_models::PlaybackSource;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_command_when_any_track_origin_is_non_qobuz() {
|
||||||
|
assert!(
|
||||||
|
validate_track_origins_for_admission(&[
|
||||||
|
TrackOrigin::QobuzOnline,
|
||||||
|
TrackOrigin::QobuzOfflineCache
|
||||||
|
])
|
||||||
|
.accepted
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!validate_track_origins_for_admission(&[
|
||||||
|
TrackOrigin::QobuzOnline,
|
||||||
|
TrackOrigin::LocalLibrary
|
||||||
|
])
|
||||||
|
.accepted
|
||||||
|
);
|
||||||
|
assert!(!validate_track_origins_for_admission(&[TrackOrigin::Plex]).accepted);
|
||||||
|
assert!(!validate_track_origins_for_admission(&[TrackOrigin::ExternalUnknown]).accepted);
|
||||||
|
assert!(!validate_track_origins_for_admission(&[]).accepted); // empty -> blocked
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn admission_matches_cast_predicate() {
|
fn admission_matches_cast_predicate() {
|
||||||
let pairs = [
|
let pairs = [
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ pub mod renderer;
|
|||||||
pub mod telemetry;
|
pub mod telemetry;
|
||||||
|
|
||||||
pub use admission::{
|
pub use admission::{
|
||||||
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, HandoffIntent,
|
evaluate_remote_queue_admission, resolve_handoff_intent, validate_track_origins_for_admission,
|
||||||
TrackOrigin,
|
AdmissionDecision, HandoffIntent, TrackOrigin,
|
||||||
};
|
};
|
||||||
pub use pending::{PendingActionError, PendingActionSlot, PendingCorrelation, PendingQueueAction};
|
pub use pending::{PendingActionError, PendingActionSlot, PendingCorrelation, PendingQueueAction};
|
||||||
pub use queue::{QConnectQueueState, QueueEvent, QueueItem, QueueVersion};
|
pub use queue::{QConnectQueueState, QueueEvent, QueueItem, QueueVersion};
|
||||||
|
|||||||
Generated
+1
@@ -5380,6 +5380,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"log",
|
"log",
|
||||||
|
"qbz-models",
|
||||||
"qconnect-core",
|
"qconnect-core",
|
||||||
"qconnect-protocol",
|
"qconnect-protocol",
|
||||||
"qconnect-transport-ws",
|
"qconnect-transport-ws",
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
//! to `QconnectServiceState` after a `runtime.check_requirements` gate.
|
//! to `QconnectServiceState` after a `runtime.check_requirements` gate.
|
||||||
|
|
||||||
use qconnect_app::{
|
use qconnect_app::{
|
||||||
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, QConnectQueueState,
|
evaluate_remote_queue_admission, resolve_handoff_intent,
|
||||||
QConnectRendererState, QconnectStartupMode, QueueCommandType, RendererReport,
|
validate_track_origins_for_admission as validate_core_track_origins_for_admission,
|
||||||
RendererReportType,
|
AdmissionDecision, QConnectQueueState, QConnectRendererState, QconnectStartupMode,
|
||||||
|
QueueCommandType, RendererReport, RendererReportType,
|
||||||
};
|
};
|
||||||
use tauri::{AppHandle, Emitter, Manager, State};
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -182,24 +183,19 @@ pub async fn v2_qconnect_evaluate_queue_admission(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server-side backstop: re-evaluate EVERY track's origin, independent of the
|
/// Thin Tauri-side wrapper over the frontend-agnostic
|
||||||
/// command-level `origin`. A bare `track_id` (a `u64`) cannot prove "Qobuz vs
|
/// `qconnect_core::validate_track_origins_for_admission`: maps the wire
|
||||||
/// local/Plex" on its own, so the frontend ships per-track origins and the gate
|
/// `QconnectTrackOrigin`s to core `TrackOrigin`s and delegates. The pure
|
||||||
/// re-validates each one here. An empty list is blocked: we cannot prove the
|
/// per-track backstop logic (empty-list block, any-non-Qobuz block) lives in
|
||||||
/// queue is all-Qobuz, so we refuse rather than trust the command-level origin.
|
/// `qconnect-core` next to `evaluate_remote_queue_admission`.
|
||||||
pub(super) fn validate_track_origins_for_admission(
|
pub(super) fn validate_track_origins_for_admission(
|
||||||
origins: &[QconnectTrackOrigin],
|
origins: &[QconnectTrackOrigin],
|
||||||
) -> AdmissionDecision {
|
) -> AdmissionDecision {
|
||||||
if origins.is_empty() {
|
let core_origins: Vec<_> = origins
|
||||||
return AdmissionDecision::block("empty_track_origins_blocked");
|
.iter()
|
||||||
}
|
.map(|origin| origin.into_core_origin())
|
||||||
for origin in origins {
|
.collect();
|
||||||
let decision = evaluate_remote_queue_admission(origin.into_core_origin());
|
validate_core_track_origins_for_admission(&core_origins)
|
||||||
if !decision.accepted {
|
|
||||||
return decision;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AdmissionDecision::allow("all_track_origins_qobuz")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -38,27 +38,12 @@ pub(super) const PLAYING_STATE_PLAYING: i32 = 2;
|
|||||||
pub(super) const PLAYING_STATE_PAUSED: i32 = 3;
|
pub(super) const PLAYING_STATE_PAUSED: i32 = 3;
|
||||||
pub(super) const BUFFER_STATE_OK: i32 = 2;
|
pub(super) const BUFFER_STATE_OK: i32 = 2;
|
||||||
|
|
||||||
/// JoinSession `reason` wire values (proto tag 3): a first join from a fresh
|
/// The renderer-liveness silence budget and the pure watchdog-arming predicate
|
||||||
/// runtime is a controller request, a join after a transport drop carries the
|
/// now live in the frontend-agnostic `qconnect_app::session` module. Re-exported
|
||||||
/// reconnection reason so the server treats it as session continuity rather
|
/// here so existing `super::…` references inside this module compile unchanged.
|
||||||
/// than a brand-new controller (P1-2).
|
/// (The JoinSession `reason` consts also moved there; they are consumed only by
|
||||||
pub(super) const JOIN_SESSION_REASON_CONTROLLER_REQUEST: i32 = 1;
|
/// `deferred_join_reason`, which moved too, so they are not re-exported here.)
|
||||||
pub(super) const JOIN_SESSION_REASON_RECONNECTION: i32 = 2;
|
pub(super) use qconnect_app::{should_arm_renderer_watchdog, QCONNECT_RENDERER_LOST_TIMEOUT_MS};
|
||||||
|
|
||||||
/// Official "renderer LOST" silence budget. A *playing* active peer renderer
|
|
||||||
/// that sends no RENDERER_STATE_UPDATED for this long is considered
|
|
||||||
/// unreachable (webplayer arms setTimeout(...,12e3) on onPlayerStateUpdated
|
|
||||||
/// while playingState==PLAY). See 05-sync-status-queue.md §1.
|
|
||||||
pub(super) const QCONNECT_RENDERER_LOST_TIMEOUT_MS: u64 = 12_000;
|
|
||||||
|
|
||||||
/// Pure arming predicate for the renderer-liveness watchdog: arm only while the
|
|
||||||
/// active renderer is a peer AND its reported playing_state is PLAYING.
|
|
||||||
pub(super) fn should_arm_renderer_watchdog(
|
|
||||||
playing_state: Option<i32>,
|
|
||||||
is_active_peer: bool,
|
|
||||||
) -> bool {
|
|
||||||
is_active_peer && playing_state == Some(PLAYING_STATE_PLAYING)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AudioQuality enum: 0=unknown, 1=mp3, 2=cd, 3=hires_l1, 4=hires_l2(192k), 5=hires_l3(384k)
|
// AudioQuality enum: 0=unknown, 1=mp3, 2=cd, 3=hires_l1, 4=hires_l2(192k), 5=hires_l3(384k)
|
||||||
pub(super) const AUDIO_QUALITY_UNKNOWN: i32 = 0;
|
pub(super) const AUDIO_QUALITY_UNKNOWN: i32 = 0;
|
||||||
|
|||||||
@@ -41,32 +41,15 @@ use super::{
|
|||||||
QconnectLifecycleState, QconnectMuteVolumeRequest, QconnectQueueVersionPayload,
|
QconnectLifecycleState, QconnectMuteVolumeRequest, QconnectQueueVersionPayload,
|
||||||
QconnectRemoteSyncState, QconnectSessionState, QconnectSetPlayerStateQueueItemPayload,
|
QconnectRemoteSyncState, QconnectSessionState, QconnectSetPlayerStateQueueItemPayload,
|
||||||
QconnectSetPlayerStateRequest, QconnectSetVolumeRequest, QconnectVisibleQueueProjection,
|
QconnectSetPlayerStateRequest, QconnectSetVolumeRequest, QconnectVisibleQueueProjection,
|
||||||
AUDIO_QUALITY_HIRES_LEVEL2, BUFFER_STATE_OK, JOIN_SESSION_REASON_CONTROLLER_REQUEST,
|
AUDIO_QUALITY_HIRES_LEVEL2, BUFFER_STATE_OK, PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING,
|
||||||
JOIN_SESSION_REASON_RECONNECTION, PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING,
|
|
||||||
PLAYING_STATE_STOPPED,
|
PLAYING_STATE_STOPPED,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Pure selector for the JoinSession `reason`: a post-drop rejoin carries
|
/// `deferred_join_reason` and `should_reask_queue_state` now live in the
|
||||||
/// RECONNECTION, the first join from a fresh runtime carries CONTROLLER_REQUEST
|
/// frontend-agnostic `qconnect_app::session` module. Re-exported here so the
|
||||||
/// (P1-2).
|
/// existing `deferred_join_reason`/`should_reask_queue_state` call sites in this
|
||||||
pub(super) fn deferred_join_reason(has_disconnected: bool) -> i32 {
|
/// module compile unchanged.
|
||||||
if has_disconnected {
|
pub(super) use qconnect_app::{deferred_join_reason, should_reask_queue_state};
|
||||||
JOIN_SESSION_REASON_RECONNECTION
|
|
||||||
} else {
|
|
||||||
JOIN_SESSION_REASON_CONTROLLER_REQUEST
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure predicate (P1-8): keep re-asking for queue state after a Lagged
|
|
||||||
/// broadcast drop until the session_uuid is confirmed or the attempt budget is
|
|
||||||
/// spent. Stops immediately once the session_uuid is known.
|
|
||||||
pub(super) fn should_reask_queue_state(
|
|
||||||
session_uuid_known: bool,
|
|
||||||
attempts: u32,
|
|
||||||
max_attempts: u32,
|
|
||||||
) -> bool {
|
|
||||||
!session_uuid_known && attempts < max_attempts
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prior-state snapshot captured from `sync_state` BEFORE a SESSION_STATE frame
|
/// Prior-state snapshot captured from `sync_state` BEFORE a SESSION_STATE frame
|
||||||
/// is applied by `event_sink`, plus the server's classified active state derived
|
/// is applied by `event_sink`, plus the server's classified active state derived
|
||||||
|
|||||||
@@ -18,69 +18,12 @@ use super::{
|
|||||||
QconnectQueueVersionPayload, QconnectRemoteSyncState, QconnectVisibleQueueProjection,
|
QconnectQueueVersionPayload, QconnectRemoteSyncState, QconnectVisibleQueueProjection,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The server's view of who currently owns the active-renderer slot in a
|
/// `ServerActiveState`, `ConnectionDecision`, and `compute_connection_state`
|
||||||
/// SESSION_STATE frame, classified relative to us (P1-3).
|
/// now live in the frontend-agnostic `qconnect_app::session` module. Re-exported
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
/// here so existing `super::session::…` references compile unchanged.
|
||||||
pub(super) enum ServerActiveState {
|
/// (`ConnectionDecision` is the return type — consumed via field access, so it
|
||||||
/// No renderer is active in the session.
|
/// is not named directly on the Tauri side; it lives in `qconnect_app`.)
|
||||||
None,
|
pub(super) use qconnect_app::{compute_connection_state, ServerActiveState};
|
||||||
/// We are the active renderer.
|
|
||||||
Me,
|
|
||||||
/// Another renderer is active and reports PLAYING.
|
|
||||||
OtherPlaying,
|
|
||||||
/// Another renderer is active and reports a non-playing state.
|
|
||||||
OtherPaused,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Outcome of takeover arbitration on a SESSION_STATE frame (P1-3).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub(super) struct ConnectionDecision {
|
|
||||||
/// We should consider ourselves the active renderer.
|
|
||||||
pub should_be_active: bool,
|
|
||||||
/// We should emit CtrlSrvrSetActiveRenderer to claim the slot.
|
|
||||||
pub should_set_active_renderer: bool,
|
|
||||||
/// We should push our queue + state to the session.
|
|
||||||
pub should_set_queue: bool,
|
|
||||||
/// We should ask the renderer for its current state.
|
|
||||||
pub should_ask_queue: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure takeover arbitration ported from the web controller's
|
|
||||||
/// `computeConnectionState`. This is a REDUCED matrix: it captures the
|
|
||||||
/// shape and named outputs faithfully from the appendix prose, but the
|
|
||||||
/// exact web truth-table cell values were not dumped verbatim. The
|
|
||||||
/// `OtherPaused` vs `OtherPlaying` divergence and repeat-mode
|
|
||||||
/// reconciliation are pending a decompiled-bundle cross-check; repeat-mode
|
|
||||||
/// reconciliation is intentionally OUT of P1-3 scope (it overlaps the
|
|
||||||
/// existing `session_loop_mode` handling in `event_sink.rs`).
|
|
||||||
pub(super) fn compute_connection_state(
|
|
||||||
was_active: bool,
|
|
||||||
was_playing: bool,
|
|
||||||
server: ServerActiveState,
|
|
||||||
queue_equal: bool,
|
|
||||||
) -> ConnectionDecision {
|
|
||||||
use ServerActiveState::*;
|
|
||||||
match server {
|
|
||||||
None => ConnectionDecision {
|
|
||||||
should_be_active: was_active,
|
|
||||||
should_set_active_renderer: was_active,
|
|
||||||
should_set_queue: was_active,
|
|
||||||
should_ask_queue: false,
|
|
||||||
},
|
|
||||||
Me => ConnectionDecision {
|
|
||||||
should_be_active: true,
|
|
||||||
should_set_active_renderer: false,
|
|
||||||
should_set_queue: !queue_equal && (was_active || was_playing),
|
|
||||||
should_ask_queue: queue_equal,
|
|
||||||
},
|
|
||||||
OtherPlaying | OtherPaused => ConnectionDecision {
|
|
||||||
should_be_active: false,
|
|
||||||
should_set_active_renderer: false,
|
|
||||||
should_set_queue: false,
|
|
||||||
should_ask_queue: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn queue_item_snapshot_for_cursor(
|
pub(super) fn queue_item_snapshot_for_cursor(
|
||||||
queue: &QConnectQueueState,
|
queue: &QConnectQueueState,
|
||||||
|
|||||||
+14
-101
@@ -49,50 +49,10 @@ fn normalizes_renderer_volume() {
|
|||||||
assert!((normalize_volume_to_fraction(125) - 1.0).abs() < f32::EPSILON);
|
assert!((normalize_volume_to_fraction(125) - 1.0).abs() < f32::EPSILON);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
// `deferred_join_reason`, `should_reask_queue_state`, and
|
||||||
fn deferred_join_reason_is_reconnection_only_after_a_drop() {
|
// `compute_connection_state` are now pure functions in
|
||||||
use super::service::deferred_join_reason;
|
// `qconnect_app::session`; their unit tests live in that crate's `session`
|
||||||
use super::{JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION};
|
// test module.
|
||||||
assert_eq!(
|
|
||||||
deferred_join_reason(false),
|
|
||||||
JOIN_SESSION_REASON_CONTROLLER_REQUEST
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
deferred_join_reason(true),
|
|
||||||
JOIN_SESSION_REASON_RECONNECTION
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reask_queue_state_stops_once_session_uuid_known_or_budget_spent() {
|
|
||||||
use super::service::should_reask_queue_state;
|
|
||||||
assert!(should_reask_queue_state(false, 0, 5));
|
|
||||||
assert!(should_reask_queue_state(false, 4, 5));
|
|
||||||
assert!(!should_reask_queue_state(false, 5, 5));
|
|
||||||
assert!(!should_reask_queue_state(true, 0, 5));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_connection_state_matrix() {
|
|
||||||
use super::session::{compute_connection_state, ServerActiveState::*};
|
|
||||||
let d = compute_connection_state(true, true, None, false);
|
|
||||||
assert!(
|
|
||||||
d.should_be_active && d.should_set_active_renderer && d.should_set_queue && !d.should_ask_queue
|
|
||||||
);
|
|
||||||
let d = compute_connection_state(true, false, Me, false);
|
|
||||||
assert!(d.should_be_active && !d.should_set_active_renderer && d.should_set_queue);
|
|
||||||
let d = compute_connection_state(false, false, Me, true);
|
|
||||||
assert!(d.should_ask_queue && !d.should_set_queue && !d.should_set_active_renderer);
|
|
||||||
let d = compute_connection_state(false, false, OtherPlaying, false);
|
|
||||||
assert!(!d.should_be_active && !d.should_set_active_renderer && d.should_ask_queue);
|
|
||||||
let d = compute_connection_state(false, false, None, false);
|
|
||||||
assert!(
|
|
||||||
!d.should_be_active
|
|
||||||
&& !d.should_set_active_renderer
|
|
||||||
&& !d.should_set_queue
|
|
||||||
&& !d.should_ask_queue
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn maps_outbound_command_type_to_protocol_command_type() {
|
fn maps_outbound_command_type_to_protocol_command_type() {
|
||||||
@@ -145,16 +105,11 @@ fn maps_qconnect_track_origin_to_core_origin_and_handoff() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
// The pure per-track admission backstop now lives in
|
||||||
fn blocks_command_when_any_track_origin_is_non_qobuz() {
|
// `qconnect_core::validate_track_origins_for_admission`; its unit test lives in
|
||||||
use super::commands::validate_track_origins_for_admission;
|
// that crate's `admission` test module. The thin Tauri wrapper
|
||||||
use super::QconnectTrackOrigin::*;
|
// `commands::validate_track_origins_for_admission` only maps wire origins to
|
||||||
assert!(validate_track_origins_for_admission(&[QobuzOnline, QobuzOfflineCache]).accepted);
|
// core origins.
|
||||||
assert!(!validate_track_origins_for_admission(&[QobuzOnline, LocalLibrary]).accepted);
|
|
||||||
assert!(!validate_track_origins_for_admission(&[Plex]).accepted);
|
|
||||||
assert!(!validate_track_origins_for_admission(&[ExternalUnknown]).accepted);
|
|
||||||
assert!(!validate_track_origins_for_admission(&[]).accepted); // empty -> blocked
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn refreshes_local_renderer_id_from_exact_device_uuid_match() {
|
fn refreshes_local_renderer_id_from_exact_device_uuid_match() {
|
||||||
@@ -1134,38 +1089,9 @@ fn device_uuid_persists_and_is_reused_across_calls() {
|
|||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
// `RendererStatus::from_wire` and `should_arm_renderer_watchdog` are now in
|
||||||
fn renderer_status_from_wire_maps_known_values() {
|
// `qconnect_app::session`; their unit tests live in that crate's `session` test
|
||||||
use super::RendererStatus;
|
// module.
|
||||||
assert_eq!(RendererStatus::from_wire(Some(0)), RendererStatus::Inactive); // UNKNOWN collapses
|
|
||||||
assert_eq!(RendererStatus::from_wire(Some(1)), RendererStatus::ActiveConnected);
|
|
||||||
assert_eq!(RendererStatus::from_wire(Some(2)), RendererStatus::ActiveDisconnected);
|
|
||||||
assert_eq!(RendererStatus::from_wire(Some(3)), RendererStatus::Inactive);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renderer_status_from_wire_collapses_unknown_and_missing_to_inactive() {
|
|
||||||
use super::RendererStatus;
|
|
||||||
assert_eq!(RendererStatus::from_wire(Some(99)), RendererStatus::Inactive); // UNRECOGNIZED
|
|
||||||
assert_eq!(RendererStatus::from_wire(None), RendererStatus::Inactive); // absent field
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn watchdog_arms_only_for_playing_active_peer() {
|
|
||||||
use super::{
|
|
||||||
should_arm_renderer_watchdog, PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING,
|
|
||||||
PLAYING_STATE_STOPPED, PLAYING_STATE_UNKNOWN,
|
|
||||||
};
|
|
||||||
// Arm: playing AND active peer.
|
|
||||||
assert!(should_arm_renderer_watchdog(Some(PLAYING_STATE_PLAYING), true));
|
|
||||||
// Do not arm when paused/stopped/unknown even if active peer.
|
|
||||||
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_PAUSED), true));
|
|
||||||
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_STOPPED), true));
|
|
||||||
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_UNKNOWN), true));
|
|
||||||
assert!(!should_arm_renderer_watchdog(None, true));
|
|
||||||
// Do not arm when not an active peer (e.g. local renderer is active).
|
|
||||||
assert!(!should_arm_renderer_watchdog(Some(PLAYING_STATE_PLAYING), false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_set_position_request_carries_position_and_queue_item_without_changing_play_state() {
|
fn build_set_position_request_carries_position_and_queue_item_without_changing_play_state() {
|
||||||
@@ -1204,21 +1130,8 @@ fn startup_retry_schedule_is_bounded_and_monotonic() {
|
|||||||
assert_eq!(*s.last().unwrap(), 30_000);
|
assert_eq!(*s.last().unwrap(), 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
// `quality_from_max_audio_quality` is now in `qconnect_app::session`; its unit
|
||||||
fn quality_from_max_audio_quality_maps_levels() {
|
// test lives in that crate's `session` test module.
|
||||||
use super::track_loading::quality_from_max_audio_quality;
|
|
||||||
use qbz_models::Quality;
|
|
||||||
// qbz Quality has four variants: Mp3, Lossless (CD), HiRes (<=96kHz),
|
|
||||||
// UltraHiRes (>96kHz). QConnect levels collapse onto these.
|
|
||||||
assert_eq!(quality_from_max_audio_quality(Some(0)), Quality::Mp3);
|
|
||||||
assert_eq!(quality_from_max_audio_quality(Some(1)), Quality::Mp3);
|
|
||||||
assert_eq!(quality_from_max_audio_quality(Some(2)), Quality::Lossless);
|
|
||||||
assert_eq!(quality_from_max_audio_quality(Some(3)), Quality::HiRes);
|
|
||||||
assert_eq!(quality_from_max_audio_quality(Some(4)), Quality::UltraHiRes);
|
|
||||||
assert_eq!(quality_from_max_audio_quality(Some(5)), Quality::UltraHiRes);
|
|
||||||
assert_eq!(quality_from_max_audio_quality(None), Quality::UltraHiRes);
|
|
||||||
assert_eq!(quality_from_max_audio_quality(Some(99)), Quality::UltraHiRes);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renderer_allows_remote_volume_defaults_allow_when_absent() {
|
fn renderer_allows_remote_volume_defaults_allow_when_absent() {
|
||||||
|
|||||||
@@ -6,11 +6,15 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use qbz_models::Quality;
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::core_bridge::CoreBridge;
|
use crate::core_bridge::CoreBridge;
|
||||||
|
|
||||||
|
/// `quality_from_max_audio_quality` now lives in the frontend-agnostic
|
||||||
|
/// `qconnect_app::session` module. Re-exported here so the existing call sites in
|
||||||
|
/// this module compile unchanged.
|
||||||
|
use qconnect_app::quality_from_max_audio_quality;
|
||||||
|
|
||||||
use super::QconnectRemoteSyncState;
|
use super::QconnectRemoteSyncState;
|
||||||
|
|
||||||
const LOAD_ATTEMPT_DEDUP_WINDOW: Duration = Duration::from_secs(5);
|
const LOAD_ATTEMPT_DEDUP_WINDOW: Duration = Duration::from_secs(5);
|
||||||
@@ -29,21 +33,6 @@ pub(super) fn should_reload_remote_track(
|
|||||||
playback_state.track_id != track_id
|
playback_state.track_id != track_id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a QConnect `max_audio_quality` level to a qbz `Quality`.
|
|
||||||
/// QConnect levels: 0/1 ~ MP3, 2 ~ CD/Lossless, 3 ~ Hi-Res (<=96kHz),
|
|
||||||
/// 4 ~ Hi-Res (>96kHz), 5/None ~ uncapped. The qbz `Quality` enum only has
|
|
||||||
/// four variants (Mp3, Lossless, HiRes, UltraHiRes), so 4 and uncapped both
|
|
||||||
/// resolve to UltraHiRes.
|
|
||||||
pub(super) fn quality_from_max_audio_quality(level: Option<i32>) -> Quality {
|
|
||||||
match level {
|
|
||||||
Some(l) if l <= 1 => Quality::Mp3,
|
|
||||||
Some(2) => Quality::Lossless,
|
|
||||||
Some(3) => Quality::HiRes,
|
|
||||||
Some(4) => Quality::UltraHiRes,
|
|
||||||
_ => Quality::UltraHiRes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_remote_track_into_player(
|
async fn load_remote_track_into_player(
|
||||||
bridge: &CoreBridge,
|
bridge: &CoreBridge,
|
||||||
track_id: u64,
|
track_id: u64,
|
||||||
|
|||||||
@@ -279,26 +279,6 @@ pub struct QconnectAskForRendererStateRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Wire renderer status from `MESSAGE_TYPE_SRVR_CTRL_RENDERER_STATE_UPDATED`
|
/// Wire renderer status from `MESSAGE_TYPE_SRVR_CTRL_RENDERER_STATE_UPDATED`
|
||||||
/// (`status` field, decoded at qconnect-protocol decoder.rs:801).
|
/// now lives in the frontend-agnostic `qconnect_app::session` module. Re-exported
|
||||||
/// Wire enum: UNKNOWN=0, ACTIVE_CONNECTED=1, ACTIVE_DISCONNECTED=2, INACTIVE=3.
|
/// here so the command surface (`pub use types::*`) keeps exposing it unchanged.
|
||||||
/// Per the official client's `Ya` collapse, UNKNOWN and any UNRECOGNIZED value
|
pub use qconnect_app::RendererStatus;
|
||||||
/// map to INACTIVE.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum RendererStatus {
|
|
||||||
ActiveConnected,
|
|
||||||
ActiveDisconnected,
|
|
||||||
Inactive,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RendererStatus {
|
|
||||||
pub(super) fn from_wire(value: Option<i64>) -> Self {
|
|
||||||
match value {
|
|
||||||
Some(1) => Self::ActiveConnected,
|
|
||||||
Some(2) => Self::ActiveDisconnected,
|
|
||||||
// 0 (UNKNOWN), 3 (INACTIVE), any UNRECOGNIZED value, and a missing
|
|
||||||
// field all collapse to INACTIVE.
|
|
||||||
_ => Self::Inactive,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user