fix(qconnect): decouple toggle state from transport so user can disable a stuck reconnect
When the reconnect loop is stuck waiting for a SESSION_STATE that never comes (the actual symptom of issue #358), the runtime is alive but `transport_connected` is false. The UI used to read its on/off toggle from `transport_connected`, so the badge appeared OFF while the backend loop kept retrying. Worse: clicking the toggle in that state called `connect()`, which rejected with "QConnect service is already running". The user had no way to disable QConnect from the UI — only to "turn it on more", which failed. Backend (`qconnect_service.rs`): - Add `QconnectLifecycleState` enum (Off / Connecting / Connected / Reconnecting / Exhausted) and surface it on `QconnectConnectionStatus::state`. Driven from the spawned transport event loop via a new `update_lifecycle_state_if_running` helper which refuses to overwrite terminal states (Off / Exhausted) with late Reconnecting / Connected events from the broadcast queue. - Make `connect()` idempotent: if a runtime already exists (alive but reconnecting), return current status instead of erroring. The toggle is the single user-visible control; the previous "already running" error was unactionable from the UI. - Auto-stop on `MaxReconnectAttemptsExceeded`: take the runtime out, set state=Exhausted, set last_error to the cloud reason, and emit a `qconnect:status_changed` event so the frontend reflects the new state without waiting for its 5 s poll. The transport loop has already exited at this point, so a fresh user-initiated connect() cleanly starts a new runtime. - `disconnect()` now forces lifecycle to Off unconditionally and tears down the runtime (already worked, the transport's `shutdown_tx` watch breaks `handle_reconnect_delay` mid-sleep), satisfying the "disable QConnect must always succeed" requirement. Frontend (`qconnectRuntime.ts`, `+page.svelte`): - Add `QconnectLifecycleState` mirror type and `isQconnectToggleOn()` helper. - Split state in the page: `isQobuzConnectConnected` keeps its old semantics (`transport_connected`) and gates send-command paths (volume reports, position reports) so we don't fire commands while the WS is down. New `isQobuzConnectToggleOn` reflects the user-facing on/off — true even during Reconnecting/Connecting — and is what the badge component and the toggle handler now read. - Listen for the new `qconnect:status_changed` event so the toggle reflects auto-stop (Exhausted) and other transitions immediately instead of after the 5 s status poll. Refs #358 cc
This commit is contained in:
3 files changed
+209
-11
No files matched your search
+41
-6
@@ -371,6 +371,7 @@
|
||||
fetchQconnectRuntimeState,
|
||||
isQconnectPeerRendererActive,
|
||||
isQconnectRemoteModeActive as computeQconnectRemoteModeActive,
|
||||
isQconnectToggleOn,
|
||||
logQconnectPlaybackReport as appendQconnectPlaybackReport,
|
||||
qconnectAdmissionReasonKey,
|
||||
shouldQconnectSuppressLocalPlaybackAutomation,
|
||||
@@ -949,6 +950,14 @@
|
||||
let isQconnectPanelOpen = $state(false);
|
||||
let showQconnectDevButton = $state(localStorage.getItem('qbz-qconnect-dev-button') === 'true');
|
||||
let isQobuzConnectConnected = $state(false);
|
||||
/**
|
||||
* User-facing toggle state. True when the user has QConnect enabled even if
|
||||
* the WS is currently re-establishing (Connecting/Reconnecting). This is
|
||||
* separate from `isQobuzConnectConnected` so a stuck reconnect loop is
|
||||
* still visible as "on" in the UI, allowing the user to disable it
|
||||
* (issue #358).
|
||||
*/
|
||||
let isQobuzConnectToggleOn = $state(false);
|
||||
let qobuzConnectBusy = $state(false);
|
||||
let qobuzConnectRefreshBusy = $state(false);
|
||||
let qobuzConnectStatus = $state<QconnectConnectionStatus>(DEFAULT_QCONNECT_CONNECTION_STATUS);
|
||||
@@ -1147,8 +1156,15 @@
|
||||
|
||||
function applyQobuzConnectStatus(status: QconnectConnectionStatus): void {
|
||||
qobuzConnectStatus = status;
|
||||
const nextConnected = Boolean(status.transport_connected);
|
||||
isQobuzConnectConnected = nextConnected;
|
||||
// `isQobuzConnectConnected` reflects whether the WS is actually up — it
|
||||
// gates "send command to QConnect server" calls (volume reports, position
|
||||
// reports, etc.). It must NOT include Connecting/Reconnecting, otherwise
|
||||
// we'd send commands while the transport is down.
|
||||
isQobuzConnectConnected = Boolean(status.transport_connected);
|
||||
// `isQobuzConnectToggleOn` is the user-facing on/off — true even during a
|
||||
// stuck reconnect loop, so the user can disable it from the UI
|
||||
// (issue #358).
|
||||
isQobuzConnectToggleOn = isQconnectToggleOn(status);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -1314,7 +1330,11 @@
|
||||
if (qobuzConnectBusy) return;
|
||||
qobuzConnectBusy = true;
|
||||
try {
|
||||
await toggleQconnectConnection(isQobuzConnectConnected);
|
||||
// Use the user-facing toggle state, not transport_connected. During a
|
||||
// stuck reconnect loop `isQobuzConnectConnected` is false but
|
||||
// `isQobuzConnectToggleOn` is true — clicking the toggle must call
|
||||
// disconnect to break the loop (issue #358).
|
||||
await toggleQconnectConnection(isQobuzConnectToggleOn);
|
||||
} catch (err) {
|
||||
console.error('Qobuz Connect toggle failed:', err);
|
||||
pushQobuzConnectDiagnostic('toggle', 'error', err);
|
||||
@@ -4490,7 +4510,10 @@
|
||||
|
||||
void refreshQobuzConnectRuntimeState();
|
||||
const qobuzConnectStatusInterval = setInterval(() => {
|
||||
if (isQconnectPanelOpen || isQobuzConnectConnected) {
|
||||
// Poll the runtime when the panel is open OR the toggle is on — this
|
||||
// includes Connecting/Reconnecting/Exhausted, so the UI stays in sync
|
||||
// through state transitions (issue #358).
|
||||
if (isQconnectPanelOpen || isQobuzConnectToggleOn) {
|
||||
void refreshQobuzConnectRuntimeState();
|
||||
} else {
|
||||
void refreshQobuzConnectStatus();
|
||||
@@ -5128,6 +5151,7 @@
|
||||
let unlistenLinkResolved: UnlistenFn | null = null;
|
||||
let unlistenQconnectEvent: UnlistenFn | null = null;
|
||||
let unlistenQconnectError: UnlistenFn | null = null;
|
||||
let unlistenQconnectStatusChanged: UnlistenFn | null = null;
|
||||
let unlistenQconnectAdmissionBlocked: UnlistenFn | null = null;
|
||||
let unlistenQconnectDiagnostic: UnlistenFn | null = null;
|
||||
let unlistenQconnectRendererReportDebug: UnlistenFn | null = null;
|
||||
@@ -5301,6 +5325,16 @@
|
||||
if (disposed) { unlisten7(); return; }
|
||||
unlistenQconnectError = unlisten7;
|
||||
|
||||
// Backend emits this whenever the lifecycle transitions
|
||||
// (Connecting → Reconnecting → Exhausted, etc). Refresh status promptly
|
||||
// so the toggle reflects the new state without waiting for the 5s poll
|
||||
// (issue #358).
|
||||
const unlistenStatusChanged = await listen('qconnect:status_changed', () => {
|
||||
void refreshQobuzConnectStatus();
|
||||
});
|
||||
if (disposed) { unlistenStatusChanged(); return; }
|
||||
unlistenQconnectStatusChanged = unlistenStatusChanged;
|
||||
|
||||
const unlisten8 = await listen<QconnectAdmissionBlockedEvent>('qconnect:admission_blocked', (event) => {
|
||||
pushQobuzConnectDiagnostic('qconnect:admission_blocked', 'warn', event.payload);
|
||||
showToast($t(qconnectAdmissionReasonKey(event.payload.reason)), 'warning');
|
||||
@@ -5353,6 +5387,7 @@
|
||||
unlistenLinkResolved?.();
|
||||
unlistenQconnectEvent?.();
|
||||
unlistenQconnectError?.();
|
||||
unlistenQconnectStatusChanged?.();
|
||||
unlistenQconnectAdmissionBlocked?.();
|
||||
unlistenQconnectDiagnostic?.();
|
||||
unlistenQconnectRendererReportDebug?.();
|
||||
@@ -6482,7 +6517,7 @@
|
||||
onCast={openCastPicker}
|
||||
{isCastConnected}
|
||||
onQobuzConnect={openQobuzConnectPanelFromNowPlaying}
|
||||
{isQobuzConnectConnected}
|
||||
isQobuzConnectConnected={isQobuzConnectToggleOn}
|
||||
onToggleLyrics={toggleLyricsSidebar}
|
||||
lyricsActive={lyricsSidebarVisible}
|
||||
onArtistClick={() => {
|
||||
@@ -6529,7 +6564,7 @@
|
||||
onCast={openCastPicker}
|
||||
{isCastConnected}
|
||||
onQobuzConnect={openQobuzConnectPanelFromNowPlaying}
|
||||
{isQobuzConnectConnected}
|
||||
isQobuzConnectConnected={isQobuzConnectToggleOn}
|
||||
queueOpen={isQueueOpen}
|
||||
{volume}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
|
||||
Reference in new issue
Block a user