mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(orbit): kick a fresh playTrack when engine is stuck mid-load (#526)
* fix(orbit): kick a fresh playTrack when engine is stuck mid-load Follow-up to #525. Symptom: occasionally on join, the guest gets no audio until the next host-driven track change, at which point a fresh `playTrack` runs and audio plays normally. Root cause: when the initial `syncToHost` poll hits its 5 s deadline without the engine reporting `isPlaying === true` (slow Navidrome cold-start), the next pull tick takes the cheap "track already loaded" shortcut and calls `applyMirror`. `applyMirror` fires `seek` + `resume` on an engine that is stuck in a "loaded but never started" limbo — seek silently no-ops and `resume` can't kick a track that never began. Guest is silent until something else triggers a fresh `playTrack`. Tighten the shortcut: take the cheap path only when the engine is already in the state the host expects (or playing while host is paused, which is fine to align via pause). Otherwise fall through to a fresh `playTrack` and the existing 5 s ready-poll, which re-initialises the engine and lets audio actually start. * docs(changelog): add engine-limbo follow-up bullet * fix(orbit): re-sync when engine silently fell back to paused The optimistic `isPlaying: true` `playTrack` writes synchronously masks a `audio_play` failure: the post-playTrack poll sees the optimistic flag, fires `applyMirror`, and the outer tick records `lastAppliedRef = { ..., isPlaying: true }` as a successful sync. But if the underlying `invoke('audio_play')` rejects later (network blip / cold-start exhaustion), the catch handler flips the flag back to `false` and schedules `next()`, which short-circuits to `audio_stop` in an Orbit guest — leaving the player silent while `lastAppliedRef` still claims we're playing. None of the divergence-detection branches (track-change / play-pause-flip) match, so the guest never re-syncs. Add a recovery check before the if/else-if chain: when the captured `last` says we applied playing, the engine is currently not playing, and the host is still playing the same track, reset `lastAppliedRef.current = null`. The next iteration of the chain re-runs initial-sync (with the 500 ms fast-poll cadence) which fires a fresh `playTrack`. If `audio_play` succeeds the second time, audio finally starts; if it keeps failing, we loop with no extra harm (audio was already silent). Adds an `engine-recovery` event scope to the diagnostics buffer so future captures can see when this re-sync fired. * docs(changelog): expand engine-recovery bullet to cover both cases
This commit is contained in:
committed by
GitHub
parent
af1b9661f5
commit
27c740b760
@@ -231,6 +231,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **Catch Up button no longer flickers and no longer changes the bar height.** Drift is computed from a noisy signal (guest's `currentTime` updates in coarse chunks while host's position is extrapolated linearly), so the diff swings ±5 s on a normal session even when sync is fine — and the button popping in/out shifted the whole Orbit bar up and down because it was 6 px taller than the other action buttons. Visibility is now debounced (must stay over the threshold for ≥ 3 s before the button appears) and the button matches the 26 px height of its neighbours so the bar's vertical layout is stable.
|
||||
* **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.
|
||||
|
||||
### Context menu — render above the floating player bar
|
||||
|
||||
|
||||
+49
-11
@@ -97,7 +97,22 @@ export function useOrbitGuest(): void {
|
||||
};
|
||||
|
||||
const player = usePlayerStore.getState();
|
||||
if (player.currentTrack?.id === trackId) {
|
||||
const sameTrack = player.currentTrack?.id === trackId;
|
||||
// Take the cheap path only when the engine is actually in the
|
||||
// state the host expects. If the track is loaded but the engine
|
||||
// never reported `isPlaying === true` (slow cold-start, audio-
|
||||
// device warmup), this branch used to fire `seek` + `resume`
|
||||
// into a stuck engine — the seek silently no-oped and `resume`
|
||||
// can't restart a track that never started. Result: guest sees
|
||||
// "synced" but hears nothing until the next host-driven track
|
||||
// change kicks a fresh `playTrack`. Falling through to a fresh
|
||||
// `playTrack` here re-initialises the engine instead.
|
||||
if (sameTrack && player.isPlaying === hostState.isPlaying) {
|
||||
return applyMirror();
|
||||
}
|
||||
if (sameTrack && player.isPlaying && !hostState.isPlaying) {
|
||||
// We're playing but host is paused — pause locally without
|
||||
// re-loading the track.
|
||||
return applyMirror();
|
||||
}
|
||||
|
||||
@@ -217,7 +232,30 @@ export function useOrbitGuest(): void {
|
||||
last,
|
||||
}));
|
||||
|
||||
if (!last) {
|
||||
// Engine-recovery: detect a silent `audio_play` failure after our
|
||||
// optimistic `isPlaying: true` mark. `playTrack` flips the store
|
||||
// flag synchronously before the Tauri call resolves, so the
|
||||
// post-playTrack poll sees `isPlaying === true` even when the
|
||||
// engine never actually started; if `audio_play` later rejects,
|
||||
// the catch handler sets `isPlaying: false`. Without this check
|
||||
// the divergence-detection branches all pass (last/host both
|
||||
// think we're playing) and the guest stays stuck silent. Reset
|
||||
// `lastAppliedRef` so the next iteration re-runs initial-sync.
|
||||
if (
|
||||
last
|
||||
&& last.isPlaying
|
||||
&& !player.isPlaying
|
||||
&& hostPlaying
|
||||
&& last.trackId === hostTrackId
|
||||
&& last.trackId === player.currentTrack?.id
|
||||
) {
|
||||
pushOrbitEvent('engine-recovery', 'engine fell back to paused while host plays — re-syncing');
|
||||
lastAppliedRef.current = null;
|
||||
}
|
||||
|
||||
// Re-read after the recovery check above may have reset it.
|
||||
const currentLast = lastAppliedRef.current;
|
||||
if (!currentLast) {
|
||||
// Initial sync: only record `last` *after* syncToHost actually
|
||||
// landed. If the first attempt loses the race (engine not ready,
|
||||
// stale audio state, network blip), a retry ticker below will try
|
||||
@@ -233,7 +271,7 @@ export function useOrbitGuest(): void {
|
||||
pushOrbitEvent('initial-sync', 'host has no current track yet, anchor only');
|
||||
lastAppliedRef.current = { trackId: null, isPlaying: hostPlaying };
|
||||
}
|
||||
} else if (last.trackId !== hostTrackId) {
|
||||
} else if (currentLast.trackId !== hostTrackId) {
|
||||
// Distinguish "user manually paused" (true divergence) from "track
|
||||
// ended naturally" (NOT divergence — guest just needs the host's
|
||||
// next track loaded). Both leave `player.isPlaying === false`, but
|
||||
@@ -242,18 +280,18 @@ export function useOrbitGuest(): void {
|
||||
// `currentTime` somewhere mid-track. The 0-position discriminator
|
||||
// separates them.
|
||||
const naturalEnd = !player.isPlaying
|
||||
&& player.currentTrack?.id === last.trackId
|
||||
&& player.currentTrack?.id === currentLast.trackId
|
||||
&& (player.currentTime ?? 0) < 0.5;
|
||||
const diverged = !naturalEnd && player.isPlaying !== last.isPlaying;
|
||||
const diverged = !naturalEnd && player.isPlaying !== currentLast.isPlaying;
|
||||
if (diverged) {
|
||||
// Guest is running their own show (typically: paused while host
|
||||
// kept going). Do not load/start the host's new track — just
|
||||
// track the host state so the catch-up prompt stays accurate.
|
||||
pushOrbitEvent('track-change',
|
||||
`host: ${last.trackId} → ${hostTrackId} BUT guest diverged (player.isPlaying=${player.isPlaying} ≠ last.isPlaying=${last.isPlaying}) — NOT loading new track`);
|
||||
`host: ${currentLast.trackId} → ${hostTrackId} BUT guest diverged (player.isPlaying=${player.isPlaying} ≠ last.isPlaying=${currentLast.isPlaying}) — NOT loading new track`);
|
||||
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
|
||||
} else if (hostTrackId) {
|
||||
pushOrbitEvent('track-change', `host: ${last.trackId} → ${hostTrackId}, guest in sync, following`);
|
||||
pushOrbitEvent('track-change', `host: ${currentLast.trackId} → ${hostTrackId}, guest in sync, following`);
|
||||
void syncToHost(hostTrackId, state);
|
||||
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
|
||||
} else {
|
||||
@@ -261,20 +299,20 @@ export function useOrbitGuest(): void {
|
||||
if (player.isPlaying) player.pause();
|
||||
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
|
||||
}
|
||||
} else if (last.isPlaying !== hostPlaying) {
|
||||
} else if (currentLast.isPlaying !== hostPlaying) {
|
||||
// Only mirror when the guest hasn't diverged. We compare against the
|
||||
// *last applied* host state, not the new one — divergence means the
|
||||
// local player no longer matches what we last pushed in.
|
||||
const localMatchesLast = player.isPlaying === last.isPlaying;
|
||||
const localMatchesLast = player.isPlaying === currentLast.isPlaying;
|
||||
pushOrbitEvent('play-pause-flip',
|
||||
`host: ${last.isPlaying} → ${hostPlaying}, guest matches last=${localMatchesLast} (will ${localMatchesLast ? 'mirror' : 'skip'})`);
|
||||
`host: ${currentLast.isPlaying} → ${hostPlaying}, guest matches last=${localMatchesLast} (will ${localMatchesLast ? 'mirror' : 'skip'})`);
|
||||
if (localMatchesLast) {
|
||||
if (hostPlaying) player.resume();
|
||||
else player.pause();
|
||||
}
|
||||
// Either way, advance the anchor so we don't keep retrying the same
|
||||
// flip every tick.
|
||||
lastAppliedRef.current = { trackId: last.trackId, isPlaying: hostPlaying };
|
||||
lastAppliedRef.current = { trackId: currentLast.trackId, isPlaying: hostPlaying };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user