mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
27c740b760
commit
6c5465e9c7
@@ -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<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
|
||||
|
||||
Reference in New Issue
Block a user