From 6c5465e9c7e36b72328331b240dbd252647e5eca Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 9 May 2026 22:37:45 +0200 Subject: [PATCH] fix(orbit): hysteresis on Catch Up visibility so the button stays clickable (#527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- CHANGELOG.md | 1 + src/components/OrbitSessionBar.tsx | 83 ++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f25310..2b83832a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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. ### Context menu — render above the floating player bar diff --git a/src/components/OrbitSessionBar.tsx b/src/components/OrbitSessionBar.tsx index 92c22112..cfcb61ff 100644 --- a/src/components/OrbitSessionBar.tsx +++ b/src/components/OrbitSessionBar.tsx @@ -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 1–3 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 5–8 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(null); + const underSinceRef = useRef(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