fix(orbit): three interlocking guest playback bugs (#525)

* fix(orbit): guest short-circuits queue-exhaustion fallback paths

When a guest's local queue runs out (single-track queue from `syncToHost`
empties on `audio:ended`), the player walks the standard fallback chain
in `next()`: radio top-up → infinite-queue → stop. The infinite-queue
branch builds a 6-track queue and calls `playTrack`, which trips
`orbitBulkGuard` and pops a "Add 6 tracks to the Orbit queue?" modal.
Hitting Cancel leaves playback frozen; "Add them all" injects unrelated
tracks into the host's shared queue.

In an active Orbit guest session the host owns the queue. Skip the
fallback paths entirely and just stop — the next `useOrbitGuest` pull
tick will sync to whatever the host advanced to.

Bonus side-effect: kills the deferred-promise race where a
`buildInfiniteQueueCandidates().then(...)` from a guest's track end
could resolve *after* a Catch Up replaced the queue and pop the modal
a second time against the now-current 1-track queue.

* fix(orbit): treat natural track-end as not-diverged in guest sync

When a guest's track ended naturally before the host advanced, the
divergence-detection branch read `player.isPlaying === false` and
classified it as the user manually paused — so it refused to load the
host's next track. The guest sat silent until they clicked Catch Up.

`handleAudioEnded` keeps `currentTrack` pinned to the just-ended track
and resets `currentTime` to 0, while a real manual pause leaves
`currentTime` somewhere mid-track. Use the 0-position discriminator to
classify natural-end as not-diverged so the host's new track loads.

Confirmed via the captured guest log buffer:
  18:43:08.598 [track-change] host: VJkV5… → 6i6RP… BUT guest diverged
                              (player.isPlaying=false ≠ last.isPlaying=true)
  — guest stuck for ~33s until Catch Up was pressed.

* fix(orbit): Catch Up polls until engine is ready before seeking

The 400 ms blind setTimeout in `onCatchUp` was too short for an
HTTP-streamed cold-start on high-latency links. If the audio engine
wasn't ready by then, `seek(fraction)` silently no-oped and playback
started at 0:00, making Catch Up effectively useless on exactly the
slow links where it's needed. Captured log shows a Catch Up bringing
the guest to posSec=30, then 5 s later the guest was at posSec=6
(playback restarted from the head).

Replace with the same poll-until-ready pattern `syncToHost` already
uses: check every 100 ms, fire the seek as soon as the engine reports
playing, fall back to a blind apply at the 4 s deadline.

* docs(changelog): add orbit guest playback fixes entry

* fix(orbit): debounce Catch Up button + match bar item height

Two follow-on UX fixes after PR #525's three primary bugs landed:

1. **Debounce visibility.** Drift is computed from an asymmetric signal:
   guest's `currentTime` updates in coarse ~5 s chunks, while host's
   position is extrapolated linearly via `(nowMs - posAt)`. Even on a
   perfectly-synced session the diff swings ±5 s every tick, so the
   button flickered in and out continuously. Show only after drift has
   stayed over the 3 s threshold for ≥ 3 s of wall clock — measurement
   noise is filtered out, real sustained drift still surfaces in time.

2. **Match neighbour height.** The button was 32 px tall against 26 px
   for the other action buttons (.orbit-bar__settings) so every flicker
   shifted the entire bar height. Set `height: 26 px` and tighten the
   padding/font so the layout is stable regardless of visibility.

* fix(orbit): tighten queue-extension lockout + reliable initial-sync seek

Two follow-on fixes after the 4-bug umbrella:

**1. Local queue-extension paths fully off during Orbit.**
Phase check broadened from `active` to cover `starting` / `joining` /
`active` so a fetch-then-join race can't pop the bulk-add modal *after*
the join. The proactive infinite-queue topper inside `next()` (which
fires when ≤ 2 auto-tracks remain ahead) is now also gated, plus each
async `.then()` callback in the radio + infinite-queue paths re-checks
at resolution time. A `playTrack(... 6-track queue ...)` after the user
joined Orbit was the path that re-triggered the "Add 5 tracks?" modal
on a freshly-joined guest.

**2. `syncToHost` only seeks once the engine reports playing.**
The previous 2 s deadline-fallback applied the seek even when the
engine hadn't started, where the seek silently no-ops and the track
plays from 0:00. Symptom: clicking Catch Up makes the song "jump 50 %
forward" — that's the seek finally landing because the engine is now
ready, the initial-sync seek had already failed silently. New deadline
is 5 s, and on timeout we return `false` so the outer pull tick keeps
`lastAppliedRef` null and the 500 ms fast-poll retries.

