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(|d| d.get("device_type"))
.and_then(Value::as_i64) .and_then(Value::as_i64)
.map(|v| v as i32), .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); refresh_local_renderer_id(&mut state.session);
} }
@@ -289,6 +294,13 @@ impl TauriQconnectEventSink {
{ {
existing.device_type = Some(device_type as i32); 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); refresh_local_renderer_id(&mut state.session);
} }
let _ = ensure_session_renderer_state(&mut state, renderer_id); let _ = ensure_session_renderer_state(&mut state, renderer_id);
+19 -1
View File
@@ -30,7 +30,7 @@ use super::queue_resolution::{
use super::session::{ use super::session::{
build_effective_renderer_snapshot, build_visible_queue_projection, compute_connection_state, build_effective_renderer_snapshot, build_visible_queue_projection, compute_connection_state,
ensure_session_renderer_state, is_local_renderer_active, is_peer_renderer_active, ensure_session_renderer_state, is_local_renderer_active, is_peer_renderer_active,
QconnectFileAudioQualitySnapshot, renderer_allows_remote_volume, QconnectFileAudioQualitySnapshot,
}; };
use super::transport::{ use super::transport::{
default_qconnect_device_info, default_qconnect_device_info_with_name, hex_preview, default_qconnect_device_info, default_qconnect_device_info_with_name, hex_preview,
@@ -1659,6 +1659,24 @@ impl QconnectServiceState {
return Ok(false); 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 { let payload = serde_json::to_value(QconnectSetVolumeRequest {
renderer_id: session.active_renderer_id, renderer_id: session.active_renderer_id,
volume: Some(volume), volume: Some(volume),
+10
View File
@@ -152,6 +152,16 @@ pub struct QconnectRendererInfo {
pub brand: Option<String>, pub brand: Option<String>,
pub model: Option<String>, pub model: Option<String>,
pub device_type: Option<i32>, 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) { pub(super) fn refresh_local_renderer_id(session: &mut QconnectSessionState) {
+28 -1
View File
@@ -8,7 +8,8 @@ use super::queue_resolution::{
QconnectRemoteSkipDirection, QconnectRemoteSkipDirection,
}; };
use super::session::{ 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::{ use super::transport::{
decode_hex_channel, default_qconnect_device_info, parse_subscribe_channels, 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()), brand: Some("Apple".to_string()),
model: Some("iPhone".to_string()), model: Some("iPhone".to_string()),
device_type: Some(6), device_type: Some(6),
volume_remote_control: None,
}, },
QconnectRendererInfo { QconnectRendererInfo {
renderer_id: 6, renderer_id: 6,
@@ -175,6 +177,7 @@ fn refreshes_local_renderer_id_from_exact_device_uuid_match() {
brand: Some("QBZ".to_string()), brand: Some("QBZ".to_string()),
model: Some("QBZ".to_string()), model: Some("QBZ".to_string()),
device_type: Some(5), device_type: Some(5),
volume_remote_control: None,
}, },
], ],
..Default::default() ..Default::default()
@@ -199,6 +202,7 @@ fn refreshes_local_renderer_id_from_unique_fingerprint_when_uuid_missing() {
brand: Some("Apple".to_string()), brand: Some("Apple".to_string()),
model: Some("iPhone".to_string()), model: Some("iPhone".to_string()),
device_type: Some(6), device_type: Some(6),
volume_remote_control: None,
}, },
QconnectRendererInfo { QconnectRendererInfo {
renderer_id: 6, renderer_id: 6,
@@ -207,6 +211,7 @@ fn refreshes_local_renderer_id_from_unique_fingerprint_when_uuid_missing() {
brand: local_device_info.brand.clone(), brand: local_device_info.brand.clone(),
model: local_device_info.model.clone(), model: local_device_info.model.clone(),
device_type: local_device_info.device_type, device_type: local_device_info.device_type,
volume_remote_control: None,
}, },
], ],
..Default::default() ..Default::default()
@@ -228,6 +233,7 @@ fn does_not_guess_local_renderer_id_when_fingerprint_is_ambiguous() {
brand: Some("QBZ".to_string()), brand: Some("QBZ".to_string()),
model: Some("QBZ".to_string()), model: Some("QBZ".to_string()),
device_type: Some(5), device_type: Some(5),
volume_remote_control: None,
}, },
QconnectRendererInfo { QconnectRendererInfo {
renderer_id: 9, renderer_id: 9,
@@ -236,6 +242,7 @@ fn does_not_guess_local_renderer_id_when_fingerprint_is_ambiguous() {
brand: Some("QBZ".to_string()), brand: Some("QBZ".to_string()),
model: Some("QBZ".to_string()), model: Some("QBZ".to_string()),
device_type: Some(5), device_type: Some(5),
volume_remote_control: None,
}, },
], ],
..Default::default() ..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(None), Quality::UltraHiRes);
assert_eq!(quality_from_max_audio_quality(Some(99)), 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
}
+7 -1
View File
@@ -106,6 +106,9 @@
qconnectBusy?: boolean; qconnectBusy?: boolean;
showQconnectDevButton?: boolean; showQconnectDevButton?: boolean;
volumeLocked?: boolean; volumeLocked?: boolean;
// P1-5: distinguishes the hardware (ALSA Direct) lock from the remote
// renderer (cast device) lock so the tooltip can be specific.
volumeLockedReason?: 'hardware' | 'remote';
bufferProgress?: number | null; bufferProgress?: number | null;
} }
@@ -162,6 +165,7 @@
qconnectBusy = false, qconnectBusy = false,
showQconnectDevButton = false, showQconnectDevButton = false,
volumeLocked = false, volumeLocked = false,
volumeLockedReason = 'hardware',
bufferProgress = null, bufferProgress = null,
}: Props = $props(); }: Props = $props();
@@ -823,7 +827,9 @@
{#if volumeLocked} {#if volumeLocked}
<button <button
class="control-btn volume-btn" class="control-btn volume-btn"
title={$translateStore('player.volumeLockedHw')} title={volumeLockedReason === 'remote'
? $translateStore('player.volumeLockedRemote')
: $translateStore('player.volumeLockedHw')}
disabled disabled
> >
<Volume2 size={16} /> <Volume2 size={16} />
+1
View File
@@ -1540,6 +1540,7 @@
"volumeUp": "Lauter", "volumeUp": "Lauter",
"volumeDown": "Leiser", "volumeDown": "Leiser",
"volumeLockedHw": "Lautstärke auf 100% fixiert — ALSA Direct hw: (am DAC/Hardware einstellen)", "volumeLockedHw": "Lautstärke auf 100% fixiert — ALSA Direct hw: (am DAC/Hardware einstellen)",
"volumeLockedRemote": "Lautstärkeregelung durch das Cast-Gerät deaktiviert",
"clearQueue": "Warteschlange leeren", "clearQueue": "Warteschlange leeren",
"queueEmpty": "Warteschlange ist leer", "queueEmpty": "Warteschlange ist leer",
"queueEmptyDescription": "Suche nach Musik oder durchsuchen Sie Ihre Bibliothek, um Titel hinzuzufügen.", "queueEmptyDescription": "Suche nach Musik oder durchsuchen Sie Ihre Bibliothek, um Titel hinzuzufügen.",
+1
View File
@@ -1542,6 +1542,7 @@
"volumeUp": "Increase volume", "volumeUp": "Increase volume",
"volumeDown": "Decrease volume", "volumeDown": "Decrease volume",
"volumeLockedHw": "Volume locked at 100% — ALSA Direct hw: (adjust at DAC/hardware level)", "volumeLockedHw": "Volume locked at 100% — ALSA Direct hw: (adjust at DAC/hardware level)",
"volumeLockedRemote": "Volume control disabled by the cast device",
"clearQueue": "Clear Queue", "clearQueue": "Clear Queue",
"queueEmpty": "Queue is empty", "queueEmpty": "Queue is empty",
"queueEmptyDescription": "Search for music or browse your library to add tracks.", "queueEmptyDescription": "Search for music or browse your library to add tracks.",
+1
View File
@@ -1540,6 +1540,7 @@
"volumeUp": "Subir volumen", "volumeUp": "Subir volumen",
"volumeDown": "Bajar volumen", "volumeDown": "Bajar volumen",
"volumeLockedHw": "Volumen fijo al 100% — ALSA Direct hw: (ajusta desde el DAC/hardware)", "volumeLockedHw": "Volumen fijo al 100% — ALSA Direct hw: (ajusta desde el DAC/hardware)",
"volumeLockedRemote": "El control de volumen está deshabilitado por el dispositivo",
"clearQueue": "Limpiar Cola", "clearQueue": "Limpiar Cola",
"queueEmpty": "La cola está vacía", "queueEmpty": "La cola está vacía",
"queueEmptyDescription": "Busca música o explora tu biblioteca para agregar pistas.", "queueEmptyDescription": "Busca música o explora tu biblioteca para agregar pistas.",
+1
View File
@@ -1540,6 +1540,7 @@
"volumeUp": "Augmenter le volume", "volumeUp": "Augmenter le volume",
"volumeDown": "Diminuer le volume", "volumeDown": "Diminuer le volume",
"volumeLockedHw": "Volume verrouillé à 100% — ALSA Direct hw: (réglez au niveau DAC/matériel)", "volumeLockedHw": "Volume verrouillé à 100% — ALSA Direct hw: (réglez au niveau DAC/matériel)",
"volumeLockedRemote": "Contrôle du volume désactivé par l'appareil de diffusion",
"clearQueue": "Vider la file d'attente", "clearQueue": "Vider la file d'attente",
"queueEmpty": "La file d'attente est vide", "queueEmpty": "La file d'attente est vide",
"queueEmptyDescription": "Recherchez de la musique ou parcourez votre bibliothèque pour ajouter des titres.", "queueEmptyDescription": "Recherchez de la musique ou parcourez votre bibliothèque pour ajouter des titres.",
+1
View File
@@ -1540,6 +1540,7 @@
"volumeUp": "Aumentar volume", "volumeUp": "Aumentar volume",
"volumeDown": "Diminuir volume", "volumeDown": "Diminuir volume",
"volumeLockedHw": "Volume fixo em 100% — ALSA Direct hw: (ajuste no DAC/hardware)", "volumeLockedHw": "Volume fixo em 100% — ALSA Direct hw: (ajuste no DAC/hardware)",
"volumeLockedRemote": "Controle de volume desativado pelo dispositivo de transmissão",
"clearQueue": "Limpar Fila", "clearQueue": "Limpar Fila",
"queueEmpty": "A fila está vazia", "queueEmpty": "A fila está vazia",
"queueEmptyDescription": "Pesquise músicas ou navegue pela sua biblioteca para adicionar faixas.", "queueEmptyDescription": "Pesquise músicas ou navegue pela sua biblioteca para adicionar faixas.",
+3
View File
@@ -32,6 +32,9 @@ export type QconnectRendererInfo = {
brand?: string | null; brand?: string | null;
model?: string | null; model?: string | null;
device_type?: number | null; device_type?: number | null;
// P1-5: capabilities.volume_remote_control. 1 == ALLOWED. null/undefined ==
// not advertised (treated as allowed). Any other value disables remote volume.
volume_remote_control?: number | null;
}; };
export type QconnectSessionSnapshot = { export type QconnectSessionSnapshot = {
+27 -3
View File
@@ -1125,6 +1125,26 @@
const qconnectPeerRendererActive = $derived( const qconnectPeerRendererActive = $derived(
isQconnectPeerRendererActive(qobuzConnectSessionSnapshot) isQconnectPeerRendererActive(qobuzConnectSessionSnapshot)
); );
// P1-5: respect the active renderer's volume_remote_control capability.
// Absent (null/undefined) => allowed; only an explicit non-1 value disables.
const qconnectRemoteVolumeAllowed = $derived.by(() => {
if (!qconnectPeerRendererActive) return true;
const snap = qobuzConnectSessionSnapshot;
const activeId = snap?.active_renderer_id ?? null;
const info = snap?.renderers?.find((r) => r.renderer_id === activeId) ?? null;
const vrc = info?.volume_remote_control;
return vrc == null || vrc === 1;
});
// P1-5: unified volume-lock state shared by the now-playing bar + immersive
// player. Hardware lock applies only locally; remote lock applies when a peer
// renderer is active and disallows remote volume.
const qconnectVolumeLocked = $derived(
(isAlsaDirectHw && !qconnectPeerRendererActive) ||
(qconnectPeerRendererActive && !qconnectRemoteVolumeAllowed)
);
const qconnectVolumeLockedReason: 'hardware' | 'remote' = $derived(
qconnectPeerRendererActive && !qconnectRemoteVolumeAllowed ? 'remote' : 'hardware'
);
const qconnectSuppressLocalPlaybackAutomation = $derived( const qconnectSuppressLocalPlaybackAutomation = $derived(
shouldQconnectSuppressLocalPlaybackAutomation( shouldQconnectSuppressLocalPlaybackAutomation(
isQobuzConnectConnected, isQobuzConnectConnected,
@@ -2887,6 +2907,8 @@
async function handleVolumeChange(newVolume: number) { async function handleVolumeChange(newVolume: number) {
// ALSA Direct hw: locks volume at 100% — unless controlling a remote renderer // ALSA Direct hw: locks volume at 100% — unless controlling a remote renderer
if (isAlsaDirectHw && !qconnectPeerRendererActive) return; if (isAlsaDirectHw && !qconnectPeerRendererActive) return;
// P1-5: the active remote renderer disallows remote volume control.
if (qconnectPeerRendererActive && !qconnectRemoteVolumeAllowed) return;
try { try {
const handledRemotely = await invoke<boolean>('v2_qconnect_set_volume_if_remote', { volume: newVolume }); const handledRemotely = await invoke<boolean>('v2_qconnect_set_volume_if_remote', { volume: newVolume });
@@ -7141,7 +7163,8 @@
onToggleQconnectConnection={handleQobuzConnectButton} onToggleQconnectConnection={handleQobuzConnectButton}
qconnectBusy={qobuzConnectBusy} qconnectBusy={qobuzConnectBusy}
{showQconnectDevButton} {showQconnectDevButton}
volumeLocked={isAlsaDirectHw && !qconnectPeerRendererActive} volumeLocked={qconnectVolumeLocked}
volumeLockedReason={qconnectVolumeLockedReason}
{bufferProgress} {bufferProgress}
/> />
{:else} {:else}
@@ -7164,7 +7187,8 @@
onToggleQconnectConnection={handleQobuzConnectButton} onToggleQconnectConnection={handleQobuzConnectButton}
qconnectBusy={qobuzConnectBusy} qconnectBusy={qobuzConnectBusy}
{showQconnectDevButton} {showQconnectDevButton}
volumeLocked={isAlsaDirectHw && !qconnectPeerRendererActive} volumeLocked={qconnectVolumeLocked}
volumeLockedReason={qconnectVolumeLockedReason}
/> />
{/if} {/if}
@@ -7198,7 +7222,7 @@
{volume} {volume}
onVolumeChange={handleVolumeChange} onVolumeChange={handleVolumeChange}
onToggleMute={handleToggleMute} onToggleMute={handleToggleMute}
volumeLocked={isAlsaDirectHw && !qconnectPeerRendererActive} volumeLocked={qconnectVolumeLocked}
{isShuffle} {isShuffle}
onToggleShuffle={toggleShuffle} onToggleShuffle={toggleShuffle}
{repeatMode} {repeatMode}