diff --git a/CHANGELOG.md b/CHANGELOG.md index 691a2ece..95f25310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts index 38fb06d6..ddd46f40 100644 --- a/src/hooks/useOrbitGuest.ts +++ b/src/hooks/useOrbitGuest.ts @@ -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 }; } };