Files
psysonic/src/store/previewStore.ts
T
Frank Stellmacher af1b9661f5 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.
2026-05-09 21:50:52 +02:00

171 lines
6.3 KiB
TypeScript

import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl } from '../api/subsonic';
import { usePlayerStore } from './playerStore';
import { useAuthStore, type TrackPreviewLocation } from './authStore';
import { useOrbitStore } from './orbitStore';
/** Minimal track info needed to surface the preview in the player bar UI. */
export interface PreviewingTrack {
id: string;
title: string;
artist: string;
coverArt?: string;
}
interface PreviewState {
/** Subsonic song id of the active preview, or null when nothing previews. */
previewingId: string | null;
/** Currently previewing track with the metadata the player-bar UI needs. */
previewingTrack: PreviewingTrack | null;
/** Seconds elapsed in the current preview window. */
elapsed: number;
/** Total preview window in seconds (echoes the duration_sec arg). */
duration: number;
/**
* True only after the engine has emitted `audio:preview-start` for the
* current `previewingId` — i.e. audio is actually playing. Drives the
* progress-ring animation so the ring doesn't run ahead of the speaker
* during the engine's download/decode/seek warmup. Reset to false on every
* `startPreview` call so a switch from track A to track B doesn't carry
* over A's animation state.
*/
audioStarted: boolean;
startPreview: (song: { id: string; title: string; artist: string; coverArt?: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>;
stopPreview: () => Promise<void>;
/** Internal — called from the TauriEventBridge on `audio:preview-start`. */
_onStart: (id: string) => void;
/** Internal — called from the TauriEventBridge on `audio:preview-progress`. */
_onProgress: (id: string, elapsed: number, duration: number) => void;
/** Internal — called from the TauriEventBridge on `audio:preview-end`. */
_onEnd: (id: string) => void;
}
const PREVIEW_VOLUME_MATCH = true;
/**
* Effective preview volume to send to the Rust engine.
*
* Mirrors the main sink's audible level: takes the player's slider value and,
* when loudness normalization is active, folds in the LUFS pre-analysis
* attenuation the engine applies to the main sink (the engine has no view of
* preview-specific gain, so we pre-multiply here). Master headroom is added on
* the Rust side.
*/
function computePreviewVolume(): number {
const auth = useAuthStore.getState();
let volume = usePlayerStore.getState().volume;
if (PREVIEW_VOLUME_MATCH && auth.normalizationEngine === 'loudness') {
const preDbAtt = Math.min(0, auth.loudnessPreAnalysisAttenuationDb ?? -4.5);
volume = volume * Math.pow(10, preDbAtt / 20);
}
return Math.max(0, Math.min(1, volume));
}
export const usePreviewStore = create<PreviewState>((set, get) => ({
previewingId: null,
previewingTrack: null,
elapsed: 0,
duration: 30,
audioStarted: false,
startPreview: async (song, location) => {
const auth = useAuthStore.getState();
if (!auth.trackPreviewsEnabled) return;
if (!auth.trackPreviewLocations[location]) return;
// Block preview during any Orbit session — the preview path runs
// through the same Rust audio engine as the shared playback, and a
// preview started by a guest would yank the host's track out from
// under them. UI buttons are hidden via `[data-orbit-active]` CSS;
// this guards keyboard shortcuts / programmatic callers.
const orbit = useOrbitStore.getState();
const inOrbit = (orbit.role === 'host' || orbit.role === 'guest')
&& (orbit.phase === 'active' || orbit.phase === 'joining' || orbit.phase === 'starting');
if (inOrbit) return;
const current = get().previewingId;
if (current === song.id) {
await get().stopPreview();
return;
}
const previewDuration = auth.trackPreviewDurationSec;
const startRatio = auth.trackPreviewStartRatio;
const url = buildStreamUrl(song.id);
const trackDuration = Math.max(song.duration ?? 0, 0);
const startSec = trackDuration > previewDuration * 1.5
? trackDuration * startRatio
: 0;
set({
previewingId: song.id,
previewingTrack: { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt },
elapsed: 0,
duration: previewDuration,
audioStarted: false,
});
try {
await invoke('audio_preview_play', {
id: song.id,
url,
startSec,
durationSec: previewDuration,
volume: computePreviewVolume(),
});
} catch (e) {
// Roll back optimistic state on failure.
if (get().previewingId === song.id) {
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
}
throw e;
}
},
stopPreview: async () => {
if (!get().previewingId) return;
try {
await invoke('audio_preview_stop');
} catch {
/* engine will emit preview-end anyway; clear locally as fallback */
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
}
},
_onStart: (id) => {
const current = get().previewingId;
if (current !== id) {
// Engine fired start for an id we didn't track locally — keep id but
// leave previewingTrack as-is (the caller's startPreview() set it).
set({ previewingId: id, elapsed: 0, audioStarted: true });
} else {
// Audio is now actually playing — unblock the progress-ring animation.
set({ audioStarted: true });
}
},
_onProgress: (id, elapsed, duration) => {
if (get().previewingId !== id) return;
set({ elapsed, duration });
},
_onEnd: (id) => {
if (get().previewingId !== id) return;
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
},
}));
// Keep the preview sink in sync with player volume slider movements while a
// preview is in flight. Without this the Rust preview Sink stays at whatever
// level was set at `audio_preview_play` — slider drags only ramp the main
// sink. Auth/normalization changes during preview are intentionally ignored
// (preview is short and the case is rare).
usePlayerStore.subscribe((state, prev) => {
if (state.volume === prev.volume) return;
if (!usePreviewStore.getState().previewingId) return;
invoke('audio_preview_set_volume', { volume: computePreviewVolume() }).catch(() => {});
});