mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
committed by
GitHub
parent
6c5465e9c7
commit
eae649bcad
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user