From 4217e8e6a03c043610476b2abeb7847c4f2a3034 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:15:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(player):=20cross-device=20resume=20?= =?UTF-8?q?=E2=80=94=20push=20queue=20position=20on=20pause=20+=20all=20ex?= =?UTF-8?q?it=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src-tauri/src/lib.rs | 10 ++++---- src/App.tsx | 50 ++++++++++++++++++++++++++-------------- src/store/playerStore.ts | 43 ++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a770ed2b..92b74a1d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { } } } - "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"))] diff --git a/src/App.tsx b/src/App.tsx index 0e2a5000..e6fe2881 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 61b2d9b9..13c1dbba 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -813,9 +813,12 @@ let bytePreloadingId: string | null = null; // ─── Server queue sync ───────────────────────────────────────────────────────── let syncTimeout: ReturnType | 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 { + 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 { + 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()( } 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 }); },