diff --git a/src-tauri/src/qconnect/service.rs b/src-tauri/src/qconnect/service.rs index e546186d7..60c0d77fe 100644 --- a/src-tauri/src/qconnect/service.rs +++ b/src-tauri/src/qconnect/service.rs @@ -431,6 +431,15 @@ impl QconnectServiceState { { log::warn!("[QConnect] AskForRendererState after reconnect failed: {err}"); } + // P1-11: signal the frontend that the post-reconnect + // resync has been issued, so it can evaluate whether + // to replay or advise on the last transport intent. + emit_qconnect_diagnostic( + &app_handle_for_status, + "qconnect:resync_complete", + "info", + json!({}), + ); } } qconnect_transport_ws::TransportEvent::KeepalivePingSent => { diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index 4a8743376..494242aff 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -1803,6 +1803,7 @@ }, "qconnect": { "title": "Qobuz Connect", + "lastActionMayNotHaveApplied": "Die letzte Aktion wurde möglicherweise nicht angewendet. Bitte überprüfe den aktuellen Status.", "statusConnected": "Verbunden", "statusDisconnected": "Getrennt", "turnOn": "Einschalten", diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index cc8d09892..993ab72d5 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -1803,6 +1803,7 @@ }, "qconnect": { "title": "Qobuz Connect", + "lastActionMayNotHaveApplied": "The last action may not have applied. Please check the current state.", "statusConnected": "Connected", "statusDisconnected": "Disconnected", "turnOn": "Turn On", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index 1907c4a14..809391297 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -1803,6 +1803,7 @@ }, "qconnect": { "title": "Qobuz Connect", + "lastActionMayNotHaveApplied": "Es posible que la última acción no se haya aplicado. Revisa el estado actual.", "statusConnected": "Conectado", "statusDisconnected": "Desconectado", "turnOn": "Encender", diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json index f887363e2..f35f11862 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -1803,6 +1803,7 @@ }, "qconnect": { "title": "Qobuz Connect", + "lastActionMayNotHaveApplied": "La dernière action n'a peut-être pas été appliquée. Veuillez vérifier l'état actuel.", "statusConnected": "Connecté", "statusDisconnected": "Déconnecté", "turnOn": "Activer", diff --git a/src/lib/i18n/locales/pt.json b/src/lib/i18n/locales/pt.json index bd0054644..71f666b95 100644 --- a/src/lib/i18n/locales/pt.json +++ b/src/lib/i18n/locales/pt.json @@ -1803,6 +1803,7 @@ }, "qconnect": { "title": "Qobuz Connect", + "lastActionMayNotHaveApplied": "A última ação pode não ter sido aplicada. Verifique o estado atual.", "statusConnected": "Conectado", "statusDisconnected": "Desconectado", "turnOn": "Ligar", diff --git a/src/lib/services/qconnectRuntime.test.ts b/src/lib/services/qconnectRuntime.test.ts index 4c2b3704c..a6ecaaf96 100644 --- a/src/lib/services/qconnectRuntime.test.ts +++ b/src/lib/services/qconnectRuntime.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { evaluateQconnectPlaybackReportSkip, + evaluateQconnectReconnectAdvisory, evaluateQconnectSessionPersistence, isQconnectPeerRendererActive, isQconnectRemoteModeActive, @@ -246,3 +247,46 @@ describe('QConnect runtime state helpers', () => { ).toBeNull(); }); }); + +describe('evaluateQconnectReconnectAdvisory', () => { + const base = { prevState: 'reconnecting' as const, nextState: 'connected' as const, now: 1000 }; + + it('replays an idempotent play intent issued recently', () => { + const r = evaluateQconnectReconnectAdvisory({ + ...base, + lastIntent: { kind: 'play', issuedAt: 900 } + }); + expect(r.shouldReplay).toBe(true); + expect(r.shouldToast).toBe(false); + }); + + it('only advises (no replay) for next, to avoid double-skip', () => { + const r = evaluateQconnectReconnectAdvisory({ + ...base, + lastIntent: { kind: 'next', issuedAt: 900 } + }); + expect(r.shouldReplay).toBe(false); + expect(r.shouldToast).toBe(true); + expect(r.toastKey).toBe('qconnect.lastActionMayNotHaveApplied'); + }); + + it('does nothing when the intent is stale', () => { + const r = evaluateQconnectReconnectAdvisory({ + ...base, + now: 100_000, + lastIntent: { kind: 'play', issuedAt: 900 } + }); + expect(r.shouldReplay).toBe(false); + expect(r.shouldToast).toBe(false); + }); + + it('does nothing outside a reconnecting->connected transition', () => { + const r = evaluateQconnectReconnectAdvisory({ + prevState: 'connected', + nextState: 'connected', + now: 1000, + lastIntent: { kind: 'play', issuedAt: 900 } + }); + expect(r.shouldReplay).toBe(false); + }); +}); diff --git a/src/lib/services/qconnectRuntime.ts b/src/lib/services/qconnectRuntime.ts index 911cbac57..a13fb5873 100644 --- a/src/lib/services/qconnectRuntime.ts +++ b/src/lib/services/qconnectRuntime.ts @@ -334,3 +334,70 @@ export async function toggleQconnectConnection(toggleOn: boolean): Promise await invoke('v2_qconnect_connect', { options: null }); } + +/** + * A user-issued remote transport intent we may want to replay after a + * reconnect (P1-11). + */ +export type QconnectTransportIntent = { + kind: 'play' | 'pause' | 'next' | 'previous' | 'set_track'; + trackId?: number | null; + /** Epoch ms when the intent was issued. */ + issuedAt: number; +}; + +export type QconnectReconnectAdvisory = { + shouldReplay: boolean; + shouldToast: boolean; + toastKey: string | null; +}; + +/** + * Pure decision helper (P1-11): on a `reconnecting -> connected` transition, + * decide whether to replay the last user transport intent or merely advise. + * + * - Idempotent intents (`play` / `pause` / `set_track`) issued recently are + * safe to replay verbatim. + * - `next` / `previous` are NOT replayed — the action may already have applied + * on the renderer before the drop, so replaying risks a double-skip; we only + * surface an advisory toast instead. + * - Stale intents (older than `staleAfterMs`) and non-reconnect transitions are + * no-ops. + * + * No side effects, no `invoke` — fully unit-testable. + */ +export function evaluateQconnectReconnectAdvisory(params: { + prevState: QconnectLifecycleState; + nextState: QconnectLifecycleState; + lastIntent: QconnectTransportIntent | null; + now: number; + staleAfterMs?: number; +}): QconnectReconnectAdvisory { + const { prevState, nextState, lastIntent, now } = params; + const staleAfterMs = params.staleAfterMs ?? 15_000; + const noop: QconnectReconnectAdvisory = { + shouldReplay: false, + shouldToast: false, + toastKey: null + }; + const reconnected = prevState === 'reconnecting' && nextState === 'connected'; + if (!reconnected || !lastIntent) { + return noop; + } + if (now - lastIntent.issuedAt > staleAfterMs) { + return noop; + } + const idempotent = + lastIntent.kind === 'play' || + lastIntent.kind === 'pause' || + lastIntent.kind === 'set_track'; + if (idempotent) { + return { shouldReplay: true, shouldToast: false, toastKey: null }; + } + // next/previous: replaying risks a double-skip -> only advise. + return { + shouldReplay: false, + shouldToast: true, + toastKey: 'qconnect.lastActionMayNotHaveApplied' + }; +}