feat(qconnect-app): apply renderer commands from WS transport events

-cx
This commit is contained in:
vicrodh committed 2026-02-21 12:35:20 -06:00
1 parent 0124728aa9
commit fc0135246a
4 files changed
+142 -8

No files matched your search

+125 -4
View File
@@ -4,12 +4,12 @@ use std::{
}; };
use qconnect_core::{ use qconnect_core::{
apply_event, telemetry, PendingCorrelation, PendingQueueAction, QConnectQueueState, QueueEvent, apply_event, apply_renderer_command, telemetry, PendingCorrelation, PendingQueueAction,
QueueItem, QConnectQueueState, QConnectRendererState, QueueEvent, QueueItem, RendererCommand,
}; };
use qconnect_protocol::{ use qconnect_protocol::{
build_qconnect_outbound_envelope, parse_inbound_event, InboundEnvelope, QueueCommand, build_qconnect_outbound_envelope, parse_inbound_event, InboundEnvelope, QueueCommand,
QueueCommandType, QueueEventType, QueueServerEvent, QueueCommandType, QueueEventType, QueueServerEvent, RendererCommandType, RendererServerCommand,
}; };
use qconnect_transport_ws::{TransportEvent, WsTransport, WsTransportConfig}; use qconnect_transport_ws::{TransportEvent, WsTransport, WsTransportConfig};
use serde_json::Value; use serde_json::Value;
@@ -94,6 +94,10 @@ where
self.state.lock().await.queue.clone() self.state.lock().await.queue.clone()
} }
pub async fn renderer_state_snapshot(&self) -> QConnectRendererState {
self.state.lock().await.renderer.clone()
}
pub async fn build_queue_command( pub async fn build_queue_command(
&self, &self,
command_type: QueueCommandType, command_type: QueueCommandType,
@@ -171,6 +175,9 @@ where
TransportEvent::InboundQueueServerEvent(event) => { TransportEvent::InboundQueueServerEvent(event) => {
self.apply_server_event(event).await?; self.apply_server_event(event).await?;
} }
TransportEvent::InboundRendererServerCommand(command) => {
self.apply_renderer_server_command(command).await;
}
TransportEvent::Authenticated TransportEvent::Authenticated
| TransportEvent::Subscribed | TransportEvent::Subscribed
| TransportEvent::ReconnectScheduled { .. } | TransportEvent::ReconnectScheduled { .. }
@@ -269,6 +276,22 @@ where
Ok(()) Ok(())
} }
async fn apply_renderer_server_command(&self, command: RendererServerCommand) {
let Some(renderer_command) = map_renderer_server_command(&command) else {
return;
};
let snapshot = {
let mut state = self.state.lock().await;
apply_renderer_command(&mut state.renderer, &renderer_command, now_ms());
state.renderer.clone()
};
self.sink
.on_event(QconnectAppEvent::RendererUpdated(snapshot))
.await;
}
fn spawn_pending_timeout_watch(&self, action_uuid: String) { fn spawn_pending_timeout_watch(&self, action_uuid: String) {
let app = self.clone(); let app = self.clone();
tokio::spawn(async move { tokio::spawn(async move {
@@ -472,6 +495,40 @@ fn map_server_event(event: &QueueServerEvent, current: &QConnectQueueState) -> Q
} }
} }
fn map_renderer_server_command(command: &RendererServerCommand) -> Option<RendererCommand> {
match command.command_type {
RendererCommandType::SrvrRndrSetState => Some(RendererCommand::SetState {
playing_state: parse_i32(&command.payload, "playing_state"),
current_position_ms: parse_u64(&command.payload, "current_position"),
current_track: parse_renderer_track(&command.payload, "current_track"),
next_track: parse_renderer_track(&command.payload, "next_track"),
}),
RendererCommandType::SrvrRndrSetVolume => Some(RendererCommand::SetVolume {
volume: parse_i32(&command.payload, "volume"),
volume_delta: parse_i32(&command.payload, "volume_delta"),
}),
RendererCommandType::SrvrRndrSetActive => Some(RendererCommand::SetActive {
active: parse_bool(&command.payload, "active", false),
}),
RendererCommandType::SrvrRndrSetMaxAudioQuality => {
parse_i32(&command.payload, "max_audio_quality")
.map(|max_audio_quality| RendererCommand::SetMaxAudioQuality { max_audio_quality })
}
RendererCommandType::SrvrRndrSetLoopMode => parse_i32(&command.payload, "loop_mode")
.map(|loop_mode| RendererCommand::SetLoopMode { loop_mode }),
RendererCommandType::SrvrRndrSetShuffleMode => Some(RendererCommand::SetShuffleMode {
shuffle_mode: parse_bool(&command.payload, "shuffle_mode", false),
}),
RendererCommandType::SrvrRndrMuteVolume => Some(RendererCommand::MuteVolume {
value: parse_bool(&command.payload, "value", false),
}),
}
}
fn parse_renderer_track(payload: &Value, field: &str) -> Option<QueueItem> {
payload.get(field).and_then(parse_queue_item)
}
fn parse_queue_items(payload: &Value, field: &str) -> Vec<QueueItem> { fn parse_queue_items(payload: &Value, field: &str) -> Vec<QueueItem> {
payload payload
.get(field) .get(field)
@@ -530,6 +587,10 @@ fn parse_optional_bool(payload: &Value, field: &str) -> Option<bool> {
payload.get(field).and_then(Value::as_bool) payload.get(field).and_then(Value::as_bool)
} }
fn parse_i32(payload: &Value, field: &str) -> Option<i32> {
payload.get(field).and_then(value_as_i32)
}
fn parse_usize_list(payload: &Value, field: &str) -> Vec<usize> { fn parse_usize_list(payload: &Value, field: &str) -> Vec<usize> {
payload payload
.get(field) .get(field)
@@ -550,6 +611,13 @@ fn value_as_u64(value: &Value) -> Option<u64> {
.or_else(|| value.as_i64().and_then(|entry| u64::try_from(entry).ok())) .or_else(|| value.as_i64().and_then(|entry| u64::try_from(entry).ok()))
} }
fn value_as_i32(value: &Value) -> Option<i32> {
value
.as_i64()
.and_then(|entry| i32::try_from(entry).ok())
.or_else(|| value.as_u64().and_then(|entry| i32::try_from(entry).ok()))
}
fn value_to_string(value: &Value) -> String { fn value_to_string(value: &Value) -> String {
value value
.as_str() .as_str()
@@ -577,7 +645,10 @@ mod tests {
use crate::{QconnectAppEvent, QconnectEventSink}; use crate::{QconnectAppEvent, QconnectEventSink};
use super::QconnectApp; use super::QconnectApp;
use qconnect_protocol::{QueueCommandType, QueueEventType, QueueServerEvent}; use qconnect_protocol::{
QueueCommandType, QueueEventType, QueueServerEvent, RendererCommandType,
RendererServerCommand,
};
use qconnect_transport_ws::WsTransportConfig; use qconnect_transport_ws::WsTransportConfig;
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
@@ -737,4 +808,54 @@ mod tests {
"ignored late queue error should not mutate queue" "ignored late queue error should not mutate queue"
); );
} }
#[tokio::test]
async fn inbound_renderer_command_updates_renderer_state() {
let (app, sink, _, _events_rx) = build_connected_app().await;
app.apply_renderer_server_command(RendererServerCommand {
command_type: RendererCommandType::SrvrRndrSetState,
payload: json!({
"playing_state": 2,
"current_position": 65321,
"current_track": {
"track_context_uuid": "ctx-remote",
"track_id": 777001,
"queue_item_id": 991
}
}),
})
.await;
app.apply_renderer_server_command(RendererServerCommand {
command_type: RendererCommandType::SrvrRndrSetVolume,
payload: json!({
"volume": 52,
"volume_delta": 3
}),
})
.await;
let renderer = app.renderer_state_snapshot().await;
assert_eq!(renderer.playing_state, Some(2));
assert_eq!(renderer.current_position_ms, Some(65_321));
assert_eq!(renderer.volume, Some(55));
assert_eq!(renderer.volume_delta, Some(3));
assert_eq!(
renderer
.current_track
.as_ref()
.map(|item| item.queue_item_id),
Some(991)
);
let events = sink.snapshot().await;
assert!(
events
.iter()
.filter(|event| matches!(event, QconnectAppEvent::RendererUpdated(_)))
.count()
>= 2
);
}
} }
+2 -1
View File
@@ -1,5 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use qconnect_core::QConnectQueueState; use qconnect_core::{QConnectQueueState, QConnectRendererState};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -7,6 +7,7 @@ pub enum QconnectAppEvent {
TransportConnected, TransportConnected,
TransportDisconnected, TransportDisconnected,
QueueUpdated(QConnectQueueState), QueueUpdated(QConnectQueueState),
RendererUpdated(QConnectRendererState),
PendingActionStarted { PendingActionStarted {
uuid: String, uuid: String,
}, },
+2 -1
View File
@@ -1,8 +1,9 @@
use qconnect_core::{PendingActionSlot, QConnectQueueState}; use qconnect_core::{PendingActionSlot, QConnectQueueState, QConnectRendererState};
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct QconnectRuntimeState { pub struct QconnectRuntimeState {
pub queue: QConnectQueueState, pub queue: QConnectQueueState,
pub renderer: QConnectRendererState,
pub pending: PendingActionSlot, pub pending: PendingActionSlot,
pub transport_connected: bool, pub transport_connected: bool,
pub concurrency_canceled_action_uuid: Option<String>, pub concurrency_canceled_action_uuid: Option<String>,
+13 -2
View File
@@ -10,8 +10,9 @@ use prost::{
Message, Message,
}; };
use qconnect_protocol::{ use qconnect_protocol::{
decode_inbound_json, decode_queue_server_events, encode_outbound_payload_bytes, decode_inbound_json, decode_queue_server_events, decode_renderer_server_commands,
InboundEnvelope, OutboundEnvelope, QueueServerEvent, encode_outbound_payload_bytes, InboundEnvelope, OutboundEnvelope, QueueServerEvent,
RendererServerCommand,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::{ use tokio::{
@@ -61,6 +62,7 @@ pub enum TransportEvent {
action_uuid: String, action_uuid: String,
}, },
InboundQueueServerEvent(QueueServerEvent), InboundQueueServerEvent(QueueServerEvent),
InboundRendererServerCommand(RendererServerCommand),
InboundReceived(InboundEnvelope), InboundReceived(InboundEnvelope),
} }
@@ -598,6 +600,15 @@ fn handle_incoming_binary(
} }
} }
if let Ok(commands) = decode_renderer_server_commands(&inner_payload) {
for command in commands {
emit(
events_tx,
TransportEvent::InboundRendererServerCommand(command),
);
}
}
if let Ok(inbound) = decode_inbound_json(&inner_payload) { if let Ok(inbound) = decode_inbound_json(&inner_payload) {
emit(events_tx, TransportEvent::InboundReceived(inbound)); emit(events_tx, TransportEvent::InboundReceived(inbound));
} }