feat(qconnect): handle ACTIVE_DISCONNECTED renderer status

The wire 'status' field on RENDERER_STATE_UPDATED was decoded into the
adapter payload (decoder.rs:801) then dropped — a graceful peer
disconnect was invisible and the controller kept showing stale 'playing'.
Add a RendererStatus enum (UNKNOWN/UNRECOGNIZED -> INACTIVE collapse) and,
on ACTIVE_DISCONNECTED for the active remote renderer (id != -1), freeze
the projection to UNKNOWN and emit qconnect:renderer_disconnected. Svelte
surfaces it as an error toast (rendererDisconnected, all 5 locales). Pure
consumer-side: no protocol change.

cc
This commit is contained in:
vicrodh committed 2026-06-01 02:54:31 -06:00
1 parent c32bbc5db3
commit d625e0afef
9 files changed
+110 -1

No files matched your search

+51 -1
View File
@@ -23,7 +23,10 @@ use super::session::{
is_peer_renderer_active, normalize_active_renderer_id, refresh_local_renderer_id, is_peer_renderer_active, normalize_active_renderer_id, refresh_local_renderer_id,
sync_session_renderer_active_flags, sync_session_renderer_active_flags,
}; };
use super::{qconnect_now_ms, QconnectRemoteSyncState, QconnectRendererInfo}; use super::{
qconnect_now_ms, QconnectRemoteSyncState, QconnectRendererInfo, RendererStatus,
PLAYING_STATE_UNKNOWN,
};
#[derive(Clone)] #[derive(Clone)]
pub(super) struct TauriQconnectEventSink { pub(super) struct TauriQconnectEventSink {
@@ -121,6 +124,7 @@ impl TauriQconnectEventSink {
let mut remote_projection_renderer_id: Option<i32> = None; let mut remote_projection_renderer_id: Option<i32> = None;
let mut sync_local_playback = false; let mut sync_local_playback = false;
let mut apply_loop_mode: Option<i32> = None; let mut apply_loop_mode: Option<i32> = None;
let mut disconnected_renderer_id: Option<i32> = None;
let mut state = self.sync_state.lock().await; let mut state = self.sync_state.lock().await;
match message_type { match message_type {
"MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" => { "MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" => {
@@ -298,6 +302,19 @@ impl TauriQconnectEventSink {
renderer_state.updated_at_ms = qconnect_now_ms(); renderer_state.updated_at_ms = qconnect_now_ms();
remote_projection_renderer_id = Some(renderer_id as i32); remote_projection_renderer_id = Some(renderer_id as i32);
sync_local_playback = true; sync_local_playback = true;
// P0-2: graceful disconnect. status==ACTIVE_DISCONNECTED(2) for
// the active remote renderer (id != -1) means the renderer left
// cleanly — freeze the projection so the UI stops lying.
let status =
RendererStatus::from_wire(payload.get("status").and_then(Value::as_i64));
if status == RendererStatus::ActiveDisconnected
&& renderer_id != -1
&& state.session.active_renderer_id == Some(renderer_id as i32)
&& is_peer_renderer_active(&state.session)
{
disconnected_renderer_id = Some(renderer_id as i32);
}
} }
"MESSAGE_TYPE_SRVR_CTRL_VOLUME_CHANGED" => { "MESSAGE_TYPE_SRVR_CTRL_VOLUME_CHANGED" => {
let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else { let Some(renderer_id) = payload.get("renderer_id").and_then(Value::as_i64) else {
@@ -379,6 +396,39 @@ impl TauriQconnectEventSink {
if let Some(renderer_id) = remote_projection_renderer_id { if let Some(renderer_id) = remote_projection_renderer_id {
self.sync_active_renderer_projection(renderer_id).await; self.sync_active_renderer_projection(renderer_id).await;
} }
if let Some(renderer_id) = disconnected_renderer_id {
self.freeze_active_renderer_projection_to_unknown(
renderer_id,
"qconnect:renderer_disconnected",
)
.await;
}
}
/// Force the active renderer's cached projection to UNKNOWN/stopped and
/// emit a freeze event. Shared by ACTIVE_DISCONNECTED (P0-2) and the
/// silence watchdog (P0-1). Caller passes the event name to emit.
///
/// This is a pure-state mutation + emit; it does NOT route back through
/// `apply_session_management_event`, so it cannot re-arm the watchdog.
async fn freeze_active_renderer_projection_to_unknown(
&self,
renderer_id: i32,
event_name: &str,
) {
{
let mut state = self.sync_state.lock().await;
let renderer_state = ensure_session_renderer_state(&mut state, renderer_id);
renderer_state.playing_state = Some(PLAYING_STATE_UNKNOWN);
renderer_state.updated_at_ms = qconnect_now_ms();
}
if let Err(err) = self
.app_handle
.emit(event_name, serde_json::json!({ "renderer_id": renderer_id }))
{
log::warn!("[QConnect] Failed to emit {event_name}: {err}");
}
} }
async fn sync_local_playback_for_renderer_ownership(&self) { async fn sync_local_playback_for_renderer_ownership(&self) {
+16
View File
@@ -1080,3 +1080,19 @@ fn device_uuid_persists_and_is_reused_across_calls() {
let _ = std::fs::remove_file(&tmp); let _ = std::fs::remove_file(&tmp);
} }
#[test]
fn renderer_status_from_wire_maps_known_values() {
use super::RendererStatus;
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
}
+25
View File
@@ -277,3 +277,28 @@ pub struct QconnectSetMaxAudioQualityRequest {
pub struct QconnectAskForRendererStateRequest { pub struct QconnectAskForRendererStateRequest {
pub renderer_id: Option<i32>, pub renderer_id: Option<i32>,
} }
/// 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, 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,
}
}
}
+1
View File
@@ -1839,6 +1839,7 @@
"remoteQueuedNext": "In Qobuz Connect zur nächsten Wiedergabe eingereiht.", "remoteQueuedNext": "In Qobuz Connect zur nächsten Wiedergabe eingereiht.",
"remoteQueuedLater": "Zur Qobuz Connect-Warteschlange hinzugefügt.", "remoteQueuedLater": "Zur Qobuz Connect-Warteschlange hinzugefügt.",
"remoteQueueFailed": "Aktualisierung der Qobuz Connect-Warteschlange fehlgeschlagen.", "remoteQueueFailed": "Aktualisierung der Qobuz Connect-Warteschlange fehlgeschlagen.",
"rendererDisconnected": "Das entfernte Gerät hat die Verbindung zu Qobuz Connect getrennt.",
"sessionTitle": "Sitzung", "sessionTitle": "Sitzung",
"sessionUuid": "Sitzungs-UUID", "sessionUuid": "Sitzungs-UUID",
"activeRenderer": "Aktiver Renderer", "activeRenderer": "Aktiver Renderer",
+1
View File
@@ -1839,6 +1839,7 @@
"remoteQueuedNext": "Queued on Qobuz Connect to play next.", "remoteQueuedNext": "Queued on Qobuz Connect to play next.",
"remoteQueuedLater": "Added to Qobuz Connect queue.", "remoteQueuedLater": "Added to Qobuz Connect queue.",
"remoteQueueFailed": "Failed to update Qobuz Connect queue.", "remoteQueueFailed": "Failed to update Qobuz Connect queue.",
"rendererDisconnected": "The remote device disconnected from Qobuz Connect.",
"sessionTitle": "Session", "sessionTitle": "Session",
"sessionUuid": "Session UUID", "sessionUuid": "Session UUID",
"activeRenderer": "Active renderer", "activeRenderer": "Active renderer",
+1
View File
@@ -1839,6 +1839,7 @@
"remoteQueuedNext": "Agregada para reproducir a continuacion en Qobuz Connect.", "remoteQueuedNext": "Agregada para reproducir a continuacion en Qobuz Connect.",
"remoteQueuedLater": "Agregada a la cola de Qobuz Connect.", "remoteQueuedLater": "Agregada a la cola de Qobuz Connect.",
"remoteQueueFailed": "No se pudo actualizar la cola de Qobuz Connect.", "remoteQueueFailed": "No se pudo actualizar la cola de Qobuz Connect.",
"rendererDisconnected": "El dispositivo remoto se desconectó de Qobuz Connect.",
"sessionTitle": "Sesion", "sessionTitle": "Sesion",
"sessionUuid": "UUID de sesion", "sessionUuid": "UUID de sesion",
"activeRenderer": "Renderer activo", "activeRenderer": "Renderer activo",
+1
View File
@@ -1839,6 +1839,7 @@
"remoteQueuedNext": "Ajouté à Qobuz Connect pour lecture suivante.", "remoteQueuedNext": "Ajouté à Qobuz Connect pour lecture suivante.",
"remoteQueuedLater": "Ajouté à la file Qobuz Connect.", "remoteQueuedLater": "Ajouté à la file Qobuz Connect.",
"remoteQueueFailed": "Échec de la mise à jour de la file Qobuz Connect.", "remoteQueueFailed": "Échec de la mise à jour de la file Qobuz Connect.",
"rendererDisconnected": "L'appareil distant s'est déconnecté de Qobuz Connect.",
"sessionTitle": "Session", "sessionTitle": "Session",
"sessionUuid": "UUID de session", "sessionUuid": "UUID de session",
"activeRenderer": "Rendu actif", "activeRenderer": "Rendu actif",
+1
View File
@@ -1839,6 +1839,7 @@
"remoteQueuedNext": "Adicionado ao Qobuz Connect para reprodução seguinte.", "remoteQueuedNext": "Adicionado ao Qobuz Connect para reprodução seguinte.",
"remoteQueuedLater": "Adicionado à fila do Qobuz Connect.", "remoteQueuedLater": "Adicionado à fila do Qobuz Connect.",
"remoteQueueFailed": "Falha ao atualizar a fila do Qobuz Connect.", "remoteQueueFailed": "Falha ao atualizar a fila do Qobuz Connect.",
"rendererDisconnected": "O dispositivo remoto desconectou-se do Qobuz Connect.",
"sessionTitle": "Sessão", "sessionTitle": "Sessão",
"sessionUuid": "UUID da sessão", "sessionUuid": "UUID da sessão",
"activeRenderer": "Renderer ativo", "activeRenderer": "Renderer ativo",
+13
View File
@@ -5592,6 +5592,7 @@
let unlistenQconnectError: UnlistenFn | null = null; let unlistenQconnectError: UnlistenFn | null = null;
let unlistenQconnectStatusChanged: UnlistenFn | null = null; let unlistenQconnectStatusChanged: UnlistenFn | null = null;
let unlistenQconnectAdmissionBlocked: UnlistenFn | null = null; let unlistenQconnectAdmissionBlocked: UnlistenFn | null = null;
let unlistenQconnectRendererDisconnected: UnlistenFn | null = null;
let unlistenQconnectDiagnostic: UnlistenFn | null = null; let unlistenQconnectDiagnostic: UnlistenFn | null = null;
let unlistenQconnectRendererReportDebug: UnlistenFn | null = null; let unlistenQconnectRendererReportDebug: UnlistenFn | null = null;
let unlistenAudioDeviceMissing: UnlistenFn | null = null; let unlistenAudioDeviceMissing: UnlistenFn | null = null;
@@ -5831,6 +5832,17 @@
if (disposed) { unlisten8(); return; } if (disposed) { unlisten8(); return; }
unlistenQconnectAdmissionBlocked = unlisten8; unlistenQconnectAdmissionBlocked = unlisten8;
const unlistenRendererDisconnected = await listen<{ renderer_id: number }>(
'qconnect:renderer_disconnected',
(event) => {
pushQobuzConnectDiagnostic('qconnect:renderer_disconnected', 'warn', event.payload);
void refreshQobuzConnectRuntimeState();
showToast($t('qconnect.rendererDisconnected'), 'error');
}
);
if (disposed) { unlistenRendererDisconnected(); return; }
unlistenQconnectRendererDisconnected = unlistenRendererDisconnected;
const unlisten9 = await listen<QconnectDiagnosticsPayload>('qconnect:diagnostic', (event) => { const unlisten9 = await listen<QconnectDiagnosticsPayload>('qconnect:diagnostic', (event) => {
pushQobuzConnectDiagnostic( pushQobuzConnectDiagnostic(
event.payload.channel, event.payload.channel,
@@ -5896,6 +5908,7 @@
unlistenQconnectError?.(); unlistenQconnectError?.();
unlistenQconnectStatusChanged?.(); unlistenQconnectStatusChanged?.();
unlistenQconnectAdmissionBlocked?.(); unlistenQconnectAdmissionBlocked?.();
unlistenQconnectRendererDisconnected?.();
unlistenQconnectDiagnostic?.(); unlistenQconnectDiagnostic?.();
unlistenQconnectRendererReportDebug?.(); unlistenQconnectRendererReportDebug?.();
unlistenAudioDeviceMissing?.(); unlistenAudioDeviceMissing?.();