fix(qconnect): add device capabilities, initial state, and channel subscriptions

- Add DeviceCapabilitiesMessage to protobuf (tag 7 on DeviceInfo) with
  volume_remote_control=ALLOWED, min/max audio quality
- Extend JoinSessionMessage with reason, initial_state, is_active fields
  matching Android renderer proto (tags 3-5)
- Bootstrap sends renderer JoinSession with is_active=true and initial
  RendererState (STOPPED) so other devices see QBZ immediately
- Send initial StateUpdated, VolumeChanged, MaxAudioQualityChanged
  reports after join for full renderer presence
- Default subscribe channels to 0x01,0x02,0x03 instead of empty
- Request queue state from server after bootstrap

cc
This commit is contained in:
vicrodh committed 2026-02-21 15:11:25 -06:00
1 parent 1fdfc4eb9d
commit b160773e50
3 files changed
+232 -26

No files matched your search

+113 -18
View File
@@ -11,16 +11,16 @@ use crate::{
build_outbound_envelope,
queue_command_proto::{
AskForQueueStateMessage, AskForRendererStateMessage, AutoplayLoadTracksMessage,
AutoplayRemoveTracksMessage, ClearQueueMessage, DeviceInfoMessage, JoinSessionMessage,
MuteVolumeMessage, PlaybackPositionMessage, QConnectMessage, QConnectMessageType,
QConnectMessages, QueueAddTracksMessage, QueueInsertTracksMessage, QueueLoadTracksMessage,
QueueRemoveTracksMessage, QueueReorderTracksMessage, QueueVersionRef,
RendererFileAudioQualityChangedMessage, RendererMaxAudioQualityChangedMessage,
RendererStateMessage, RendererStateUpdatedMessage, RendererVolumeChangedMessage,
RendererVolumeMutedMessage, SetActiveRendererMessage, SetAutoplayModeMessage,
SetLoopModeMessage, SetMaxAudioQualityMessage, SetPlayerStateMessage,
SetPlayerStateQueueItemMessage, SetQueueStateMessage, SetQueueTrackWithContext,
SetShuffleModeMessage, SetVolumeMessage,
AutoplayRemoveTracksMessage, ClearQueueMessage, DeviceCapabilitiesMessage,
DeviceInfoMessage, JoinSessionMessage, MuteVolumeMessage, PlaybackPositionMessage,
QConnectMessage, QConnectMessageType, QConnectMessages, QueueAddTracksMessage,
QueueInsertTracksMessage, QueueLoadTracksMessage, QueueRemoveTracksMessage,
QueueReorderTracksMessage, QueueVersionRef, RendererFileAudioQualityChangedMessage,
RendererMaxAudioQualityChangedMessage, RendererStateMessage, RendererStateUpdatedMessage,
RendererVolumeChangedMessage, RendererVolumeMutedMessage, SetActiveRendererMessage,
SetAutoplayModeMessage, SetLoopModeMessage, SetMaxAudioQualityMessage,
SetPlayerStateMessage, SetPlayerStateQueueItemMessage, SetQueueStateMessage,
SetQueueTrackWithContext, SetShuffleModeMessage, SetVolumeMessage,
},
OutboundEnvelope, ProtocolError, QueueCommand, QueueCommandType, RendererReport,
RendererReportType,
@@ -86,6 +86,10 @@ fn map_queue_command(command: &QueueCommand) -> Result<QConnectMessage, Protocol
&["session_uuid", "sessionUuid"],
)?,
device_info: parse_device_info(&command.payload)?,
// Controller JoinSession doesn't use renderer-specific fields
reason: None,
initial_state: None,
is_active: None,
}),
..Default::default()
}),
@@ -390,14 +394,26 @@ fn map_queue_command(command: &QueueCommand) -> Result<QConnectMessage, Protocol
fn map_renderer_report(report: &RendererReport) -> Result<QConnectMessage, ProtocolError> {
match report.report_type {
RendererReportType::RndrSrvrJoinSession => Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeRndrSrvrJoinSession as i32),
rndr_srvr_join_session: Some(JoinSessionMessage {
session_uuid: optional_uuid_bytes(&report.payload, &["session_uuid", "sessionUuid"])?,
device_info: parse_device_info(&report.payload)?,
}),
..Default::default()
}),
RendererReportType::RndrSrvrJoinSession => {
let initial_state = parse_renderer_initial_state(&report.payload)?;
let is_active = Some(optional_bool(&report.payload, "is_active", false));
let reason = optional_i32(&report.payload, "reason")?;
Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeRndrSrvrJoinSession as i32),
rndr_srvr_join_session: Some(JoinSessionMessage {
session_uuid: optional_uuid_bytes(
&report.payload,
&["session_uuid", "sessionUuid"],
)?,
device_info: parse_device_info(&report.payload)?,
reason,
initial_state,
is_active,
}),
..Default::default()
})
}
RendererReportType::RndrSrvrDeviceInfoUpdated => Ok(QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeRndrSrvrDeviceInfoUpdated as i32),
rndr_srvr_device_info_updated: parse_device_info(&report.payload)?,
@@ -700,6 +716,7 @@ fn parse_device_info(payload: &Value) -> Result<Option<DeviceInfoMessage>, Proto
let serial_number = optional_string_any(source, &["serial_number", "serialNumber"]);
let device_type = optional_i32_any(source, &["device_type", "deviceType"])?;
let software_version = optional_string_any(source, &["software_version", "softwareVersion"]);
let capabilities = parse_device_capabilities(source)?;
if device_uuid.is_none()
&& friendly_name.is_none()
@@ -708,6 +725,7 @@ fn parse_device_info(payload: &Value) -> Result<Option<DeviceInfoMessage>, Proto
&& serial_number.is_none()
&& device_type.is_none()
&& software_version.is_none()
&& capabilities.is_none()
{
return Ok(None);
}
@@ -719,10 +737,87 @@ fn parse_device_info(payload: &Value) -> Result<Option<DeviceInfoMessage>, Proto
model,
serial_number,
device_type,
capabilities,
software_version,
}))
}
fn parse_device_capabilities(
source: &Value,
) -> Result<Option<DeviceCapabilitiesMessage>, ProtocolError> {
let nested = source
.get("capabilities")
.or_else(|| source.get("deviceCapabilities"));
if let Some(caps) = nested {
if caps.is_object() {
return Ok(Some(DeviceCapabilitiesMessage {
min_audio_quality: optional_i32_any(
caps,
&["min_audio_quality", "minAudioQuality"],
)?,
max_audio_quality: optional_i32_any(
caps,
&["max_audio_quality", "maxAudioQuality"],
)?,
volume_remote_control: optional_i32_any(
caps,
&["volume_remote_control", "volumeRemoteControl"],
)?,
}));
}
}
// Check flat fields (capabilities declared directly on device_info payload)
let min_aq = optional_i32_any(source, &["min_audio_quality", "minAudioQuality"])?;
let max_aq = optional_i32_any(source, &["max_audio_quality", "maxAudioQuality"])?;
let vol_rc = optional_i32_any(source, &["volume_remote_control", "volumeRemoteControl"])?;
if min_aq.is_some() || max_aq.is_some() || vol_rc.is_some() {
return Ok(Some(DeviceCapabilitiesMessage {
min_audio_quality: min_aq,
max_audio_quality: max_aq,
volume_remote_control: vol_rc,
}));
}
Ok(None)
}
fn parse_renderer_initial_state(
payload: &Value,
) -> Result<Option<RendererStateMessage>, ProtocolError> {
let nested = payload
.get("initial_state")
.or_else(|| payload.get("initialState"));
let Some(source) = nested else {
return Ok(None);
};
if !source.is_object() {
return Ok(None);
}
let current_position = optional_i32(source, "current_position")?;
let playback_position = current_position.map(|value| PlaybackPositionMessage {
timestamp: Some(now_ms()),
value: Some(value),
});
Ok(Some(RendererStateMessage {
playing_state: optional_i32(source, "playing_state")?,
buffer_state: optional_i32(source, "buffer_state")?,
current_position: playback_position,
duration: optional_i32(source, "duration")?,
queue_version: optional_queue_version(source, "queue_version")?
.map(|v| to_proto_queue_version(v))
.transpose()?,
current_queue_item_id: optional_i32(source, "current_queue_item_id")?,
next_queue_item_id: optional_i32(source, "next_queue_item_id")?,
}))
}
fn parse_set_player_state_queue_item(
payload: &Value,
) -> Result<Option<SetPlayerStateQueueItemMessage>, ProtocolError> {
@@ -94,6 +94,16 @@ pub struct SetQueueTrackWithContext {
pub context_uuid: Option<Vec<u8>>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DeviceCapabilitiesMessage {
#[prost(int32, optional, tag = "1")]
pub min_audio_quality: Option<i32>,
#[prost(int32, optional, tag = "2")]
pub max_audio_quality: Option<i32>,
#[prost(int32, optional, tag = "3")]
pub volume_remote_control: Option<i32>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DeviceInfoMessage {
#[prost(bytes = "vec", optional, tag = "1")]
@@ -108,6 +118,8 @@ pub struct DeviceInfoMessage {
pub serial_number: Option<String>,
#[prost(int32, optional, tag = "6")]
pub device_type: Option<i32>,
#[prost(message, optional, tag = "7")]
pub capabilities: Option<DeviceCapabilitiesMessage>,
#[prost(string, optional, tag = "8")]
pub software_version: Option<String>,
}
@@ -118,6 +130,15 @@ pub struct JoinSessionMessage {
pub session_uuid: Option<Vec<u8>>,
#[prost(message, optional, tag = "2")]
pub device_info: Option<DeviceInfoMessage>,
/// Renderer-only: reason for joining (0=unknown, 1=controller_request, 2=reconnection)
#[prost(int32, optional, tag = "3")]
pub reason: Option<i32>,
/// Renderer-only: initial playback state sent on join
#[prost(message, optional, tag = "4")]
pub initial_state: Option<RendererStateMessage>,
/// Renderer-only: whether this renderer is active
#[prost(bool, optional, tag = "5")]
pub is_active: Option<bool>,
}
#[derive(Clone, PartialEq, ::prost::Message)]