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
+1
View File
@@ -232,6 +232,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Double-clicking the inline play button on an album-track row now suggests/enqueues to the host's queue**, matching the existing double-click-on-row behaviour. Previously the button stopped propagation on click, so its double-click never reached the row's orbit-aware handler — clicking it twice just bounced the "double-click to add" hint toast.
* **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.
### Context menu — render above the floating player bar
+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