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
+5 -5
View File
@@ -109,6 +109,7 @@ fn greet(name: &str) -> String {
#[tauri::command]
fn exit_app(app_handle: tauri::AppHandle) {
stop_audio_engine(&app_handle);
app_handle.exit(0);
}
@@ -3161,7 +3162,7 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
}
}
}
"quit" => { stop_audio_engine(app); app.exit(0); }
"quit" => { let _ = app.emit("app:force-quit", ()); }
_ => {}
})
.on_tray_icon_event(|tray, event| {
@@ -4038,10 +4039,9 @@ pub fn run() {
#[cfg(target_os = "macos")]
{
// On macOS the red close button quits the app entirely.
// Stop the audio engine first so sound cuts immediately.
let app = window.app_handle();
stop_audio_engine(app);
app.exit(0);
// Route through JS so playback position + Orbit state get
// flushed; exit_app on the way back stops the audio engine.
let _ = window.emit("app:force-quit", ());
}
#[cfg(not(target_os = "macos"))]
+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();
+43
View File
@@ -813,9 +813,12 @@ let bytePreloadingId: string | null = null;
// ─── Server queue sync ─────────────────────────────────────────────────────────
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
let lastQueueHeartbeatAt = 0;
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(() => {
syncTimeout = null;
const ids = queue.slice(0, 1000).map(t => t.id);
const pos = Math.floor(currentTime * 1000);
savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
@@ -824,6 +827,30 @@ function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTi
}, 5000);
}
// Cancel any pending debounced sync and push the current position
// immediately. Used by the playback heartbeat, pause(), and the
// app-close handler — all paths where a user might switch to another
// device and expect to resume from the right spot.
function flushQueueSyncToServer(queue: Track[], currentTrack: Track | null, currentTime: number): Promise<void> {
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = null;
}
if (!currentTrack || queue.length === 0) return Promise.resolve();
lastQueueHeartbeatAt = Date.now();
const ids = queue.slice(0, 1000).map(t => t.id);
const pos = Math.floor(currentTime * 1000);
return savePlayQueue(ids, currentTrack.id, pos).catch(err => {
console.error('Failed to flush play queue to server', err);
});
}
export function flushPlayQueuePosition(): Promise<void> {
const s = usePlayerStore.getState();
if (s.currentRadio) return Promise.resolve();
return flushQueueSyncToServer(s.queue, s.currentTrack, s.currentTime);
}
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
function handleAudioPlaying(_duration: number) {
@@ -875,6 +902,16 @@ function handleAudioProgress(current_time: number, duration: number) {
const progress = displayTime / dur;
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
// Heartbeat: push current position to the server every 15 s while
// playing so cross-device resume works even on a hard close — pause()
// and the close handler flush on top of this for clean shutdowns.
if (store.isPlaying && !store.currentRadio) {
const now = Date.now();
if (now - lastQueueHeartbeatAt >= 15_000) {
void flushQueueSyncToServer(store.queue, track, displayTime);
}
}
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
if (progress >= 0.5 && !store.scrobbled) {
usePlayerStore.setState({ scrobbled: true });
@@ -1936,6 +1973,12 @@ export const usePlayerStore = create<PlayerState>()(
} else {
invoke('audio_pause').catch(console.error);
isAudioPaused = true;
// Flush position so a quick close after pause still leaves the
// server with the right resume point for other devices.
const s = get();
if (s.currentTrack) {
void flushQueueSyncToServer(s.queue, s.currentTrack, s.currentTime);
}
}
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
},