feat(session): restore local on startup, let QConnect overwrite when it lands (#315)

Today the startup orchestration skips the local session restore whenever
qconnectRuntimeActive — defensive against the visual flicker of seeing
the last local queue and then watching QConnect overwrite it. That left
QConnect users staring at "No track playing" while the WebSocket
bootstrapped, and the queue was effectively unrecoverable if QConnect
failed to push state.

Inverts the policy per #315: always restore the local snapshot. When
QConnect is bootstrapping at launch, the restore is held back behind a
1s flicker guard timer. The QueueUpdated event listener cancels the
timer if the remote queue snapshot arrives first — the user sees only
QConnect state, no flicker. If 1s elapses without a remote snapshot
(slow network, server hiccup, controller offline), the timer fires and
local restore proceeds so the user isn't stuck.

Two related wires:
- restoreLastView runs unconditionally now (view restore is unrelated
  to playback source, shouldn't be gated on QConnect).
- The QConnect listener also calls debouncedFullSessionSave after a
  successful QueueUpdated sync, covering the case where QConnect
  reorders/replaces upcoming tracks while the current_track stays the
  same — the track-change effect wouldn't fire, and without this hook
  the local snapshot would drift behind remote.

Acceptance from issue #315: queue persistence on every mutation
(prior commit), QConnect state read at startup (already wired via
bootstrap_remote_presence → AskForQueueState → QueueStateChanged),
QConnect wins on divergence (cancel-guard makes this the typical
path), local-only path unchanged when QConnect is off (else branch).

The auto-advance / delete-timing guard logic in corebridge.rs and
commands_v2/playback.rs is untouched — the change lives in the route
component's restore orchestration, not in the QConnect runtime.

cc
This commit is contained in:
vicrodh committed 2026-05-13 12:54:36 -06:00
1 parent 267440f541
commit d4745f84a3
1 file changed
+61 -9
+61 -9
View File
@@ -4176,11 +4176,24 @@
await syncQueueState();
}
if (sessionPersistEnabled && !qconnectRuntimeActive) {
if (sessionPersistEnabled) {
const session = await loadSessionState();
// Restore queue + track (visual only — paused at 0:00)
if (session && session.queue_tracks.length > 0) {
// Last-page restore is unrelated to the queue and runs unconditionally
// — having QConnect connecting at startup shouldn't change which UI
// view the user lands on.
if (session) {
restoreLastView(session);
}
// Queue + track visual restore. Wrapped in a closure so the flicker
// guard below can defer it without duplicating the body. When
// QConnect is bootstrapping, we wait up to 1s for the remote queue
// to land — the QueueUpdated listener cancels this timer if it
// does, so the user sees the remote queue directly instead of
// flashing the local one and then swapping.
const performQueueRestore = async () => {
if (!session || session.queue_tracks.length === 0) return;
console.log('[Session] Restoring previous session...');
const tracks: BackendQueueTrack[] = session.queue_tracks.map(trk => ({
@@ -4282,14 +4295,25 @@
}
console.log('[Session] Session restored successfully');
}
};
// Restore last page (opt-in via settings)
if (session) {
restoreLastView(session);
if (qconnectRuntimeActive) {
// QConnect is bootstrapping at launch. Hold back the local
// restore for 1s — if the remote queue snapshot arrives first
// (QueueUpdated event in the QConnect listener cancels this
// timer), the user sees only the remote state and we skip the
// flicker. If 1s elapses without a remote snapshot, fall back
// to the local restore so the user isn't stuck looking at
// "No track playing" indefinitely.
console.log('[Session] Deferring local restore (waiting for QConnect snapshot)');
sessionRestoreFlickerGuard = setTimeout(() => {
sessionRestoreFlickerGuard = null;
console.log('[Session] QConnect snapshot timeout, applying local restore');
void performQueueRestore();
}, 1000);
} else {
await performQueueRestore();
}
} else if (qconnectRuntimeActive) {
console.log('[Session] Skipping local session restore while Qobuz Connect remote mode is active');
}
} catch (err) {
console.error('[Session] Failed to restore session:', err);
@@ -4584,6 +4608,15 @@
}
});
// Timer that holds back the local session restore for ~1s when Qobuz
// Connect is bootstrapping at launch. If the remote queue arrives first
// (QueueUpdated event), the listener cancels the timer and we skip the
// local restore — avoids the visual flicker of swapping from the last
// local queue to the remote one. If QConnect doesn't respond in time,
// the timer fires and the local restore proceeds. See onMount QConnect
// event listener (~line 5577) for the cancellation site.
let sessionRestoreFlickerGuard: ReturnType<typeof setTimeout> | null = null;
// Debounced full session save (coalesces rapid state changes into a single save)
let sessionSaveDebounce: ReturnType<typeof setTimeout> | null = null;
function debouncedFullSessionSave() {
@@ -5574,7 +5607,22 @@
'RendererCommandApplied' in payload ||
'PendingActionCompleted' in payload;
if (needsQueueSync) {
// The remote snapshot arrived. Cancel the startup flicker
// guard if it is still pending — we want the QConnect state
// to win, not the local restore (issue #315). If the timer
// already fired or wasn't set, this is a no-op.
if (sessionRestoreFlickerGuard !== null) {
clearTimeout(sessionRestoreFlickerGuard);
sessionRestoreFlickerGuard = null;
console.log('[Session] QConnect snapshot arrived, skipping deferred local restore');
}
syncQueueState();
// Persist the QConnect-driven queue change so the local
// snapshot mirrors remote state. Covers the case where the
// remote keeps the same current_track but reorders/replaces
// upcoming tracks — the track-change effect wouldn't fire
// (issue #315 acceptance: local snapshot updated to match).
debouncedFullSessionSave();
}
const needsQconnectSnapshotRefresh =
@@ -5717,6 +5765,10 @@
unsubscribePlayer();
unsubscribeQueue();
unregisterQueueMutationObserver();
if (sessionRestoreFlickerGuard !== null) {
clearTimeout(sessionRestoreFlickerGuard);
sessionRestoreFlickerGuard = null;
}
unsubscribeLyrics();
unsubscribeContentSidebar();
unsubscribeCast();