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:
1 file changed
+61
-9
+61
-9
@@ -4176,11 +4176,24 @@
|
|||||||
await syncQueueState();
|
await syncQueueState();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionPersistEnabled && !qconnectRuntimeActive) {
|
if (sessionPersistEnabled) {
|
||||||
const session = await loadSessionState();
|
const session = await loadSessionState();
|
||||||
|
|
||||||
// Restore queue + track (visual only — paused at 0:00)
|
// Last-page restore is unrelated to the queue and runs unconditionally
|
||||||
if (session && session.queue_tracks.length > 0) {
|
// — 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...');
|
console.log('[Session] Restoring previous session...');
|
||||||
|
|
||||||
const tracks: BackendQueueTrack[] = session.queue_tracks.map(trk => ({
|
const tracks: BackendQueueTrack[] = session.queue_tracks.map(trk => ({
|
||||||
@@ -4282,14 +4295,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Session] Session restored successfully');
|
console.log('[Session] Session restored successfully');
|
||||||
}
|
};
|
||||||
|
|
||||||
// Restore last page (opt-in via settings)
|
if (qconnectRuntimeActive) {
|
||||||
if (session) {
|
// QConnect is bootstrapping at launch. Hold back the local
|
||||||
restoreLastView(session);
|
// 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) {
|
} catch (err) {
|
||||||
console.error('[Session] Failed to restore session:', 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)
|
// Debounced full session save (coalesces rapid state changes into a single save)
|
||||||
let sessionSaveDebounce: ReturnType<typeof setTimeout> | null = null;
|
let sessionSaveDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
function debouncedFullSessionSave() {
|
function debouncedFullSessionSave() {
|
||||||
@@ -5574,7 +5607,22 @@
|
|||||||
'RendererCommandApplied' in payload ||
|
'RendererCommandApplied' in payload ||
|
||||||
'PendingActionCompleted' in payload;
|
'PendingActionCompleted' in payload;
|
||||||
if (needsQueueSync) {
|
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();
|
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 =
|
const needsQconnectSnapshotRefresh =
|
||||||
@@ -5717,6 +5765,10 @@
|
|||||||
unsubscribePlayer();
|
unsubscribePlayer();
|
||||||
unsubscribeQueue();
|
unsubscribeQueue();
|
||||||
unregisterQueueMutationObserver();
|
unregisterQueueMutationObserver();
|
||||||
|
if (sessionRestoreFlickerGuard !== null) {
|
||||||
|
clearTimeout(sessionRestoreFlickerGuard);
|
||||||
|
sessionRestoreFlickerGuard = null;
|
||||||
|
}
|
||||||
unsubscribeLyrics();
|
unsubscribeLyrics();
|
||||||
unsubscribeContentSidebar();
|
unsubscribeContentSidebar();
|
||||||
unsubscribeCast();
|
unsubscribeCast();
|
||||||
|
|||||||
Reference in new issue
Block a user