Files
psysonic/src/store/playTrackAction.ts
T
cucadmuh 184e87a469 fix(audio): release idle output stream after 60s (#1071) (#1073)
* fix(audio): release idle output stream after 60s (#1071)

Lazy-open CPAL on first playback and close the device handle after one
minute without active audio so Windows can sleep; emit output-released
for cold resume and skip post-wake reopen when idle.

* docs: CHANGELOG and credits for idle audio stream fix (PR #1073)

* fix(audio): satisfy clippy if-same-then-else in idle watcher

* fix(audio): silence rodio DeviceSink drop unless logging is debug

Gate log_on_drop(false) on runtime should_log_debug() so normal/off
logging modes avoid stderr noise from intentional idle stream release.

* feat(audio): cold-start paused restore and silent engine prepare

After getPlayQueue on startup, apply saved seek position to the UI,
prefetch the current track to hot cache, and load the engine paused via
new audio_play startPaused so playback does not audibly start before
pause. Shared engineLoadTrackAtPosition with queue-undo restore.

* fix(audio): satisfy clippy too_many_arguments on stream arm helper

Bundle spawn_legacy_stream_start_when_armed parameters into
LegacyStreamStartWhenArmed so workspace clippy passes.

* fix(audio): release output stream immediately on stop (#1071)

Stop and natural queue end call audio_stop; close the CPAL device right
away instead of waiting for the 60s idle timer. Pause keeps the grace
period for warm resume.

* fix(audio): keep waveform mounted after stop (#1071)

Stop preserves currentTrack, so its cached analysis waveform stays valid.
Stop no longer nulls waveformBins for the still-shown track and re-hydrates
them from the analysis DB, instead of dropping to flat placeholder bars.

* test(audio): cover output_stream_is_needed branches; harden audio_play arg (#1071)

- Add unit tests for the idle-keepalive decision: empty/playing/paused main
  sink, preview and fading-out sinks, and radio playing/paused. Players are
  built device-less via rodio's Player::new + a Zero source, so empty()/state
  are exercised without an audio device.
- Make audio_play's `start_paused` an Option<bool> defaulting to false, so the
  new field is strictly additive (omitting startPaused no longer fails serde).
- Drop the unused `_engine` parameter from start_stream_idle_watcher; it
  resolves the engine from the AppHandle each poll.

* refactor(audio): extract sink-swap lifecycle into sink_swap module (#1071)

Move SinkSwapInputs/swap_in_new_sink and the legacy stream-arm helper
(LegacyStreamStartWhenArmed/spawn_legacy_stream_start_when_armed) out of
play_input.rs into a focused sink_swap.rs, so source selection and source
building stay separate from sink lifecycle. play_input.rs drops from 953 to
799 lines. No behavior change.
2026-06-12 17:13:51 +03:00

464 lines
19 KiB
TypeScript

import { reportNowPlaying } from '../api/subsonicScrobble';
import { invoke } from '@tauri-apps/api/core';
import { getMusicNetworkRuntimeOrNull } from '../music-network';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
import {
bindQueueServerForTracks,
getPlaybackCacheServerKey,
getPlaybackIndexKey,
playbackCacheKeyForTrack,
playbackProfileIdForTrack,
shouldBindQueueServerForPlay,
} from '../utils/playback/playbackServer';
import { stampTrackServerId, stampTrackServerIds } from '../utils/playback/trackServerScope';
import {
findLocalPlaybackUrl,
hasLocalPersistentPlaybackBytes,
} from '../utils/offline/offlineLibraryHelpers';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { useAuthStore } from './authStore';
import {
bumpPlayGeneration,
getPlayGeneration,
setIsAudioPaused,
} from './engineState';
import {
clearPreloadingIds,
getLastGaplessSwitchTime,
} from './gaplessPreloadState';
import { touchHotCacheOnPlayback } from './hotCacheTouch';
import {
isReplayGainActive,
loudnessGainDbForEngineBind,
} from './loudnessGainCache';
import { refreshLoudnessForTrack } from './loudnessRefresh';
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import { useOrbitStore } from './orbitStore';
import {
playbackSourceHintForResolvedUrl,
recordEnginePlayUrl,
} from './playbackUrlRouting';
import type { PlayerState, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { getQueueTracksView, resolveQueueTrack } from '../utils/library/queueTrackView';
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { syncQueueToServer } from './queueSync';
import { playListenSessionFinalize } from './playListenSession';
import { pushQueueUndoFromGetter } from './queueUndo';
import { stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
import { clearSeekDebounce } from './seekDebounce';
import {
clearSeekFallbackRetry,
getSeekFallbackVisualTarget,
setSeekFallbackRestartAt,
setSeekFallbackTrackId,
setSeekFallbackVisualTarget,
} from './seekFallbackState';
import {
clearSeekTarget,
setSeekTarget,
} from './seekTargetState';
import { refreshWaveformForTrack } from './waveformRefresh';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
type GetState = () => PlayerState;
/**
* Play a track, optionally replacing the queue and/or jumping to an
* explicit slot. Three guard layers run before the actual play body:
*
* 1. **Orbit bulk-gate** — when `queue.length > 1` and isn't a no-op
* replace of the current queue, prompt via `orbitBulkGuard`; on
* confirm, hosts/guests append (Orbit semantics — bulk replace
* would drop guest suggestions) and non-Orbit users replace as
* normal.
* 2. **Orbit-host single-track protection** — a `playTrack(track,
* [track])` from a host would blow away the shared queue; re-route
* to append-and-jump so guest suggestions survive.
* 3. **Ghost-command guard** — a playTrack arriving within 500 ms of
* the last gapless switch is almost certainly a stale IPC echo.
*
* The play body itself: clears all scheduled timers + seek state,
* resolves the URL, updates store + normalization snapshot
* optimistically, invokes the Rust engine, and on success seeks to
* the visual target if there was a pending one. Falls back to
* `next(false)` 500 ms after an `audio_play` failure. Same-track
* replays first flush the previous play's `stream_completed_cache`
* to hot disk so `fetch_data` doesn't re-run an HTTP range request.
*/
export function runPlayTrack(
set: SetState,
get: GetState,
track: Track,
queue: Track[] | undefined,
manual: boolean,
_orbitConfirmed: boolean,
targetQueueIndex: number | undefined,
): void {
// Orbit bulk-gate: only gate when the `queue` argument *replaces*
// the current queue (Play All / Play Album / Play Playlist / Hero
// play buttons). Navigation calls — queue-row click, next(),
// previous() — pass the existing queue back through playTrack just
// to move the index; they are not bulk operations and must not
// trigger the confirm dialog (#234 regression).
if (!_orbitConfirmed && queue && queue.length > 1) {
const current = get().queueItems;
const sameAsCurrent = queue.length === current.length
&& queue.every((t, i) => sameQueueTrackId(current[i]?.trackId, t.id));
if (!sameAsCurrent) {
void orbitBulkGuard(queue.length).then(ok => {
if (!ok) return;
// Inside an Orbit session a bulk replace would discard guest
// suggestions mid-listen. Append instead — the dialog's
// "Add them all" copy already matches that semantic. Outside
// Orbit, proceed as a normal replace.
const role = useOrbitStore.getState().role;
if (role === 'host' || role === 'guest') {
get().enqueue(queue, true);
} else {
get().playTrack(track, queue, manual, true);
}
});
return;
}
}
// Orbit-host single-track protection. The host's `playerStore.queue`
// *is* the shared Orbit queue. A `playTrack(track, [track])` call
// (e.g. OfflineLibrary's "Play this album" on a single-track album,
// or any other surface that explicitly passes a 1-track replacement
// queue) would otherwise blow away every guest suggestion + every
// upcoming track. Re-route to append + jump so the queue survives.
// Guest stays unguarded — a guest clicking Play locally is choosing
// to opt out of host-sync, which is the existing "guest is running
// their own show" path. `useOrbitGuest`'s `syncToHost` is also a
// guest-only call site, so it's never intercepted here.
if (!_orbitConfirmed && queue && queue.length === 1) {
const orbitRole = useOrbitStore.getState().role;
if (orbitRole === 'host') {
const currentItems = get().queueItems;
const currentTrackId = currentItems[get().queueIndex]?.trackId;
if (track.id !== currentTrackId) {
const existsAt = currentItems.findIndex(r => sameQueueTrackId(r.trackId, track.id));
if (existsAt >= 0) {
// Re-jump within the existing queue: pass undefined so playTrack keeps
// the canonical queueItems and just moves the index.
get().playTrack(track, undefined, manual, true, existsAt);
} else {
// Append the single track to the resolved current queue and jump to it.
const newQueue = [...getQueueTracksView(currentItems), track];
get().playTrack(track, newQueue, manual, true, newQueue.length - 1);
}
return;
}
}
}
// Ghost-command guard: if a gapless switch happened within 500 ms,
// this playTrack call is likely a stale IPC echo — suppress it.
if (Date.now() - getLastGaplessSwitchTime() < 500) {
return;
}
void playListenSessionFinalize('skip');
const scopedTrack = stampTrackServerId(track);
const scopedQueue = queue ? stampTrackServerIds(queue) : queue;
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
const gen = bumpPlayGeneration();
setIsAudioPaused(false);
clearPreloadingIds(); // new track — allow fresh preload for next
clearSeekDebounce(); clearSeekTarget();
clearSeekFallbackRetry();
setSeekFallbackRestartAt(0);
// If a radio stream is active, stop it before the new track starts so
// the PlayerBar clears radio mode immediately and the stream is released.
if (get().currentRadio) {
stopRadio();
}
const state = get();
const prevTrack = state.currentTrack;
if (prevTrack?.id !== scopedTrack.id) {
setSeekFallbackTrackId(null);
}
const visualOnEntry = getSeekFallbackVisualTarget();
if (visualOnEntry?.trackId !== scopedTrack.id) {
setSeekFallbackVisualTarget(null);
}
// Thin-state: only a real queue *replacement* (explicit `queue` arg) rebuilds
// queueItems. A no-arg navigation (next/previous/queue-row jump) keeps the
// canonical refs and just moves the index — so we never resolve the whole
// queue here (O(visible), not O(queue length)), which would hitch + churn
// every subscriber on each track change at scale.
const replacing = scopedQueue !== undefined;
const srcLen = replacing ? scopedQueue.length : state.queueItems.length;
if (replacing && shouldBindQueueServerForPlay(state.queueItems, scopedQueue, scopedQueue)) {
bindQueueServerForTracks(scopedQueue);
}
// Prefer an explicit target index from the caller (next/previous/queue-row
// click already know the exact slot). `findIndex` returns the *first*
// matching id, which jumps backwards when the queue contains the same
// track twice — breaking radio playback (issue #500).
const matchesAt = (i: number): boolean =>
replacing
? sameQueueTrackId(scopedQueue[i]?.id, scopedTrack.id)
: sameQueueTrackId(state.queueItems[i]?.trackId, scopedTrack.id);
const explicitIdxValid =
typeof targetQueueIndex === 'number'
&& targetQueueIndex >= 0
&& targetQueueIndex < srcLen
&& matchesAt(targetQueueIndex);
const idx = explicitIdxValid
? (targetQueueIndex as number)
: replacing
? scopedQueue.findIndex(t => sameQueueTrackId(t.id, scopedTrack.id))
: state.queueItems.findIndex(r => sameQueueTrackId(r.trackId, scopedTrack.id));
const playIdx = idx >= 0 ? idx : 0;
const playingRef = replacing ? undefined : state.queueItems[playIdx];
const prevPlayingRef = replacing ? undefined : state.queueItems[state.queueIndex];
// ±1 neighbours for replaygain normalization — resolve only these (not the
// whole queue). On replace they come from the provided Track[]; on navigation
// from the resolver cache (the bridge keeps that window warm).
const neighbourAt = (i: number): Track | null => {
if (i < 0 || i >= srcLen) return null;
if (replacing) return scopedQueue[i] ?? null;
if (i === playIdx) return scopedTrack;
const ref = state.queueItems[i];
return ref ? resolveQueueTrack(ref) : null;
};
const prevNeighbour = neighbourAt(playIdx - 1);
const nextNeighbour = neighbourAt(playIdx + 1);
// Minimal window so deriveNormalizationSnapshot reads ±1 without a full array.
const normWindow: Track[] = prevNeighbour ? [prevNeighbour] : [];
const normIdx = normWindow.length;
normWindow.push(scopedTrack);
if (nextNeighbour) normWindow.push(nextNeighbour);
if (manual) {
pushQueueUndoFromGetter(get);
}
const visualForInitial = getSeekFallbackVisualTarget();
const pendingVisualTarget = visualForInitial?.trackId === scopedTrack.id
? visualForInitial.seconds
: null;
const initialTime = pendingVisualTarget !== null
? Math.max(0, Math.min(pendingVisualTarget, scopedTrack.duration || pendingVisualTarget))
: 0;
const initialProgress =
scopedTrack.duration && scopedTrack.duration > 0
? Math.max(0, Math.min(1, initialTime / scopedTrack.duration))
: 0;
const authState = useAuthStore.getState();
const playbackProfileId = playbackProfileIdForTrack(scopedTrack, playingRef);
const libraryLocalUrl = playbackProfileId
? findLocalPlaybackUrl(scopedTrack.id, playbackProfileId, 'library')
: null;
// Same-track replay: Rust `fetch_data` consumes `stream_completed_cache` with
// `take()` once; a second replay would full HTTP-range again unless we flush
// RAM to hot disk first (promote was only run when switching to another track).
const needSameTrackHotPromote =
!libraryLocalUrl
&& !(playbackProfileId && hasLocalPersistentPlaybackBytes(scopedTrack.id, playbackProfileId))
&& Boolean(
prevTrack
&& sameQueueTrackId(prevTrack.id, scopedTrack.id)
&& authState.hotCacheEnabled
&& getPlaybackCacheServerKey(),
);
const runPlayTrackBody = () => {
const authStateNow = useAuthStore.getState();
const playbackSid = playbackProfileIdForTrack(scopedTrack, playingRef);
const playbackCacheSid = playbackCacheKeyForTrack(scopedTrack, playingRef);
const url = libraryLocalUrl
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'library')
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'favorite-auto')
?? resolvePlaybackUrl(scopedTrack.id, playbackCacheSid);
recordEnginePlayUrl(scopedTrack.id, url);
const preloadedTrackId = get().enginePreloadedTrackId;
const keepPreloadHint = preloadedTrackId === scopedTrack.id;
const playbackSourceHint = playbackSourceHintForResolvedUrl(
scopedTrack.id,
playbackCacheSid,
url,
);
if (import.meta.env.DEV) {
console.info('[psysonic][playTrack-source]', {
trackId: scopedTrack.id,
resolvedUrl: url,
preloadedTrackId,
keepPreloadHint,
playbackSourceHint,
});
}
// Set state immediately so the UI updates before the download completes.
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
const queueSid = get().queueServerId ?? '';
// When the caller replaced the queue (explicit `queue` arg), seed the
// resolver with those tracks so the UI / hot paths resolve them without a
// network round-trip. No-arg jumps reuse already-cached refs.
if (scopedQueue) {
for (const t of scopedQueue) {
const sid = playbackCacheKeyForTrack(t);
if (sid) seedQueueResolver(sid, [t]);
}
} else if (queueSid) {
seedQueueResolver(queueSid, [scopedTrack]);
}
set({
currentTrack: scopedTrack,
currentRadio: null,
waveformBins: null,
...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx),
// Only a replace rewrites the queue; navigation keeps the canonical refs.
...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}),
queueIndex: idx >= 0 ? idx : 0,
progress: initialProgress,
buffered: 0,
currentTime: initialTime,
scrobbled: false,
networkLoved: false,
// HTTP stream: wait for Rust `audio:playing` so the seekbar does not
// extrapolate while RangedHttpSource / legacy reader is still buffering.
isPlaying: playbackSourceHint !== 'stream',
isPlaybackBuffering: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
});
if (
prevTrack
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id)
&& authStateNow.hotCacheEnabled
) {
const prevPromoteSid = playbackCacheKeyForTrack(prevTrack, prevPlayingRef);
if (prevPromoteSid) {
void promoteCompletedStreamToHotCache(
prevTrack,
prevPromoteSid,
authStateNow.hotCacheDownloadDir || null,
);
}
}
void refreshWaveformForTrack(scopedTrack.id);
void refreshLoudnessForTrack(scopedTrack.id);
setDeferHotCachePrefetch(true);
const replayGainDb = resolveReplayGainDb(
scopedTrack, prevTrack, nextNeighbour,
isReplayGainActive(), authStateNow.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
invoke('audio_play', {
url,
volume: state.volume,
durationHint: scopedTrack.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(scopedTrack.id),
preGainDb: authStateNow.replayGainPreGainDb,
fallbackDb: authStateNow.replayGainFallbackDb,
manual,
hiResEnabled: authStateNow.enableHiRes,
analysisTrackId: scopedTrack.id,
serverId: getPlaybackIndexKey() || null,
streamFormatSuffix: scopedTrack.suffix ?? null,
startPaused: false,
})
.then(() => {
if (getPlayGeneration() !== gen) return;
if (keepPreloadHint) {
set({ enginePreloadedTrackId: null });
}
const durSeek = scopedTrack.duration && scopedTrack.duration > 0 ? scopedTrack.duration : null;
const seekTo = initialTime;
const canSeekAfterPlay =
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
if (canSeekAfterPlay) {
void invoke('audio_seek', { seconds: seekTo })
.then(() => {
if (getPlayGeneration() !== gen) return;
setSeekTarget(seekTo);
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
setSeekFallbackVisualTarget(null);
}
})
.catch(() => {
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
setSeekFallbackVisualTarget(null);
}
});
}
})
.catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play failed:', err);
set({ isPlaying: false });
setTimeout(() => {
if (getPlayGeneration() !== gen) return;
get().next(false);
}, 500);
});
// Navidrome now-playing follows nowPlayingEnabled; Music Network now-playing
// follows scrobbling, as Last.fm now-playing did (runtime gates internally).
const { nowPlayingEnabled: npEnabled } = useAuthStore.getState();
if (npEnabled) reportNowPlaying(scopedTrack.id, playbackSid);
const runtime = getMusicNetworkRuntimeOrNull();
void runtime?.dispatchNowPlaying({
title: scopedTrack.title,
artist: scopedTrack.artist,
album: scopedTrack.album,
duration: scopedTrack.duration,
timestamp: Date.now(),
});
if (runtime?.getEnrichmentPrimaryId()) {
void runtime
.isTrackLoved({ title: scopedTrack.title, artist: scopedTrack.artist })
.then(loved => {
const cacheKey = `${scopedTrack.title}::${scopedTrack.artist}`;
set(s => ({
networkLoved: loved,
networkLovedCache: { ...s.networkLovedCache, [cacheKey]: loved },
}));
});
}
syncQueueToServer(get().queueItems, scopedTrack, initialTime);
touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid);
};
const hotPromoteSid = getPlaybackCacheServerKey();
if (needSameTrackHotPromote && hotPromoteSid) {
void promoteCompletedStreamToHotCache(
scopedTrack,
hotPromoteSid,
authState.hotCacheDownloadDir || null,
)
.then(() => {
if (getPlayGeneration() !== gen) return;
runPlayTrackBody();
})
.catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] same-track hot promote / play body failed:', err);
set({ isPlaying: false });
});
} else {
runPlayTrackBody();
}
}