fix(orbit): make initial-sync seek visually stick on join (#528)

* fix(orbit): make initial-sync seek visually stick on join

Reported: "I join the room, the waveform shows the host's live
position for a second or two, then snaps back to 0:00 and audio is
still playing from the start. Only after that snap can I press Catch
Up."

Two compounding causes in `useOrbitGuest`:

**1. Poll fires `applyMirror` before the engine is genuinely playing.**
   `playTrack` flips `isPlaying` to `true` *synchronously* in its
   optimistic store write, so the post-`playTrack` poll satisfied its
   "engine ready" check before the Tauri `audio_play` had even
   started producing sample. The `seek` inside `applyMirror` updates
   the store position immediately (waveform jumps to host's live
   position), but `audio_seek` is debounced and lands on a not-ready
   engine — it silently no-ops. The engine then starts playing from
   0, and its first progress events overwrite the optimistic seek
   position, snapping the waveform back. Add `currentTime > 0.1` to
   the poll's "engine genuinely playing" condition: once audio has
   flowed past the cold-start barrier, the seek commits and the
   seek-target guard correctly filters subsequent progress events.

**2. `applyMirror`'s play-state mirror raced its seek**, same shape
   as the `onCatchUp` race fixed in #527. `player.seek` debounces
   `audio_seek` via setTimeout(0) while `pause`/`resume` invoke
   synchronously — pause arriving first leaves the engine paused at
   the old position. Defer the play-state mirror by 200 ms so the
   seek lands first.

* docs(changelog): add initial-sync seek-stickiness bullet
This commit is contained in:
Frank Stellmacher
2026-05-09 22:44:52 +02:00
committed by GitHub
parent 6c5465e9c7
commit eae649bcad
2 changed files with 30 additions and 3 deletions
+1
View File
@@ -233,6 +233,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Track preview is now hidden + blocked during an Orbit session.** The preview path runs through the same Rust audio engine as the shared playback, so starting a preview as a guest would clobber the host's track in the local player. The preview button is hidden across all surfaces (album / artist / favourites / playlist / random-mix track lists) via a global `[data-orbit-active]` CSS rule, and `previewStore.startPreview` no-ops as a defensive guard for keyboard shortcuts and any programmatic callers.
* **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 58 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.
### Context menu — render above the floating player bar
+29 -3
View File
@@ -91,8 +91,20 @@ export function useOrbitGuest(): void {
const p = usePlayerStore.getState();
if (cancelled || p.currentTrack?.id !== trackId) return false;
p.seek(calcFraction());
if (hostState.isPlaying && !p.isPlaying) p.resume();
else if (!hostState.isPlaying && p.isPlaying) p.pause();
// Defer the play-state mirror so the seek's `audio_seek` invoke
// arrives at the engine before pause/resume. `player.seek` is
// debounced via setTimeout(0); `pause`/`resume` fire their
// invokes synchronously — without the delay the play-state
// change can race ahead of the seek and leave the engine in
// the wrong position.
if (hostState.isPlaying !== p.isPlaying) {
window.setTimeout(() => {
const fresh = usePlayerStore.getState();
if (cancelled || fresh.currentTrack?.id !== trackId) return;
if (hostState.isPlaying && !fresh.isPlaying) fresh.resume();
else if (!hostState.isPlaying && fresh.isPlaying) fresh.pause();
}, 200);
}
return true;
};
@@ -132,7 +144,21 @@ export function useOrbitGuest(): void {
if (cancelled) { resolve(false); return; }
const p = usePlayerStore.getState();
const trackReady = p.currentTrack?.id === trackId;
if (trackReady && p.isPlaying) { resolve(applyMirror()); return; }
// Wait for the engine to *actually* be playing, not just for
// `isPlaying = true` (which `playTrack` flips synchronously
// before the audio engine has produced a single sample).
// Also require `currentTime > 0.1` — once audio has flowed
// past the cold-start barrier, the engine is genuinely
// playing and a `seek` will commit. Without this check the
// seek inside `applyMirror` lands on a not-yet-ready engine,
// silently no-ops, and the engine's first progress events
// overwrite the optimistic store position — the visible
// symptom on join is "the waveform shows the host's live
// position for a second, then snaps back to 0:00".
const enginePlaying = trackReady
&& p.isPlaying
&& (p.currentTime ?? 0) > 0.1;
if (enginePlaying) { resolve(applyMirror()); return; }
if (Date.now() >= deadline) { resolve(false); return; }
window.setTimeout(poll, 100);
};