mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
refactor(player): E.26 — extract Rust audio event handlers cluster (#590)
The five `handleAudio*` functions + the `NormalizationStatePayload`
type move into `src/store/audioEventHandlers.ts`:
- `handleAudioPlaying` — flips isPlaying true + resets progress emit
throttles + lets hot-cache prefetch resume
- `handleAudioProgress` (~220 LOC) — the big one: seek-guard, visual
target apply, live progress emit, store commit, scrobble@50%,
server heartbeat, byte-preload + gapless-chain triggers
- `handleAudioEnded` — gapless-switch suppression + radio cleanup +
repeat-one branch with hot-cache promote + queue advance
- `handleAudioTrackSwitched` — gapless auto-advance UI sync + Now
Playing + waveform/loudness refresh
- `handleAudioError` — toast + skip-after-1.5 s with generation
guard
`initAudioListeners` (still in playerStore) imports the handlers from
the new module. Side-cleanup: 24 imports in playerStore that were
only consumed by these handlers are gone (bumpPerfCounter,
getPerfProbeFlags, the throttle accessor pair, the playback-progress
emit pair, scrobbleSong, lastfmScrobble, seek target / debounce
accessors, gapless-preload accessors, plus a handful of constants).
Behaviour preserved verbatim — existing `playerStore.events.test.ts`
+ `playerStore.progress.test.ts` characterization suites still pin
the handlers via the live Tauri event channel.
playerStore 2779 → 2394 LOC (−385).
This commit is contained in:
committed by
GitHub
parent
a5aadeea67
commit
0cb7fba272
@@ -0,0 +1,433 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
|
||||||
|
import { reportNowPlaying, scrobbleSong } from '../api/subsonic';
|
||||||
|
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||||
|
import { getPerfProbeFlags } from '../utils/perfFlags';
|
||||||
|
import { bumpPerfCounter } from '../utils/perfTelemetry';
|
||||||
|
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
||||||
|
import { resolveReplayGainDb } from '../utils/resolveReplayGainDb';
|
||||||
|
import { showToast } from '../utils/toast';
|
||||||
|
import { useAuthStore } from './authStore';
|
||||||
|
import { getPlayGeneration, setIsAudioPaused } from './engineState';
|
||||||
|
import {
|
||||||
|
clearPreloadingIds,
|
||||||
|
getBytePreloadingId,
|
||||||
|
getGaplessPreloadingId,
|
||||||
|
getLastGaplessSwitchTime,
|
||||||
|
markGaplessSwitch,
|
||||||
|
setBytePreloadingId,
|
||||||
|
setGaplessPreloadingId,
|
||||||
|
} from './gaplessPreloadState';
|
||||||
|
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
||||||
|
import {
|
||||||
|
isReplayGainActive,
|
||||||
|
loudnessGainDbForEngineBind,
|
||||||
|
} from './loudnessGainCache';
|
||||||
|
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
||||||
|
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
||||||
|
import { emitNormalizationDebug } from './normalizationDebug';
|
||||||
|
import {
|
||||||
|
emitPlaybackProgress,
|
||||||
|
getPlaybackProgressSnapshot,
|
||||||
|
} from './playbackProgress';
|
||||||
|
import {
|
||||||
|
LIVE_PROGRESS_EMIT_MIN_DELTA_SEC,
|
||||||
|
LIVE_PROGRESS_EMIT_MIN_MS,
|
||||||
|
STORE_PROGRESS_COMMIT_MIN_DELTA_SEC,
|
||||||
|
STORE_PROGRESS_COMMIT_MIN_MS,
|
||||||
|
getLastLiveProgressEmitAt,
|
||||||
|
getLastStoreProgressCommitAt,
|
||||||
|
markLiveProgressEmit,
|
||||||
|
markStoreProgressCommit,
|
||||||
|
resetProgressEmitThrottles,
|
||||||
|
} from './playbackThrottles';
|
||||||
|
import {
|
||||||
|
playbackSourceHintForResolvedUrl,
|
||||||
|
} from './playbackUrlRouting';
|
||||||
|
import { usePlayerStore, type Track } from './playerStore';
|
||||||
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
||||||
|
import {
|
||||||
|
flushQueueSyncToServer,
|
||||||
|
getLastQueueHeartbeatAt,
|
||||||
|
syncQueueToServer,
|
||||||
|
} from './queueSync';
|
||||||
|
import { isSeekDebouncePending } from './seekDebounce';
|
||||||
|
import {
|
||||||
|
SEEK_FALLBACK_VISUAL_GUARD_MS,
|
||||||
|
getSeekFallbackVisualTarget,
|
||||||
|
setSeekFallbackVisualTarget,
|
||||||
|
} from './seekFallbackState';
|
||||||
|
import {
|
||||||
|
SEEK_TARGET_GUARD_TIMEOUT_MS,
|
||||||
|
clearSeekTarget,
|
||||||
|
getSeekTarget,
|
||||||
|
getSeekTargetSetAt,
|
||||||
|
} from './seekTargetState';
|
||||||
|
import { refreshWaveformForTrack } from './waveformRefresh';
|
||||||
|
|
||||||
|
/** Rust-side `audio:normalization-state` event payload. */
|
||||||
|
export type NormalizationStatePayload = {
|
||||||
|
engine: 'off' | 'replaygain' | 'loudness' | string;
|
||||||
|
currentGainDb: number | null;
|
||||||
|
targetLufs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function handleAudioPlaying(_duration: number): void {
|
||||||
|
setDeferHotCachePrefetch(false);
|
||||||
|
resetProgressEmitThrottles();
|
||||||
|
usePlayerStore.setState({ isPlaying: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleAudioProgress(current_time: number, duration: number): void {
|
||||||
|
bumpPerfCounter('audioProgressEvents');
|
||||||
|
const perfFlags = getPerfProbeFlags();
|
||||||
|
const progressUiDisabled = perfFlags.disablePlayerProgressUi;
|
||||||
|
// While a seek is pending, the store already holds the optimistic target
|
||||||
|
// position. Accepting stale progress from the Rust engine would briefly
|
||||||
|
// snap the waveform back to the old position before the seek completes.
|
||||||
|
if (isSeekDebouncePending()) return;
|
||||||
|
// After the debounce fires, Rust may still emit 1–2 ticks with the old
|
||||||
|
// position before the seek takes effect. Block until current_time is
|
||||||
|
// within 2 s of the requested target, then clear the guard.
|
||||||
|
const activeSeekTarget = getSeekTarget();
|
||||||
|
if (activeSeekTarget !== null) {
|
||||||
|
if (Math.abs(current_time - activeSeekTarget) > 2.0) {
|
||||||
|
// If a seek command hangs while streaming is stalled, do not freeze UI.
|
||||||
|
if (Date.now() - getSeekTargetSetAt() <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
|
||||||
|
clearSeekTarget();
|
||||||
|
} else {
|
||||||
|
clearSeekTarget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = usePlayerStore.getState();
|
||||||
|
const track = store.currentTrack;
|
||||||
|
if (!track) return;
|
||||||
|
// Some backends can emit stale progress ticks shortly after pause/stop.
|
||||||
|
// Ignoring them avoids reactivating UI redraw loops while transport is idle.
|
||||||
|
const transportActive = store.isPlaying || store.currentRadio != null;
|
||||||
|
let visualTarget = getSeekFallbackVisualTarget();
|
||||||
|
if (!transportActive && !visualTarget) return;
|
||||||
|
if (visualTarget && visualTarget.trackId !== track.id) {
|
||||||
|
setSeekFallbackVisualTarget(null);
|
||||||
|
visualTarget = null;
|
||||||
|
}
|
||||||
|
let displayTime = current_time;
|
||||||
|
if (visualTarget && visualTarget.trackId === track.id) {
|
||||||
|
const nearTarget = Math.abs(current_time - visualTarget.seconds) <= 2.0;
|
||||||
|
if (nearTarget) {
|
||||||
|
setSeekFallbackVisualTarget(null);
|
||||||
|
visualTarget = null;
|
||||||
|
} else if (Date.now() - visualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) {
|
||||||
|
// Keep UI at the requested position while backend catches up.
|
||||||
|
displayTime = visualTarget.seconds;
|
||||||
|
} else {
|
||||||
|
setSeekFallbackVisualTarget(null);
|
||||||
|
visualTarget = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dur = duration > 0 ? duration : track.duration;
|
||||||
|
if (dur <= 0) return;
|
||||||
|
const progress = displayTime / dur;
|
||||||
|
if (!progressUiDisabled) {
|
||||||
|
const nowLive = Date.now();
|
||||||
|
const live = getPlaybackProgressSnapshot();
|
||||||
|
const liveTimeDelta = Math.abs(live.currentTime - displayTime);
|
||||||
|
if (
|
||||||
|
nowLive - getLastLiveProgressEmitAt() >= LIVE_PROGRESS_EMIT_MIN_MS ||
|
||||||
|
liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC ||
|
||||||
|
visualTarget != null
|
||||||
|
) {
|
||||||
|
emitPlaybackProgress({
|
||||||
|
currentTime: displayTime,
|
||||||
|
progress,
|
||||||
|
buffered: 0,
|
||||||
|
});
|
||||||
|
markLiveProgressEmit(nowLive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Heartbeat: push current position to the server every 15 s while playing so
|
||||||
|
// cross-device resume works even on a hard close — pause() and the close
|
||||||
|
// handler flush on top of this for clean shutdowns.
|
||||||
|
if (store.isPlaying && !store.currentRadio) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - getLastQueueHeartbeatAt() >= 15_000) {
|
||||||
|
void flushQueueSyncToServer(store.queue, track, displayTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||||
|
if (progress >= 0.5 && !store.scrobbled) {
|
||||||
|
usePlayerStore.setState({ scrobbled: true });
|
||||||
|
scrobbleSong(track.id, Date.now());
|
||||||
|
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||||
|
if (scrobblingEnabled && lastfmSessionKey) {
|
||||||
|
lastfmScrobble(track, Date.now(), lastfmSessionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (progressUiDisabled) return;
|
||||||
|
// Critical architectural guard: avoid high-frequency writes to the persisted
|
||||||
|
// Zustand store (each write serializes queue state). Keep only coarse commits.
|
||||||
|
const nowCommit = Date.now();
|
||||||
|
const commitDelta = Math.abs(store.currentTime - displayTime);
|
||||||
|
const shouldCommitStore =
|
||||||
|
visualTarget != null ||
|
||||||
|
nowCommit - getLastStoreProgressCommitAt() >= STORE_PROGRESS_COMMIT_MIN_MS ||
|
||||||
|
commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC;
|
||||||
|
if (shouldCommitStore) {
|
||||||
|
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
|
||||||
|
markStoreProgressCommit(nowCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-buffer / pre-chain next track based on preload mode and crossfade.
|
||||||
|
const {
|
||||||
|
gaplessEnabled,
|
||||||
|
preloadMode,
|
||||||
|
preloadCustomSeconds,
|
||||||
|
hotCacheEnabled,
|
||||||
|
crossfadeEnabled,
|
||||||
|
crossfadeSecs,
|
||||||
|
} = useAuthStore.getState();
|
||||||
|
const remaining = dur - current_time;
|
||||||
|
|
||||||
|
// Gapless chain: always triggers at 30s regardless of preloadMode.
|
||||||
|
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
|
||||||
|
// Byte pre-download: skip when Hot Cache is active (it already handles buffering).
|
||||||
|
// Even with preload mode OFF, crossfade needs the next track bytes ready before
|
||||||
|
// we enter the fade window to avoid a hard gap after track boundary.
|
||||||
|
const shouldBytePreloadFromMode = preloadMode !== 'off' && (
|
||||||
|
preloadMode === 'early'
|
||||||
|
? current_time >= 5
|
||||||
|
: preloadMode === 'custom'
|
||||||
|
? remaining < preloadCustomSeconds && remaining > 0
|
||||||
|
: remaining < 30 && remaining > 0 // balanced (default)
|
||||||
|
);
|
||||||
|
const crossfadeWindowSecs = Math.max(8, Math.min(30, crossfadeSecs + 6));
|
||||||
|
const shouldBytePreloadForCrossfade =
|
||||||
|
!gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0;
|
||||||
|
const shouldBytePreload = !hotCacheEnabled && (
|
||||||
|
shouldBytePreloadFromMode ||
|
||||||
|
shouldBytePreloadForCrossfade
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldChainGapless || shouldBytePreload || gaplessEnabled) {
|
||||||
|
const { queue, queueIndex, repeatMode } = store;
|
||||||
|
const nextIdx = queueIndex + 1;
|
||||||
|
const nextTrack = repeatMode === 'one'
|
||||||
|
? track
|
||||||
|
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
||||||
|
if (!nextTrack || nextTrack.id === track.id) return;
|
||||||
|
|
||||||
|
// Gapless backup: keep next-track bytes ready even if chain/decode misses
|
||||||
|
// the boundary. Start earlier for larger files / slower conservative link.
|
||||||
|
const estBytes = (() => {
|
||||||
|
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
|
||||||
|
return nextTrack.size;
|
||||||
|
}
|
||||||
|
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
|
||||||
|
? nextTrack.bitRate
|
||||||
|
: 320;
|
||||||
|
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
|
||||||
|
})();
|
||||||
|
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
|
||||||
|
const estDownloadSecs = estBytes / conservativeBytesPerSec;
|
||||||
|
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
|
||||||
|
const shouldBytePreloadForGaplessBackup =
|
||||||
|
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
|
||||||
|
|
||||||
|
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||||
|
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||||
|
|
||||||
|
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||||
|
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== getBytePreloadingId()) {
|
||||||
|
setBytePreloadingId(nextTrack.id);
|
||||||
|
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
|
||||||
|
// waveformBins and would replace the current track's seekbar while still playing it.
|
||||||
|
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.info('[psysonic][preload-request]', {
|
||||||
|
nextTrackId: nextTrack.id,
|
||||||
|
nextUrl,
|
||||||
|
shouldBytePreload,
|
||||||
|
shouldBytePreloadForGaplessBackup,
|
||||||
|
remaining,
|
||||||
|
gaplessEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
invoke('audio_preload', {
|
||||||
|
url: nextUrl,
|
||||||
|
durationHint: nextTrack.duration,
|
||||||
|
analysisTrackId: nextTrack.id,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gapless chain — decode + chain into Sink 30s before track boundary.
|
||||||
|
if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) {
|
||||||
|
setGaplessPreloadingId(nextTrack.id);
|
||||||
|
// Ensure loudness gain is already cached for the chained request payload.
|
||||||
|
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||||
|
const authState = useAuthStore.getState();
|
||||||
|
// Auto-mode neighbours for the *next* track: current track on its left,
|
||||||
|
// queue[nextIdx+1] on its right.
|
||||||
|
const nextNeighbour = nextIdx + 1 < queue.length
|
||||||
|
? queue[nextIdx + 1]
|
||||||
|
: (repeatMode === 'all' && queue.length > 0 ? queue[0] : null);
|
||||||
|
const replayGainDb = resolveReplayGainDb(
|
||||||
|
nextTrack, track, nextNeighbour,
|
||||||
|
isReplayGainActive(), authState.replayGainMode,
|
||||||
|
);
|
||||||
|
const replayGainPeak = isReplayGainActive()
|
||||||
|
? (nextTrack.replayGainPeak ?? null)
|
||||||
|
: null;
|
||||||
|
invoke('audio_chain_preload', {
|
||||||
|
url: nextUrl,
|
||||||
|
volume: store.volume,
|
||||||
|
durationHint: nextTrack.duration,
|
||||||
|
replayGainDb,
|
||||||
|
replayGainPeak,
|
||||||
|
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
|
||||||
|
preGainDb: authState.replayGainPreGainDb,
|
||||||
|
fallbackDb: authState.replayGainFallbackDb,
|
||||||
|
hiResEnabled: authState.enableHiRes,
|
||||||
|
analysisTrackId: nextTrack.id,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleAudioEnded(): void {
|
||||||
|
// If a gapless switch happened recently, this ended event is stale — the
|
||||||
|
// progress task fired it for the OLD source before seeing the chained one.
|
||||||
|
if (Date.now() - getLastGaplessSwitchTime() < 600) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radio stream disconnected — just stop; don't advance queue.
|
||||||
|
if (usePlayerStore.getState().currentRadio) {
|
||||||
|
setIsAudioPaused(false);
|
||||||
|
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { repeatMode, currentTrack, queue, queueIndex } = usePlayerStore.getState();
|
||||||
|
setIsAudioPaused(false);
|
||||||
|
usePlayerStore.setState({
|
||||||
|
isPlaying: false,
|
||||||
|
progress: 0,
|
||||||
|
currentTime: 0,
|
||||||
|
buffered: 0,
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
if (repeatMode === 'one' && currentTrack) {
|
||||||
|
const authState = useAuthStore.getState();
|
||||||
|
const repeatPromoteSid = authState.activeServerId;
|
||||||
|
if (authState.hotCacheEnabled && repeatPromoteSid) {
|
||||||
|
// Same-track repeat never hit `playTrack`'s prev→promote path; flush
|
||||||
|
// Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local.
|
||||||
|
await promoteCompletedStreamToHotCache(
|
||||||
|
currentTrack,
|
||||||
|
repeatPromoteSid,
|
||||||
|
authState.hotCacheDownloadDir || null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Pin to the current slot — the track may appear elsewhere in the queue.
|
||||||
|
usePlayerStore.getState().playTrack(currentTrack, queue, false, false, queueIndex);
|
||||||
|
} else {
|
||||||
|
usePlayerStore.getState().next(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle gapless auto-advance: the Rust engine has already switched to the
|
||||||
|
* next source sample-accurately. We just need to update the UI state without
|
||||||
|
* touching the audio stream (no playTrack() call!).
|
||||||
|
*/
|
||||||
|
export function handleAudioTrackSwitched(_duration: number): void {
|
||||||
|
markGaplessSwitch();
|
||||||
|
clearPreloadingIds(); // allow preloading for the track after this one
|
||||||
|
setIsAudioPaused(false);
|
||||||
|
|
||||||
|
const store = usePlayerStore.getState();
|
||||||
|
if (store.currentTrack?.id) {
|
||||||
|
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
|
||||||
|
}
|
||||||
|
const { queue, queueIndex, repeatMode } = store;
|
||||||
|
const nextIdx = queueIndex + 1;
|
||||||
|
let nextTrack: Track | null = null;
|
||||||
|
let newIndex = queueIndex;
|
||||||
|
|
||||||
|
if (repeatMode === 'one' && store.currentTrack) {
|
||||||
|
nextTrack = store.currentTrack;
|
||||||
|
// queueIndex stays the same
|
||||||
|
} else if (nextIdx < queue.length) {
|
||||||
|
nextTrack = queue[nextIdx];
|
||||||
|
newIndex = nextIdx;
|
||||||
|
} else if (repeatMode === 'all' && queue.length > 0) {
|
||||||
|
nextTrack = queue[0];
|
||||||
|
newIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextTrack) return;
|
||||||
|
|
||||||
|
const switchServerId = useAuthStore.getState().activeServerId ?? '';
|
||||||
|
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||||
|
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
||||||
|
|
||||||
|
usePlayerStore.setState({
|
||||||
|
currentTrack: nextTrack,
|
||||||
|
waveformBins: null,
|
||||||
|
...deriveNormalizationSnapshot(nextTrack, queue, newIndex),
|
||||||
|
normalizationDbgSource: 'track-switched',
|
||||||
|
normalizationDbgTrackId: nextTrack.id,
|
||||||
|
queueIndex: newIndex,
|
||||||
|
isPlaying: true,
|
||||||
|
progress: 0,
|
||||||
|
currentTime: 0,
|
||||||
|
buffered: 0,
|
||||||
|
scrobbled: false,
|
||||||
|
lastfmLoved: false,
|
||||||
|
currentPlaybackSource: switchPlaybackSource,
|
||||||
|
});
|
||||||
|
emitNormalizationDebug('track-switched', {
|
||||||
|
trackId: nextTrack.id,
|
||||||
|
queueIndex: newIndex,
|
||||||
|
engineRequested: useAuthStore.getState().normalizationEngine,
|
||||||
|
});
|
||||||
|
void refreshWaveformForTrack(nextTrack.id);
|
||||||
|
void refreshLoudnessForTrack(nextTrack.id);
|
||||||
|
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||||
|
|
||||||
|
// Report Now Playing to Navidrome + Last.fm
|
||||||
|
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||||
|
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id);
|
||||||
|
if (lastfmSessionKey) {
|
||||||
|
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
|
||||||
|
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
|
||||||
|
const cacheKey = `${nextTrack!.title}::${nextTrack!.artist}`;
|
||||||
|
usePlayerStore.setState(s => ({
|
||||||
|
lastfmLoved: loved,
|
||||||
|
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
syncQueueToServer(queue, nextTrack, 0);
|
||||||
|
touchHotCacheOnPlayback(nextTrack.id, useAuthStore.getState().activeServerId ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleAudioError(message: string): void {
|
||||||
|
console.error('[psysonic] Audio error from backend:', message);
|
||||||
|
setIsAudioPaused(false);
|
||||||
|
|
||||||
|
const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
|
||||||
|
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
|
||||||
|
|
||||||
|
const gen = getPlayGeneration();
|
||||||
|
usePlayerStore.setState({ isPlaying: false });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (getPlayGeneration() !== gen) return;
|
||||||
|
usePlayerStore.getState().next(false);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
+10
-395
@@ -4,10 +4,10 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, getSong, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic';
|
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, getSong, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic';
|
||||||
import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
|
import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
|
||||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
import { lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { useOfflineStore } from './offlineStore';
|
import { useOfflineStore } from './offlineStore';
|
||||||
import { useHotCacheStore } from './hotCacheStore';
|
import { useHotCacheStore } from './hotCacheStore';
|
||||||
@@ -17,8 +17,6 @@ import { useOrbitStore } from './orbitStore';
|
|||||||
import { estimateLivePosition } from '../api/orbit';
|
import { estimateLivePosition } from '../api/orbit';
|
||||||
import { loudnessGainPlaceholderUntilCacheDb } from '../utils/loudnessPlaceholder';
|
import { loudnessGainPlaceholderUntilCacheDb } from '../utils/loudnessPlaceholder';
|
||||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider';
|
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider';
|
||||||
import { getPerfProbeFlags } from '../utils/perfFlags';
|
|
||||||
import { bumpPerfCounter } from '../utils/perfTelemetry';
|
|
||||||
import { resolveReplayGainDb } from '../utils/resolveReplayGainDb';
|
import { resolveReplayGainDb } from '../utils/resolveReplayGainDb';
|
||||||
import { shuffleArray } from '../utils/shuffleArray';
|
import { shuffleArray } from '../utils/shuffleArray';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
@@ -73,24 +71,15 @@ import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState';
|
|||||||
import {
|
import {
|
||||||
flushPlayQueuePosition,
|
flushPlayQueuePosition,
|
||||||
flushQueueSyncToServer,
|
flushQueueSyncToServer,
|
||||||
getLastQueueHeartbeatAt,
|
|
||||||
syncQueueToServer,
|
syncQueueToServer,
|
||||||
} from './queueSync';
|
} from './queueSync';
|
||||||
import {
|
import {
|
||||||
clearPreloadingIds,
|
clearPreloadingIds,
|
||||||
getBytePreloadingId,
|
|
||||||
getGaplessPreloadingId,
|
|
||||||
getLastGaplessSwitchTime,
|
getLastGaplessSwitchTime,
|
||||||
markGaplessSwitch,
|
|
||||||
setBytePreloadingId,
|
|
||||||
setGaplessPreloadingId,
|
|
||||||
} from './gaplessPreloadState';
|
} from './gaplessPreloadState';
|
||||||
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
||||||
import {
|
import {
|
||||||
SEEK_TARGET_GUARD_TIMEOUT_MS,
|
|
||||||
clearSeekTarget,
|
clearSeekTarget,
|
||||||
getSeekTarget,
|
|
||||||
getSeekTargetSetAt,
|
|
||||||
setSeekTarget,
|
setSeekTarget,
|
||||||
} from './seekTargetState';
|
} from './seekTargetState';
|
||||||
import { tryAcquireTogglePlayLock } from './togglePlayLock';
|
import { tryAcquireTogglePlayLock } from './togglePlayLock';
|
||||||
@@ -106,21 +95,11 @@ import {
|
|||||||
stopRadio,
|
stopRadio,
|
||||||
} from './radioPlayer';
|
} from './radioPlayer';
|
||||||
import {
|
import {
|
||||||
LIVE_PROGRESS_EMIT_MIN_DELTA_SEC,
|
|
||||||
LIVE_PROGRESS_EMIT_MIN_MS,
|
|
||||||
NORMALIZATION_UI_THROTTLE_MS,
|
NORMALIZATION_UI_THROTTLE_MS,
|
||||||
STORE_PROGRESS_COMMIT_MIN_DELTA_SEC,
|
|
||||||
STORE_PROGRESS_COMMIT_MIN_MS,
|
|
||||||
getLastLiveProgressEmitAt,
|
|
||||||
getLastNormalizationUiUpdateAtMs,
|
getLastNormalizationUiUpdateAtMs,
|
||||||
getLastStoreProgressCommitAt,
|
|
||||||
markLiveProgressEmit,
|
|
||||||
markNormalizationUiUpdate,
|
markNormalizationUiUpdate,
|
||||||
markStoreProgressCommit,
|
|
||||||
resetProgressEmitThrottles,
|
|
||||||
} from './playbackThrottles';
|
} from './playbackThrottles';
|
||||||
import {
|
import {
|
||||||
SEEK_FALLBACK_VISUAL_GUARD_MS,
|
|
||||||
clearSeekFallbackRetry,
|
clearSeekFallbackRetry,
|
||||||
getSeekFallbackRestartAt,
|
getSeekFallbackRestartAt,
|
||||||
getSeekFallbackTrackId,
|
getSeekFallbackTrackId,
|
||||||
@@ -133,7 +112,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
armSeekDebounce,
|
armSeekDebounce,
|
||||||
clearSeekDebounce,
|
clearSeekDebounce,
|
||||||
isSeekDebouncePending,
|
|
||||||
} from './seekDebounce';
|
} from './seekDebounce';
|
||||||
import {
|
import {
|
||||||
bumpPlayGeneration,
|
bumpPlayGeneration,
|
||||||
@@ -157,6 +135,14 @@ import {
|
|||||||
} from './infiniteQueueState';
|
} from './infiniteQueueState';
|
||||||
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
|
import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore';
|
||||||
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
|
import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch';
|
||||||
|
import {
|
||||||
|
handleAudioEnded,
|
||||||
|
handleAudioError,
|
||||||
|
handleAudioPlaying,
|
||||||
|
handleAudioProgress,
|
||||||
|
handleAudioTrackSwitched,
|
||||||
|
type NormalizationStatePayload,
|
||||||
|
} from './audioEventHandlers';
|
||||||
|
|
||||||
// Re-export so TauriEventBridge + persistence test keep their existing
|
// Re-export so TauriEventBridge + persistence test keep their existing
|
||||||
// `from './playerStore'` imports.
|
// `from './playerStore'` imports.
|
||||||
@@ -399,379 +385,8 @@ export interface PlayerState {
|
|||||||
closeSongInfo: () => void;
|
closeSongInfo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type NormalizationStatePayload = {
|
|
||||||
engine: 'off' | 'replaygain' | 'loudness' | string;
|
|
||||||
currentGainDb: number | null;
|
|
||||||
targetLufs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Module-level playback primitives ─────────────────────────────────────────
|
// ─── Module-level playback primitives ─────────────────────────────────────────
|
||||||
|
|
||||||
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
|
||||||
|
|
||||||
|
|
||||||
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
|
|
||||||
|
|
||||||
function handleAudioPlaying(_duration: number) {
|
|
||||||
setDeferHotCachePrefetch(false);
|
|
||||||
resetProgressEmitThrottles();
|
|
||||||
usePlayerStore.setState({ isPlaying: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAudioProgress(current_time: number, duration: number) {
|
|
||||||
bumpPerfCounter('audioProgressEvents');
|
|
||||||
const perfFlags = getPerfProbeFlags();
|
|
||||||
const progressUiDisabled = perfFlags.disablePlayerProgressUi;
|
|
||||||
// While a seek is pending, the store already holds the optimistic target
|
|
||||||
// position. Accepting stale progress from the Rust engine would briefly
|
|
||||||
// snap the waveform back to the old position before the seek completes.
|
|
||||||
if (isSeekDebouncePending()) return;
|
|
||||||
// After the debounce fires, Rust may still emit 1–2 ticks with the old
|
|
||||||
// position before the seek takes effect. Block until current_time is
|
|
||||||
// within 2 s of the requested target, then clear the guard.
|
|
||||||
const activeSeekTarget = getSeekTarget();
|
|
||||||
if (activeSeekTarget !== null) {
|
|
||||||
if (Math.abs(current_time - activeSeekTarget) > 2.0) {
|
|
||||||
// If a seek command hangs while streaming is stalled, do not freeze UI.
|
|
||||||
if (Date.now() - getSeekTargetSetAt() <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
|
|
||||||
clearSeekTarget();
|
|
||||||
} else {
|
|
||||||
clearSeekTarget();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = usePlayerStore.getState();
|
|
||||||
const track = store.currentTrack;
|
|
||||||
if (!track) return;
|
|
||||||
// Some backends can emit stale progress ticks shortly after pause/stop.
|
|
||||||
// Ignoring them avoids reactivating UI redraw loops while transport is idle.
|
|
||||||
const transportActive = store.isPlaying || store.currentRadio != null;
|
|
||||||
let visualTarget = getSeekFallbackVisualTarget();
|
|
||||||
if (!transportActive && !visualTarget) return;
|
|
||||||
if (visualTarget && visualTarget.trackId !== track.id) {
|
|
||||||
setSeekFallbackVisualTarget(null);
|
|
||||||
visualTarget = null;
|
|
||||||
}
|
|
||||||
let displayTime = current_time;
|
|
||||||
if (visualTarget && visualTarget.trackId === track.id) {
|
|
||||||
const nearTarget = Math.abs(current_time - visualTarget.seconds) <= 2.0;
|
|
||||||
if (nearTarget) {
|
|
||||||
setSeekFallbackVisualTarget(null);
|
|
||||||
visualTarget = null;
|
|
||||||
} else if (Date.now() - visualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) {
|
|
||||||
// Keep UI at the requested position while backend catches up.
|
|
||||||
displayTime = visualTarget.seconds;
|
|
||||||
} else {
|
|
||||||
setSeekFallbackVisualTarget(null);
|
|
||||||
visualTarget = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dur = duration > 0 ? duration : track.duration;
|
|
||||||
if (dur <= 0) return;
|
|
||||||
const progress = displayTime / dur;
|
|
||||||
if (!progressUiDisabled) {
|
|
||||||
const nowLive = Date.now();
|
|
||||||
const live = getPlaybackProgressSnapshot();
|
|
||||||
const liveTimeDelta = Math.abs(live.currentTime - displayTime);
|
|
||||||
if (
|
|
||||||
nowLive - getLastLiveProgressEmitAt() >= LIVE_PROGRESS_EMIT_MIN_MS ||
|
|
||||||
liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC ||
|
|
||||||
visualTarget != null
|
|
||||||
) {
|
|
||||||
emitPlaybackProgress({
|
|
||||||
currentTime: displayTime,
|
|
||||||
progress,
|
|
||||||
buffered: 0,
|
|
||||||
});
|
|
||||||
markLiveProgressEmit(nowLive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Heartbeat: push current position to the server every 15 s while playing so
|
|
||||||
// cross-device resume works even on a hard close — pause() and the close
|
|
||||||
// handler flush on top of this for clean shutdowns.
|
|
||||||
if (store.isPlaying && !store.currentRadio) {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - getLastQueueHeartbeatAt() >= 15_000) {
|
|
||||||
void flushQueueSyncToServer(store.queue, track, displayTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
|
||||||
if (progress >= 0.5 && !store.scrobbled) {
|
|
||||||
usePlayerStore.setState({ scrobbled: true });
|
|
||||||
scrobbleSong(track.id, Date.now());
|
|
||||||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
|
||||||
if (scrobblingEnabled && lastfmSessionKey) {
|
|
||||||
lastfmScrobble(track, Date.now(), lastfmSessionKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (progressUiDisabled) return;
|
|
||||||
// Critical architectural guard: avoid high-frequency writes to the persisted
|
|
||||||
// Zustand store (each write serializes queue state). Keep only coarse commits.
|
|
||||||
const nowCommit = Date.now();
|
|
||||||
const commitDelta = Math.abs(store.currentTime - displayTime);
|
|
||||||
const shouldCommitStore =
|
|
||||||
visualTarget != null ||
|
|
||||||
nowCommit - getLastStoreProgressCommitAt() >= STORE_PROGRESS_COMMIT_MIN_MS ||
|
|
||||||
commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC;
|
|
||||||
if (shouldCommitStore) {
|
|
||||||
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
|
|
||||||
markStoreProgressCommit(nowCommit);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-buffer / pre-chain next track based on preload mode and crossfade.
|
|
||||||
const {
|
|
||||||
gaplessEnabled,
|
|
||||||
preloadMode,
|
|
||||||
preloadCustomSeconds,
|
|
||||||
hotCacheEnabled,
|
|
||||||
crossfadeEnabled,
|
|
||||||
crossfadeSecs,
|
|
||||||
} = useAuthStore.getState();
|
|
||||||
const remaining = dur - current_time;
|
|
||||||
|
|
||||||
// Gapless chain: always triggers at 30s regardless of preloadMode.
|
|
||||||
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
|
|
||||||
// Byte pre-download: skip when Hot Cache is active (it already handles buffering).
|
|
||||||
// Even with preload mode OFF, crossfade needs the next track bytes ready before
|
|
||||||
// we enter the fade window to avoid a hard gap after track boundary.
|
|
||||||
const shouldBytePreloadFromMode = preloadMode !== 'off' && (
|
|
||||||
preloadMode === 'early'
|
|
||||||
? current_time >= 5
|
|
||||||
: preloadMode === 'custom'
|
|
||||||
? remaining < preloadCustomSeconds && remaining > 0
|
|
||||||
: remaining < 30 && remaining > 0 // balanced (default)
|
|
||||||
);
|
|
||||||
const crossfadeWindowSecs = Math.max(8, Math.min(30, crossfadeSecs + 6));
|
|
||||||
const shouldBytePreloadForCrossfade =
|
|
||||||
!gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0;
|
|
||||||
const shouldBytePreload = !hotCacheEnabled && (
|
|
||||||
shouldBytePreloadFromMode ||
|
|
||||||
shouldBytePreloadForCrossfade
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shouldChainGapless || shouldBytePreload || gaplessEnabled) {
|
|
||||||
const { queue, queueIndex, repeatMode } = store;
|
|
||||||
const nextIdx = queueIndex + 1;
|
|
||||||
const nextTrack = repeatMode === 'one'
|
|
||||||
? track
|
|
||||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
|
||||||
if (!nextTrack || nextTrack.id === track.id) return;
|
|
||||||
|
|
||||||
// Gapless backup: keep next-track bytes ready even if chain/decode misses
|
|
||||||
// the boundary. Start earlier for larger files / slower conservative link.
|
|
||||||
const estBytes = (() => {
|
|
||||||
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
|
|
||||||
return nextTrack.size;
|
|
||||||
}
|
|
||||||
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
|
|
||||||
? nextTrack.bitRate
|
|
||||||
: 320;
|
|
||||||
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
|
|
||||||
})();
|
|
||||||
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
|
|
||||||
const estDownloadSecs = estBytes / conservativeBytesPerSec;
|
|
||||||
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
|
|
||||||
const shouldBytePreloadForGaplessBackup =
|
|
||||||
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
|
|
||||||
|
|
||||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
|
||||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
|
||||||
|
|
||||||
// Byte pre-download — runs early so bytes are cached by chain time.
|
|
||||||
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== getBytePreloadingId()) {
|
|
||||||
setBytePreloadingId(nextTrack.id);
|
|
||||||
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
|
|
||||||
// waveformBins and would replace the current track's seekbar while still playing it.
|
|
||||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.info('[psysonic][preload-request]', {
|
|
||||||
nextTrackId: nextTrack.id,
|
|
||||||
nextUrl,
|
|
||||||
shouldBytePreload,
|
|
||||||
shouldBytePreloadForGaplessBackup,
|
|
||||||
remaining,
|
|
||||||
gaplessEnabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
invoke('audio_preload', {
|
|
||||||
url: nextUrl,
|
|
||||||
durationHint: nextTrack.duration,
|
|
||||||
analysisTrackId: nextTrack.id,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gapless chain — decode + chain into Sink 30s before track boundary.
|
|
||||||
if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) {
|
|
||||||
setGaplessPreloadingId(nextTrack.id);
|
|
||||||
// Ensure loudness gain is already cached for the chained request payload.
|
|
||||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
|
||||||
const authState = useAuthStore.getState();
|
|
||||||
// Auto-mode neighbours for the *next* track: current track on its left,
|
|
||||||
// queue[nextIdx+1] on its right.
|
|
||||||
const nextNeighbour = nextIdx + 1 < queue.length
|
|
||||||
? queue[nextIdx + 1]
|
|
||||||
: (repeatMode === 'all' && queue.length > 0 ? queue[0] : null);
|
|
||||||
const replayGainDb = resolveReplayGainDb(
|
|
||||||
nextTrack, track, nextNeighbour,
|
|
||||||
isReplayGainActive(), authState.replayGainMode,
|
|
||||||
);
|
|
||||||
const replayGainPeak = isReplayGainActive()
|
|
||||||
? (nextTrack.replayGainPeak ?? null)
|
|
||||||
: null;
|
|
||||||
invoke('audio_chain_preload', {
|
|
||||||
url: nextUrl,
|
|
||||||
volume: store.volume,
|
|
||||||
durationHint: nextTrack.duration,
|
|
||||||
replayGainDb,
|
|
||||||
replayGainPeak,
|
|
||||||
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
|
|
||||||
preGainDb: authState.replayGainPreGainDb,
|
|
||||||
fallbackDb: authState.replayGainFallbackDb,
|
|
||||||
hiResEnabled: authState.enableHiRes,
|
|
||||||
analysisTrackId: nextTrack.id,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAudioEnded() {
|
|
||||||
// If a gapless switch happened recently, this ended event is stale — the
|
|
||||||
// progress task fired it for the OLD source before seeing the chained one.
|
|
||||||
if (Date.now() - getLastGaplessSwitchTime() < 600) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Radio stream disconnected — just stop; don't advance queue.
|
|
||||||
if (usePlayerStore.getState().currentRadio) {
|
|
||||||
setIsAudioPaused(false);
|
|
||||||
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { repeatMode, currentTrack, queue, queueIndex } = usePlayerStore.getState();
|
|
||||||
setIsAudioPaused(false);
|
|
||||||
usePlayerStore.setState({
|
|
||||||
isPlaying: false,
|
|
||||||
progress: 0,
|
|
||||||
currentTime: 0,
|
|
||||||
buffered: 0,
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
void (async () => {
|
|
||||||
if (repeatMode === 'one' && currentTrack) {
|
|
||||||
const authState = useAuthStore.getState();
|
|
||||||
const repeatPromoteSid = authState.activeServerId;
|
|
||||||
if (authState.hotCacheEnabled && repeatPromoteSid) {
|
|
||||||
// Same-track repeat never hit `playTrack`'s prev→promote path; flush
|
|
||||||
// Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local.
|
|
||||||
await promoteCompletedStreamToHotCache(
|
|
||||||
currentTrack,
|
|
||||||
repeatPromoteSid,
|
|
||||||
authState.hotCacheDownloadDir || null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Pin to the current slot — the track may appear elsewhere in the queue.
|
|
||||||
usePlayerStore.getState().playTrack(currentTrack, queue, false, false, queueIndex);
|
|
||||||
} else {
|
|
||||||
usePlayerStore.getState().next(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle gapless auto-advance: the Rust engine has already switched to the
|
|
||||||
* next source sample-accurately. We just need to update the UI state without
|
|
||||||
* touching the audio stream (no playTrack() call!).
|
|
||||||
*/
|
|
||||||
function handleAudioTrackSwitched(duration: number) {
|
|
||||||
markGaplessSwitch();
|
|
||||||
clearPreloadingIds(); // allow preloading for the track after this one
|
|
||||||
setIsAudioPaused(false);
|
|
||||||
|
|
||||||
const store = usePlayerStore.getState();
|
|
||||||
if (store.currentTrack?.id) {
|
|
||||||
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
|
|
||||||
}
|
|
||||||
const { queue, queueIndex, repeatMode } = store;
|
|
||||||
const nextIdx = queueIndex + 1;
|
|
||||||
let nextTrack: Track | null = null;
|
|
||||||
let newIndex = queueIndex;
|
|
||||||
|
|
||||||
if (repeatMode === 'one' && store.currentTrack) {
|
|
||||||
nextTrack = store.currentTrack;
|
|
||||||
// queueIndex stays the same
|
|
||||||
} else if (nextIdx < queue.length) {
|
|
||||||
nextTrack = queue[nextIdx];
|
|
||||||
newIndex = nextIdx;
|
|
||||||
} else if (repeatMode === 'all' && queue.length > 0) {
|
|
||||||
nextTrack = queue[0];
|
|
||||||
newIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nextTrack) return;
|
|
||||||
|
|
||||||
const switchServerId = useAuthStore.getState().activeServerId ?? '';
|
|
||||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
|
||||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
|
||||||
|
|
||||||
usePlayerStore.setState({
|
|
||||||
currentTrack: nextTrack,
|
|
||||||
waveformBins: null,
|
|
||||||
...deriveNormalizationSnapshot(nextTrack, queue, newIndex),
|
|
||||||
normalizationDbgSource: 'track-switched',
|
|
||||||
normalizationDbgTrackId: nextTrack.id,
|
|
||||||
queueIndex: newIndex,
|
|
||||||
isPlaying: true,
|
|
||||||
progress: 0,
|
|
||||||
currentTime: 0,
|
|
||||||
buffered: 0,
|
|
||||||
scrobbled: false,
|
|
||||||
lastfmLoved: false,
|
|
||||||
currentPlaybackSource: switchPlaybackSource,
|
|
||||||
});
|
|
||||||
emitNormalizationDebug('track-switched', {
|
|
||||||
trackId: nextTrack.id,
|
|
||||||
queueIndex: newIndex,
|
|
||||||
engineRequested: useAuthStore.getState().normalizationEngine,
|
|
||||||
});
|
|
||||||
void refreshWaveformForTrack(nextTrack.id);
|
|
||||||
void refreshLoudnessForTrack(nextTrack.id);
|
|
||||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
|
||||||
|
|
||||||
// Report Now Playing to Navidrome + Last.fm
|
|
||||||
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
|
||||||
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id);
|
|
||||||
if (lastfmSessionKey) {
|
|
||||||
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
|
|
||||||
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
|
|
||||||
const cacheKey = `${nextTrack!.title}::${nextTrack!.artist}`;
|
|
||||||
usePlayerStore.setState(s => ({
|
|
||||||
lastfmLoved: loved,
|
|
||||||
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
syncQueueToServer(queue, nextTrack, 0);
|
|
||||||
touchHotCacheOnPlayback(nextTrack.id, useAuthStore.getState().activeServerId ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAudioError(message: string) {
|
|
||||||
console.error('[psysonic] Audio error from backend:', message);
|
|
||||||
setIsAudioPaused(false);
|
|
||||||
|
|
||||||
const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
|
|
||||||
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
|
|
||||||
|
|
||||||
const gen = getPlayGeneration();
|
|
||||||
usePlayerStore.setState({ isPlaying: false });
|
|
||||||
setTimeout(() => {
|
|
||||||
if (getPlayGeneration() !== gen) return;
|
|
||||||
usePlayerStore.getState().next(false);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up Tauri event listeners for the Rust audio engine.
|
* Set up Tauri event listeners for the Rust audio engine.
|
||||||
* Returns a cleanup function — pass it to useEffect's return value so that
|
* Returns a cleanup function — pass it to useEffect's return value so that
|
||||||
|
|||||||
Reference in New Issue
Block a user