diff --git a/src-tauri/src/qconnect/event_sink.rs b/src-tauri/src/qconnect/event_sink.rs index efd1d90e6..57a07c491 100644 --- a/src-tauri/src/qconnect/event_sink.rs +++ b/src-tauri/src/qconnect/event_sink.rs @@ -23,7 +23,10 @@ use super::session::{ is_peer_renderer_active, normalize_active_renderer_id, refresh_local_renderer_id, 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)] pub(super) struct TauriQconnectEventSink { @@ -121,6 +124,7 @@ impl TauriQconnectEventSink { let mut remote_projection_renderer_id: Option = None; let mut sync_local_playback = false; let mut apply_loop_mode: Option = None; + let mut disconnected_renderer_id: Option = None; let mut state = self.sync_state.lock().await; match message_type { "MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" => { @@ -298,6 +302,19 @@ impl TauriQconnectEventSink { renderer_state.updated_at_ms = qconnect_now_ms(); remote_projection_renderer_id = Some(renderer_id as i32); 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" => { 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 { 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) { diff --git a/src-tauri/src/qconnect/tests.rs b/src-tauri/src/qconnect/tests.rs index f7fa74a87..ecc1677fa 100644 --- a/src-tauri/src/qconnect/tests.rs +++ b/src-tauri/src/qconnect/tests.rs @@ -1080,3 +1080,19 @@ fn device_uuid_persists_and_is_reused_across_calls() { 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 +} diff --git a/src-tauri/src/qconnect/types.rs b/src-tauri/src/qconnect/types.rs index 3c8701681..f7d229b13 100644 --- a/src-tauri/src/qconnect/types.rs +++ b/src-tauri/src/qconnect/types.rs @@ -277,3 +277,28 @@ pub struct QconnectSetMaxAudioQualityRequest { pub struct QconnectAskForRendererStateRequest { pub renderer_id: Option, } + +/// 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) -> 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, + } + } +} diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index 82afce4e1..d6e106b82 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -1839,6 +1839,7 @@ "remoteQueuedNext": "In Qobuz Connect zur nächsten Wiedergabe eingereiht.", "remoteQueuedLater": "Zur Qobuz Connect-Warteschlange hinzugefügt.", "remoteQueueFailed": "Aktualisierung der Qobuz Connect-Warteschlange fehlgeschlagen.", + "rendererDisconnected": "Das entfernte Gerät hat die Verbindung zu Qobuz Connect getrennt.", "sessionTitle": "Sitzung", "sessionUuid": "Sitzungs-UUID", "activeRenderer": "Aktiver Renderer", diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 19df37529..34a783a03 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -1839,6 +1839,7 @@ "remoteQueuedNext": "Queued on Qobuz Connect to play next.", "remoteQueuedLater": "Added to Qobuz Connect queue.", "remoteQueueFailed": "Failed to update Qobuz Connect queue.", + "rendererDisconnected": "The remote device disconnected from Qobuz Connect.", "sessionTitle": "Session", "sessionUuid": "Session UUID", "activeRenderer": "Active renderer", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index 1f253b54d..c45a4901c 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -1839,6 +1839,7 @@ "remoteQueuedNext": "Agregada para reproducir a continuacion en Qobuz Connect.", "remoteQueuedLater": "Agregada a 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", "sessionUuid": "UUID de sesion", "activeRenderer": "Renderer activo", diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json index 791bc6520..5e1083161 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -1839,6 +1839,7 @@ "remoteQueuedNext": "Ajouté à Qobuz Connect pour lecture suivante.", "remoteQueuedLater": "Ajouté à 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", "sessionUuid": "UUID de session", "activeRenderer": "Rendu actif", diff --git a/src/lib/i18n/locales/pt.json b/src/lib/i18n/locales/pt.json index 0f5a79330..868b06eed 100644 --- a/src/lib/i18n/locales/pt.json +++ b/src/lib/i18n/locales/pt.json @@ -1839,6 +1839,7 @@ "remoteQueuedNext": "Adicionado ao Qobuz Connect para reprodução seguinte.", "remoteQueuedLater": "Adicionado à 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", "sessionUuid": "UUID da sessão", "activeRenderer": "Renderer ativo", diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2deb2804c..a97b03f89 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -5592,6 +5592,7 @@ let unlistenQconnectError: UnlistenFn | null = null; let unlistenQconnectStatusChanged: UnlistenFn | null = null; let unlistenQconnectAdmissionBlocked: UnlistenFn | null = null; + let unlistenQconnectRendererDisconnected: UnlistenFn | null = null; let unlistenQconnectDiagnostic: UnlistenFn | null = null; let unlistenQconnectRendererReportDebug: UnlistenFn | null = null; let unlistenAudioDeviceMissing: UnlistenFn | null = null; @@ -5831,6 +5832,17 @@ if (disposed) { unlisten8(); return; } 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('qconnect:diagnostic', (event) => { pushQobuzConnectDiagnostic( event.payload.channel, @@ -5896,6 +5908,7 @@ unlistenQconnectError?.(); unlistenQconnectStatusChanged?.(); unlistenQconnectAdmissionBlocked?.(); + unlistenQconnectRendererDisconnected?.(); unlistenQconnectDiagnostic?.(); unlistenQconnectRendererReportDebug?.(); unlistenAudioDeviceMissing?.();