diff --git a/CHANGELOG.md b/CHANGELOG.md index b79eb89d..81aac8f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -234,6 +234,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Audio reliably starts on join, even after a slow first cold-start** (PR [#526](https://github.com/Psychotoxical/psysonic/pull/526)). Two cases were leaving the guest silent on join: (a) the initial sync's 5 s ready-poll timed out (slow Navidrome warmup), the next pull tick took the cheap "track already loaded" shortcut and fired `seek` + `resume` on a stuck engine — both no-oped against a track that never started; (b) `playTrack`'s optimistic `isPlaying: true` write masked a later `audio_play` rejection, so the guest tick recorded a "successful" sync but the engine had silently fallen back to paused. Both are now handled: the shortcut is gated on engine state matching the host's expected state, and a recovery check at the top of the pull tick resets the anchor whenever the engine is paused while the host is still playing the same track — the next 500 ms fast-poll fires a fresh `playTrack` and audio kicks in. * **Catch Up button stays clickable on high-latency sessions** (PR [#527](https://github.com/Psychotoxical/psysonic/pull/527)). On a real-world high-latency session the genuine drift fluctuates between ~1 s and ~8 s in lockstep with both sides' chunked `currentTime` updates — the previous single-stage debounce hid the button as soon as drift briefly dipped below 3 s even though the baseline drift was still 5–8 s, so the button "appeared and vanished too fast to click". Two-stage hysteresis now: show after drift > 3 s for 3 s, hide only after drift < 1 s for 1 s. * **Initial-sync seek visually sticks on join** (PR [#528](https://github.com/Psychotoxical/psysonic/pull/528)). On join the waveform briefly showed the host's live position then snapped back to 0:00 with audio playing from the start — the post-`playTrack` poll fired `applyMirror`'s seek against `playTrack`'s optimistic `isPlaying: true`, before the Tauri `audio_play` had actually produced any samples. The seek's store update landed (waveform at 70 %) but the `audio_seek` debounced behind it no-oped on the not-ready engine, and the engine's first progress events from 0:00 overwrote the optimistic position. Now the poll waits for `currentTime > 0.1` before applying the seek, and `applyMirror` defers the play-state mirror by 200 ms so the seek's invoke wins the IPC ordering race against pause/resume. +* **Host single-track plays no longer wipe the Orbit queue** (PR [#529](https://github.com/Psychotoxical/psysonic/pull/529)). A `playTrack(track, [track])` call from any UI that passes an explicit 1-track replacement queue (e.g. OfflineLibrary's "Play this album" on a single-track album) slipped past the orbit bulk-guard (which only fires for `queue.length > 1`) and replaced the host's `playerStore.queue`. Since the host's queue *is* the shared Orbit queue, that one click destroyed every accepted guest suggestion + every upcoming track. Now intercepted: when role is host and the incoming queue is a single track, append + jump instead of replacing. ### Context menu — render above the floating player bar diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 0ff56ec7..9eefb62b 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -2523,6 +2523,34 @@ export const usePlayerStore = create()( } } + // Orbit-host single-track protection. The host's `playerStore.queue` + // *is* the shared Orbit queue. A `playTrack(track, [track])` call + // (e.g. OfflineLibrary's "Play this album" on a single-track album, + // or any other surface that explicitly passes a 1-track replacement + // queue) would otherwise blow away every guest suggestion + every + // upcoming track. Re-route to append + jump so the queue survives. + // Guest stays unguarded — a guest clicking Play locally is choosing + // to opt out of host-sync, which is the existing "guest is running + // their own show" path. `useOrbitGuest`'s `syncToHost` is also a + // guest-only call site, so it's never intercepted here. + if (!_orbitConfirmed && queue && queue.length === 1) { + const orbitRole = useOrbitStore.getState().role; + if (orbitRole === 'host') { + const current = get().queue; + const currentTrackId = current[get().queueIndex]?.id; + if (track.id !== currentTrackId) { + const existsAt = current.findIndex(t => sameQueueTrackId(t.id, track.id)); + if (existsAt >= 0) { + get().playTrack(track, current, manual, true, existsAt); + } else { + const newQueue = [...current, track]; + get().playTrack(track, newQueue, manual, true, newQueue.length - 1); + } + return; + } + } + } + // Ghost-command guard: if a gapless switch happened within 500 ms, // this playTrack call is likely a stale IPC echo — suppress it. if (Date.now() - lastGaplessSwitchTime < 500) {