mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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]
|
#[tauri::command]
|
||||||
fn exit_app(app_handle: tauri::AppHandle) {
|
fn exit_app(app_handle: tauri::AppHandle) {
|
||||||
|
stop_audio_engine(&app_handle);
|
||||||
app_handle.exit(0);
|
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| {
|
.on_tray_icon_event(|tray, event| {
|
||||||
@@ -4038,10 +4039,9 @@ pub fn run() {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
// On macOS the red close button quits the app entirely.
|
// On macOS the red close button quits the app entirely.
|
||||||
// Stop the audio engine first so sound cuts immediately.
|
// Route through JS so playback position + Orbit state get
|
||||||
let app = window.app_handle();
|
// flushed; exit_app on the way back stops the audio engine.
|
||||||
stop_audio_engine(app);
|
let _ = window.emit("app:force-quit", ());
|
||||||
app.exit(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
|||||||
+33
-17
@@ -93,7 +93,7 @@ import { initHotCachePrefetch } from './hotCachePrefetch';
|
|||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
import { playByOpaqueId } from './utils/playByOpaqueId';
|
import { playByOpaqueId } from './utils/playByOpaqueId';
|
||||||
import { switchActiveServer } from './utils/switchActiveServer';
|
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 { useThemeStore } from './store/themeStore';
|
||||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||||
import { useFontStore } from './store/fontStore';
|
import { useFontStore } from './store/fontStore';
|
||||||
@@ -961,31 +961,47 @@ function TauriEventBridge() {
|
|||||||
unlisten.push(u);
|
unlisten.push(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
// window:close-requested is emitted by Rust (prevent_close + emit).
|
// Shared exit path: flush play-queue position so other devices can
|
||||||
// JS decides: minimize to tray or exit, based on user setting.
|
// 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 () => {
|
const u = await listen('window:close-requested', async () => {
|
||||||
if (useAuthStore.getState().minimizeToTray) {
|
if (useAuthStore.getState().minimizeToTray) {
|
||||||
await invoke('pause_rendering').catch(() => {});
|
await invoke('pause_rendering').catch(() => {});
|
||||||
await getCurrentWindow().hide();
|
await getCurrentWindow().hide();
|
||||||
} else {
|
} else {
|
||||||
// Clean up an active Orbit session before we go down — leaving
|
await performExit();
|
||||||
// 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');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (cancelled) { u(); return; }
|
if (cancelled) { u(); return; }
|
||||||
unlisten.push(u);
|
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();
|
setup();
|
||||||
|
|||||||
@@ -813,9 +813,12 @@ let bytePreloadingId: string | null = null;
|
|||||||
|
|
||||||
// ─── Server queue sync ─────────────────────────────────────────────────────────
|
// ─── Server queue sync ─────────────────────────────────────────────────────────
|
||||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let lastQueueHeartbeatAt = 0;
|
||||||
|
|
||||||
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
|
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
|
||||||
if (syncTimeout) clearTimeout(syncTimeout);
|
if (syncTimeout) clearTimeout(syncTimeout);
|
||||||
syncTimeout = setTimeout(() => {
|
syncTimeout = setTimeout(() => {
|
||||||
|
syncTimeout = null;
|
||||||
const ids = queue.slice(0, 1000).map(t => t.id);
|
const ids = queue.slice(0, 1000).map(t => t.id);
|
||||||
const pos = Math.floor(currentTime * 1000);
|
const pos = Math.floor(currentTime * 1000);
|
||||||
savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
|
savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
|
||||||
@@ -824,6 +827,30 @@ function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTi
|
|||||||
}, 5000);
|
}, 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) ───────────────────
|
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
|
||||||
|
|
||||||
function handleAudioPlaying(_duration: number) {
|
function handleAudioPlaying(_duration: number) {
|
||||||
@@ -875,6 +902,16 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
const progress = displayTime / dur;
|
const progress = displayTime / dur;
|
||||||
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
|
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)
|
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||||
if (progress >= 0.5 && !store.scrobbled) {
|
if (progress >= 0.5 && !store.scrobbled) {
|
||||||
usePlayerStore.setState({ scrobbled: true });
|
usePlayerStore.setState({ scrobbled: true });
|
||||||
@@ -1936,6 +1973,12 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
} else {
|
} else {
|
||||||
invoke('audio_pause').catch(console.error);
|
invoke('audio_pause').catch(console.error);
|
||||||
isAudioPaused = true;
|
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 });
|
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user