mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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
+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