mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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:
@@ -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