mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user