feat(qconnect): gate volume on renderer volume_remote_control capability

Add volume_remote_control to QconnectRendererInfo plus the
renderer_allows_remote_volume predicate (absent => allowed, only an explicit
non-1 value disables). Parse it from device_info.capabilities in the ADD and
UPDATE renderer paths, and short-circuit set_volume_if_remote when the active
renderer disallows remote volume (returns handled no-op so the frontend does
not fall back to local). Frontend surfaces volume_remote_control in the session
snapshot, derives a unified volume lock + reason, guards handleVolumeChange,
and shows a distinct player.volumeLockedRemote tooltip (added to all 5 locales).

cc
This commit is contained in:
vicrodh committed 2026-06-01 04:44:58 -06:00
1 parent d18bec1905
commit c4016e539f
12 files changed
+111 -6

No files matched your search

+12
View File
@@ -242,6 +242,11 @@ impl TauriQconnectEventSink {
.and_then(|d| d.get("device_type"))
.and_then(Value::as_i64)
.map(|v| v as i32),
volume_remote_control: device_info
.and_then(|d| d.get("capabilities"))
.and_then(|c| c.get("volume_remote_control"))
.and_then(Value::as_i64)
.map(|v| v as i32),
});
refresh_local_renderer_id(&mut state.session);
}
@@ -289,6 +294,13 @@ impl TauriQconnectEventSink {
{
existing.device_type = Some(device_type as i32);
}
if let Some(vrc) = device_info
.and_then(|d| d.get("capabilities"))
.and_then(|c| c.get("volume_remote_control"))
.and_then(Value::as_i64)
{
existing.volume_remote_control = Some(vrc as i32);
}
refresh_local_renderer_id(&mut state.session);
}
let _ = ensure_session_renderer_state(&mut state, renderer_id);
+19 -1
View File
@@ -30,7 +30,7 @@ use super::queue_resolution::{
use super::session::{
build_effective_renderer_snapshot, build_visible_queue_projection, compute_connection_state,
ensure_session_renderer_state, is_local_renderer_active, is_peer_renderer_active,
QconnectFileAudioQualitySnapshot,
renderer_allows_remote_volume, QconnectFileAudioQualitySnapshot,
};
use super::transport::{
default_qconnect_device_info, default_qconnect_device_info_with_name, hex_preview,
@@ -1659,6 +1659,24 @@ impl QconnectServiceState {
return Ok(false);
};
// P1-5: respect the active renderer's volume_remote_control capability.
// Absent => allowed. Only an explicit non-ALLOWED value disables.
if let Some(active_id) = session.active_renderer_id {
if let Some(info) = session
.renderers
.iter()
.find(|r| r.renderer_id == active_id)
{
if !renderer_allows_remote_volume(info) {
log::info!(
"[QConnect] set_volume_if_remote short-circuited: renderer {active_id} disallows remote volume"
);
// Handled (no-op): the frontend must NOT fall back to local.
return Ok(true);
}
}
}
let payload = serde_json::to_value(QconnectSetVolumeRequest {
renderer_id: session.active_renderer_id,
volume: Some(volume),
+10
View File
@@ -152,6 +152,16 @@ pub struct QconnectRendererInfo {
pub brand: Option<String>,
pub model: Option<String>,
pub device_type: Option<i32>,
/// capabilities.volume_remote_control. 1 == ALLOWED. None == not advertised
/// (treated as allowed to avoid regressing renderers that omit it).
#[serde(default)]
pub volume_remote_control: Option<i32>,
}
/// Whether the active renderer permits remote volume control. Absent capability
/// (None) defaults to allowed; only an explicit non-ALLOWED value disables.
pub(super) fn renderer_allows_remote_volume(info: &QconnectRendererInfo) -> bool {
matches!(info.volume_remote_control, None | Some(1))
}
pub(super) fn refresh_local_renderer_id(session: &mut QconnectSessionState) {
+28 -1
View File
@@ -8,7 +8,8 @@ use super::queue_resolution::{
QconnectRemoteSkipDirection,
};
use super::session::{
find_unique_renderer_id, refresh_local_renderer_id, QconnectFileAudioQualitySnapshot,
find_unique_renderer_id, refresh_local_renderer_id, renderer_allows_remote_volume,
QconnectFileAudioQualitySnapshot,
};
use super::transport::{
decode_hex_channel, default_qconnect_device_info, parse_subscribe_channels,
@@ -167,6 +168,7 @@ fn refreshes_local_renderer_id_from_exact_device_uuid_match() {
brand: Some("Apple".to_string()),
model: Some("iPhone".to_string()),
device_type: Some(6),
volume_remote_control: None,
},
QconnectRendererInfo {
renderer_id: 6,
@@ -175,6 +177,7 @@ fn refreshes_local_renderer_id_from_exact_device_uuid_match() {
brand: Some("QBZ".to_string()),
model: Some("QBZ".to_string()),
device_type: Some(5),
volume_remote_control: None,
},
],
..Default::default()
@@ -199,6 +202,7 @@ fn refreshes_local_renderer_id_from_unique_fingerprint_when_uuid_missing() {
brand: Some("Apple".to_string()),
model: Some("iPhone".to_string()),
device_type: Some(6),
volume_remote_control: None,
},
QconnectRendererInfo {
renderer_id: 6,
@@ -207,6 +211,7 @@ fn refreshes_local_renderer_id_from_unique_fingerprint_when_uuid_missing() {
brand: local_device_info.brand.clone(),
model: local_device_info.model.clone(),
device_type: local_device_info.device_type,
volume_remote_control: None,
},
],
..Default::default()
@@ -228,6 +233,7 @@ fn does_not_guess_local_renderer_id_when_fingerprint_is_ambiguous() {
brand: Some("QBZ".to_string()),
model: Some("QBZ".to_string()),
device_type: Some(5),
volume_remote_control: None,
},
QconnectRendererInfo {
renderer_id: 9,
@@ -236,6 +242,7 @@ fn does_not_guess_local_renderer_id_when_fingerprint_is_ambiguous() {
brand: Some("QBZ".to_string()),
model: Some("QBZ".to_string()),
device_type: Some(5),
volume_remote_control: None,
},
],
..Default::default()
@@ -1212,3 +1219,23 @@ fn quality_from_max_audio_quality_maps_levels() {
assert_eq!(quality_from_max_audio_quality(None), Quality::UltraHiRes);
assert_eq!(quality_from_max_audio_quality(Some(99)), Quality::UltraHiRes);
}
#[test]
fn renderer_allows_remote_volume_defaults_allow_when_absent() {
let mut info = QconnectRendererInfo {
renderer_id: 7,
device_uuid: None,
friendly_name: None,
brand: None,
model: None,
device_type: None,
volume_remote_control: None,
};
assert!(renderer_allows_remote_volume(&info)); // absent => allowed
info.volume_remote_control = Some(1);
assert!(renderer_allows_remote_volume(&info)); // ALLOWED
info.volume_remote_control = Some(0);
assert!(!renderer_allows_remote_volume(&info)); // explicit non-allowed
info.volume_remote_control = Some(2);
assert!(!renderer_allows_remote_volume(&info)); // any other value
}