fix(player): cross-device resume — push queue position on pause + all exit paths

Server queue position was effectively pinned at 0 because syncQueueToServer
only fired on track-change, seek, and queue edits (with a 5s debounce). A
user listening for minutes without seeking would close the app and the
server still held position=0, so a second device restarted the track.

Three pieces:
- 15s heartbeat from audio:progress flushes the live position while playing.
- pause() flushes immediately so a quick close after pause is captured.
- Tray "Exit" and macOS Cmd+Q used to call app.exit(0) directly, bypassing
  the JS close handler. Both now emit a new app:force-quit event that
  shares the same flush + Orbit-teardown path as window:close-requested,
  and exit_app stops the audio engine on its way out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-26 11:15:12 +02:00
parent c674651d35
commit 4217e8e6a0
3 changed files with 81 additions and 22 deletions
+33 -17
View File
@@ -93,7 +93,7 @@ import { initHotCachePrefetch } from './hotCachePrefetch';
import i18n from './i18n';
import { playByOpaqueId } from './utils/playByOpaqueId';
import { switchActiveServer } from './utils/switchActiveServer';
import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray } from './store/playerStore';
import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray, flushPlayQueuePosition } from './store/playerStore';
import { useThemeStore } from './store/themeStore';
import { useThemeScheduler } from './hooks/useThemeScheduler';
import { useFontStore } from './store/fontStore';
@@ -961,31 +961,47 @@ function TauriEventBridge() {
unlisten.push(u);
}
// window:close-requested is emitted by Rust (prevent_close + emit).
// JS decides: minimize to tray or exit, based on user setting.
// Shared exit path: flush play-queue position so other devices can
// resume from where we left off, tear down any active Orbit session,
// then ask Rust to exit. Each step is capped at 1500 ms so a slow
// server can't keep the app hanging on quit; the playback heartbeat
// is the safety net for anything that didn't make it out in time.
const performExit = async () => {
await Promise.race([
flushPlayQueuePosition(),
new Promise(r => setTimeout(r, 1500)),
]);
const role = useOrbitStore.getState().role;
if (role === 'host' || role === 'guest') {
const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession();
await Promise.race([
teardown.catch(() => {}),
new Promise(r => setTimeout(r, 1500)),
]);
}
await invoke('exit_app');
};
// window:close-requested is emitted by Rust (prevent_close + emit) on
// the X-button. JS decides: minimize to tray or exit.
const u = await listen('window:close-requested', async () => {
if (useAuthStore.getState().minimizeToTray) {
await invoke('pause_rendering').catch(() => {});
await getCurrentWindow().hide();
} else {
// Clean up an active Orbit session before we go down — leaving
// the session playlists behind would litter the server. Capped at
// 1500 ms so a slow server can't keep the app hanging on quit; the
// next launch's orphan sweep is the safety net for anything that
// didn't make it out in time.
const role = useOrbitStore.getState().role;
if (role === 'host' || role === 'guest') {
const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession();
await Promise.race([
teardown.catch(() => {}),
new Promise(r => setTimeout(r, 1500)),
]);
}
await invoke('exit_app');
await performExit();
}
});
if (cancelled) { u(); return; }
unlisten.push(u);
// app:force-quit bypasses the minimize-to-tray decision — used by the
// tray "Exit" menu item and the macOS red close button.
const fq = await listen('app:force-quit', async () => {
await performExit();
});
if (cancelled) { fq(); return; }
unlisten.push(fq);
};
setup();