fix(orbit): hysteresis on Catch Up visibility so the button stays clickable (#527)

* fix(orbit): keep Catch Up button visible long enough to click

Reported during testing: the Catch Up button "appears for a moment,
then disappears too fast to click". The single-stage debounce only
filtered the show direction (require ≥ 3 s sustained over-threshold
before showing), but 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 — so the button vanished as soon as
drift dipped briefly under 3 s, even though the drift baseline was
still 5–8 s.

Add a hide-side hysteresis: once visible, the button stays visible
until drift has been under a tighter 1 s threshold for ≥ 1 s.
Otherwise the noisy 1–3 s drift valleys keep the button up so the
user can actually click it.

Constants left as locals; if testing wants different floors we can
extract.

* docs(changelog): add catch-up hysteresis bullet

* fix(orbit): show Catch Up when host paused + serialise seek-then-pause

Two follow-ons after the hysteresis change:

**1. Show Catch Up even when the host is paused.** The visibility
   gate required `state.isPlaying === true`, but a guest who joined
   while the host was paused still benefits from Catch Up if their
   sync to the host's paused position failed (engine drop, brief
   network blip during initial-sync, manual local seek). Drop the
   `state.isPlaying` gate — the only signal that should matter is
   "drift between us and the host's reported state" — which
   `computeOrbitDriftMs` already computes correctly in the paused
   case (no time-extrapolation, just `guestPos - hostPos`).

**2. Defer the play-state mirror after `player.seek`.** Reported
   symptom: "I press Catch Up, the player pauses; when I press play,
   it resumes from the *old* position and the waveform jumps back."
   Root cause: `player.seek` debounces `audio_seek` via
   `setTimeout(0)`, while `player.pause` / `player.resume` fire their
   invokes synchronously. Pause arrives at the engine first, leaving
   it paused at the pre-Catch-Up position with the seek queued
   behind. When resume happens later, the engine plays from the old
   spot. Push the play-state mirror behind a 200 ms `setTimeout` so
   the seek's invoke lands first.
This commit is contained in:
Frank Stellmacher
2026-05-09 22:37:45 +02:00
committed by GitHub
parent 27c740b760
commit 6c5465e9c7
2 changed files with 69 additions and 15 deletions
+68 -15
View File
@@ -66,18 +66,37 @@ export default function OrbitSessionBar() {
return () => window.clearInterval(id);
}, [state, phase]);
// ── Catch Up button visibility — debounced ────────────────────────────
// ── Catch Up button visibility — debounced + hysteresis ───────────────
// The raw drift signal is noisy: guest's `currentTime` updates in coarse
// ~5 s chunks while host's position is extrapolated linearly via
// `(nowMs - posAt)`, so the diff swings ±5 s every tick on a normal
// session even when both sides are perfectly synced. Without debounce
// the button flickered in and out and (worse) shifted the bar height.
// Require ≥ 3 s of sustained over-threshold drift before showing.
// `(nowMs - posAt)`, so the diff swings between ~1 s and ~8 s every
// tick on a normal session even when both sides are perfectly synced.
// Two-stage filter:
// - **Hidden → shown**: drift must stay over the show-threshold (3 s)
// for 3 s of wall-clock. Filters out brief over-threshold blips.
// - **Shown → hidden**: drift must stay under the hide-threshold
// (1 s) for 1 s of wall-clock. Once visible, the button persists
// through the 13 s "drift back to small" valleys that come from
// guest's currentTime catching up in chunks; otherwise the button
// would vanish too fast to actually click on a high-latency
// session where genuine drift fluctuates around 58 s.
const SHOW_THRESHOLD_MS = CATCH_UP_DRIFT_THRESHOLD_MS;
const HIDE_THRESHOLD_MS = 1_000;
const SHOW_DEBOUNCE_MS = 3_000;
const HIDE_DEBOUNCE_MS = 1_000;
const [showCatchUp, setShowCatchUp] = useState(false);
const overSinceRef = useRef<number | null>(null);
const underSinceRef = useRef<number | null>(null);
useEffect(() => {
if (role !== 'guest' || !state || !state.isPlaying || !state.currentTrack) {
// Note: `state.isPlaying` is *not* a gate. A guest who joined while
// the host was paused still benefits from Catch Up if their sync to
// the host's paused position failed — the only signal that matters
// is "is there drift between us and the host's last reported state".
// `computeOrbitDriftMs` correctly stops time-extrapolation when the
// host is paused, so the formula holds in both states.
if (role !== 'guest' || !state || !state.currentTrack) {
overSinceRef.current = null;
underSinceRef.current = null;
setShowCatchUp(false);
return;
}
@@ -86,15 +105,34 @@ export default function OrbitSessionBar() {
const driftMs = player.currentTrack?.id === state.currentTrack.trackId
? computeOrbitDriftMs(state, localPositionMs, nowMs)
: null;
const isOver = driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS;
if (isOver) {
if (overSinceRef.current === null) overSinceRef.current = Date.now();
if (Date.now() - overSinceRef.current >= 3000) setShowCatchUp(true);
} else {
const absDrift = driftMs == null ? Infinity : Math.abs(driftMs);
if (showCatchUp) {
// Currently visible — only hide once drift has been clearly small
// for the full hide-debounce window.
overSinceRef.current = null;
setShowCatchUp(false);
if (absDrift < HIDE_THRESHOLD_MS) {
if (underSinceRef.current === null) underSinceRef.current = Date.now();
if (Date.now() - underSinceRef.current >= HIDE_DEBOUNCE_MS) {
setShowCatchUp(false);
underSinceRef.current = null;
}
} else {
underSinceRef.current = null;
}
} else {
// Currently hidden — only show after sustained over-threshold drift.
underSinceRef.current = null;
if (absDrift > SHOW_THRESHOLD_MS) {
if (overSinceRef.current === null) overSinceRef.current = Date.now();
if (Date.now() - overSinceRef.current >= SHOW_DEBOUNCE_MS) {
setShowCatchUp(true);
overSinceRef.current = null;
}
} else {
overSinceRef.current = null;
}
}
}, [role, state, nowMs]);
}, [role, state, nowMs, showCatchUp]);
// Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed.
const shouldShowBar = !!state && (
@@ -143,9 +181,24 @@ export default function OrbitSessionBar() {
const player = usePlayerStore.getState();
const fraction = targetSec / Math.max(1, track.duration);
if (player.currentTrack?.id === trackId) {
// `player.seek` debounces the underlying `audio_seek` invoke via
// `setTimeout(0)`, while `pause`/`resume` fire their invokes
// synchronously. Calling them back-to-back races on the Tauri
// command queue: pause/resume can arrive at the engine before
// the seek does, leaving the engine paused at the *old*
// position with the seek queued behind it — when the user
// hits play later the engine resumes from the pre-Catch-Up
// spot and the waveform jumps back. Defer the play-state
// mirror by one short tick so the seek lands first.
player.seek(fraction);
if (hostPlaying && !player.isPlaying) player.resume();
else if (!hostPlaying && player.isPlaying) player.pause();
if (hostPlaying !== player.isPlaying) {
window.setTimeout(() => {
const p = usePlayerStore.getState();
if (p.currentTrack?.id !== trackId) return;
if (hostPlaying && !p.isPlaying) p.resume();
else if (!hostPlaying && p.isPlaying) p.pause();
}, 200);
}
} else {
// Different track: play + seek once the engine reports the track
// loaded. The previous 400 ms blind delay was too short for an