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
+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 });
},