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