* fix(orbit): double-click play button + hide preview during session

Two cucadmuh-flagged gaps:

**1. Double-click on the inline play button now reaches the orbit-add
   path.** The album-track row's onDoubleClick already routes to
   `addTrackToOrbit` when in Orbit, but the inline play button stopped
   propagation on click — so clicking it twice just fired the "double-
   click to add" hint toast and never touched the orbit queue. Add an
   onDoubleClick on the button itself that delegates to the parent's
   `onDoubleClickSong`.

**2. Track preview is suppressed during an Orbit session.** Preview
   shares the Rust audio engine with the shared playback, so starting
   one as a guest yanks the host's track out from under everyone. A new
   `[data-orbit-active]` attribute on `<html>` (set whenever role is
   host/guest and phase is starting/joining/active) hides every
   preview button via a single CSS rule, and `previewStore.startPreview`
   short-circuits as a defensive guard for keyboard shortcuts and any
   programmatic callers.
This commit is contained in:
Frank Stellmacher
2026-05-09 21:50:52 +02:00
committed by GitHub
parent a702a5dd5b
commit af1b9661f5
8 changed files with 175 additions and 25 deletions
+1
View File
@@ -150,6 +150,7 @@ const TrackRow = React.memo(function TrackRow({
type="button"
className="playlist-suggestion-play-btn"
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
onDoubleClick={onDoubleClickSong ? e => { e.stopPropagation(); onDoubleClickSong(song); } : undefined}
data-tooltip={t('common.play')}
aria-label={t('common.play')}
>
+47 -17
View File
@@ -66,6 +66,36 @@ export default function OrbitSessionBar() {
return () => window.clearInterval(id);
}, [state, phase]);
// ── Catch Up button visibility — debounced ────────────────────────────
// 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.
const [showCatchUp, setShowCatchUp] = useState(false);
const overSinceRef = useRef<number | null>(null);
useEffect(() => {
if (role !== 'guest' || !state || !state.isPlaying || !state.currentTrack) {
overSinceRef.current = null;
setShowCatchUp(false);
return;
}
const player = usePlayerStore.getState();
const localPositionMs = Math.round((player.currentTime ?? 0) * 1000);
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 {
overSinceRef.current = null;
setShowCatchUp(false);
}
}, [role, state, nowMs]);
// Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed.
const shouldShowBar = !!state && (
phase === 'active'
@@ -78,17 +108,6 @@ export default function OrbitSessionBar() {
const untilShuffle = Math.max(0, (state.lastShuffle + effectiveShuffleIntervalMs(state)) - nowMs);
// Guest-only: detect drift from the host's estimated live position.
const guestPlayback = usePlayerStore.getState();
const localPositionMs = Math.round((guestPlayback.currentTime ?? 0) * 1000);
const driftMs = role === 'guest' && state.currentTrack && guestPlayback.currentTrack?.id === state.currentTrack.trackId
? computeOrbitDriftMs(state, localPositionMs, nowMs)
: null;
const showCatchUp = role === 'guest'
&& state.isPlaying
&& state.currentTrack
&& (driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS);
const performExit = async () => {
try {
if (role === 'host') await endOrbitSession();
@@ -128,14 +147,25 @@ export default function OrbitSessionBar() {
if (hostPlaying && !player.isPlaying) player.resume();
else if (!hostPlaying && player.isPlaying) player.pause();
} else {
// Different track: play + seek on next tick once engine is ready.
// Different track: play + seek once the engine reports the track
// loaded. The previous 400 ms blind delay was too short for an
// HTTP-streamed cold-start on a transcontinental link, so the seek
// would silently no-op and playback started at 0:00 — making Catch
// Up effectively useless on the very latency where it matters most.
// Mirrors the poll-until-ready pattern used by `syncToHost`.
player.playTrack(track, [track]);
window.setTimeout(() => {
const deadline = Date.now() + 4000;
const poll = () => {
const p = usePlayerStore.getState();
if (p.currentTrack?.id !== trackId) return;
p.seek(fraction);
if (!hostPlaying && p.isPlaying) p.pause();
}, 400);
if (p.currentTrack?.id !== trackId) return; // user changed tracks
if (p.isPlaying || Date.now() >= deadline) {
p.seek(fraction);
if (!hostPlaying && p.isPlaying) p.pause();
return;
}
window.setTimeout(poll, 100);
};
window.setTimeout(poll, 100);
}
} catch {
// silent — if the track is gone from the host's library, nothing we can do.