From eae649bcadcb766b19fd73035cdb41b5abb2441d Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 9 May 2026 22:44:52 +0200 Subject: [PATCH] fix(orbit): make initial-sync seek visually stick on join (#528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- CHANGELOG.md | 1 + src/hooks/useOrbitGuest.ts | 32 +++++++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b83832a..b79eb89d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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. ### Context menu — render above the floating player bar diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts index ddd46f40..01625f5d 100644 --- a/src/hooks/useOrbitGuest.ts +++ b/src/hooks/useOrbitGuest.ts @@ -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); };