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:
9 files changed
+110
-1
No files matched your search
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
|||||||
Reference in new issue
Block a user