refactor(playback): move the audio engine into features/playback

Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
  store/<x>                    -> features/playback/store/<x>
  store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
  utils/playback/<x>           -> features/playback/utils/playback/<x>
  utils/audio/<x>              -> features/playback/utils/audio/<x>

184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).

KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).

Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).

tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
This commit is contained in:
Psychotoxical
2026-06-30 15:00:20 +02:00
parent 6651abbc6f
commit cb1a110afb
393 changed files with 1127 additions and 1127 deletions
@@ -0,0 +1,240 @@
import { playbackReportStart } from '@/features/playback/store/playbackReportSession';
import { invoke } from '@tauri-apps/api/core';
import { getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
import { getPlaybackSourceKind } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import {
bumpPlayGeneration,
getPlayGeneration,
setIsAudioPaused,
} from '@/features/playback/store/engineState';
import { clearPreloadingIds } from '@/features/playback/store/gaplessPreloadState';
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
import type { PlayerState, QueueItemRef } from '@/features/playback/store/playerStoreTypes';
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
import { canonicalQueueServerKey } from '@/utils/server/serverIndexKey';
import { sameQueueTrackId } from '@/features/playback/utils/playback/queueIdentity';
import { queueUndoRestoreAudioEngine } from '@/features/playback/store/queueUndoAudioRestore';
import {
setPendingQueueListScrollTop,
type QueueUndoSnapshot,
} from '@/features/playback/store/queueUndo';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
import { stopRadio } from '@/features/playback/store/radioPlayer';
import { clearAllPlaybackScheduleTimers } from '@/features/playback/store/scheduleTimers';
import { syncUserQueueMutationToServer } from '@/features/playback/store/queueSync';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
type GetState = () => PlayerState;
/**
* Apply a queue-undo snapshot to the player store and resync the Rust
* audio engine where needed. Used by both `undoLastQueueEdit` and
* `redoLastQueueEdit` actions inside the store.
*
* Behaviour matrix:
* - **Snapshot has no current track but playback is live** — keep the
* playing track and prepend it to the restored queue (or rebind by
* id when it's already there).
* - **Snapshot's current track matches the live one** — keep playback
* going, restore queue + position only.
* - **Snapshot's current track differs** — issue a full audio_play via
* `queueUndoRestoreAudioEngine` to put the engine on the snapshot
* track at the captured position.
*
* Returns false only when nothing changed (caller shows no toast).
*/
export function applyQueueHistorySnapshot(
snap: QueueUndoSnapshot,
prior: PlayerState,
set: SetState,
get: GetState,
): boolean {
if (prior.currentRadio) {
stopRadio();
}
// Rebuild the display queue from the snapshot's thin refs (thin-state):
// resolver cache → placeholder. The canonical queue is the snapshot's refs;
// this resolved `nextQueue` is only for the engine restore / normalization /
// prepend logic below. The playing track is restored separately from the full
// `snap.currentTrack`.
let nextQueue = snap.queueItems.map(ref => resolveQueueTrack(ref));
let nextItems: QueueItemRef[] = [...snap.queueItems];
let nextIndex = snap.queueIndex;
let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null;
if (snap.currentTrack == null && prior.currentTrack) {
const playing = prior.currentTrack;
const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id));
if (pos === -1) {
// Prepend ref must bind to the *snapshot's* playback server (H3): a live
// server switch racing the undo would otherwise stamp the prepended ref
// with the new server, mis-resolving the still-playing track. Snapshot
// fields take precedence; existing refs in the snapshot are the next
// fallback (they share the snapshot's server); live `queueServerId` is
// last resort. Canonical key everywhere (B1).
const snapshotSid =
snap.queueServerId
?? snap.queueItems[0]?.serverId
?? get().queueServerId
?? '';
const prependServerId = canonicalQueueServerKey(snapshotSid);
nextQueue = [{ ...playing }, ...nextQueue];
nextItems = [
{ serverId: prependServerId, trackId: playing.id },
...nextItems,
];
nextIndex = 0;
nextTrack = { ...playing };
} else {
nextTrack = { ...playing };
nextIndex = pos;
}
}
nextIndex = Math.max(0, Math.min(nextIndex, Math.max(0, nextQueue.length - 1)));
const keepPlaybackFromPrior =
prior.currentTrack != null
&& nextTrack != null
&& sameQueueTrackId(prior.currentTrack.id, nextTrack.id)
&& nextQueue.some(t => sameQueueTrackId(t.id, prior.currentTrack!.id))
&& (
(snap.currentTrack != null && sameQueueTrackId(prior.currentTrack.id, snap.currentTrack.id))
|| snap.currentTrack == null
);
if (keepPlaybackFromPrior) {
const playingKeep = prior.currentTrack;
if (playingKeep) {
const idxPrior = nextQueue.findIndex(t => sameQueueTrackId(t.id, playingKeep.id));
if (idxPrior >= 0) {
nextIndex = idxPrior;
nextTrack = { ...playingKeep };
}
}
}
let tRestoreRaw = typeof snap.currentTime === 'number' && Number.isFinite(snap.currentTime)
? snap.currentTime
: 0;
let playingRestore = snap.isPlaying !== false;
if (keepPlaybackFromPrior && prior.currentTrack) {
tRestoreRaw = prior.currentTime;
playingRestore = prior.isPlaying;
}
const durForProgress = nextTrack?.duration && nextTrack.duration > 0 ? nextTrack.duration : null;
let pRestore = typeof snap.progress === 'number' && Number.isFinite(snap.progress)
? snap.progress
: (durForProgress != null && durForProgress > 0
? Math.max(0, Math.min(1, tRestoreRaw / durForProgress))
: 0);
if (keepPlaybackFromPrior) {
pRestore = prior.progress;
}
const tRestore = durForProgress != null
? Math.max(0, Math.min(tRestoreRaw, durForProgress))
: Math.max(0, tRestoreRaw);
const keepWaveform =
prior.currentTrack?.id != null &&
nextTrack?.id != null &&
sameQueueTrackId(prior.currentTrack.id, nextTrack.id);
const norm =
nextTrack != null
? deriveNormalizationSnapshot(nextTrack, nextQueue, nextIndex)
: ({
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
} as Pick<
PlayerState,
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
>);
const playbackSid = getPlaybackServerId();
const playbackSourceUndo = nextTrack
? getPlaybackSourceKind(nextTrack.id, playbackSid, null)
: null;
const playbackSourceFinal = keepPlaybackFromPrior && prior.currentPlaybackSource != null
? prior.currentPlaybackSource
: playbackSourceUndo;
clearAllPlaybackScheduleTimers();
set({
scheduledPauseAtMs: null,
scheduledPauseStartMs: null,
scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
});
clearPreloadingIds();
let gen = getPlayGeneration();
const resyncEngine = Boolean(nextTrack) && !keepPlaybackFromPrior;
if (resyncEngine || !nextTrack) {
gen = bumpPlayGeneration();
if (resyncEngine) {
setIsAudioPaused(false);
}
}
// Seed the resolver with the playing track so its ref always resolves (it may
// have been prepended and not yet in the cache window). Same canonical key
// source as the prepend above — keeps cache bucket and ref serverId in lockstep
// even when a server switch races the undo.
const seedSid = canonicalQueueServerKey(
snap.queueServerId
?? snap.queueItems[0]?.serverId
?? get().queueServerId
?? '',
);
if (seedSid && nextTrack) seedQueueResolver(seedSid, [nextTrack]);
set({
queueItems: nextItems,
queueIndex: nextIndex,
currentTrack: nextTrack,
currentRadio: null,
currentTime: tRestore,
progress: pRestore,
isPlaying: playingRestore,
waveformBins: keepWaveform ? prior.waveformBins : null,
enginePreloadedTrackId: keepPlaybackFromPrior ? prior.enginePreloadedTrackId : null,
currentPlaybackSource: playbackSourceFinal,
...norm,
});
if (!nextTrack) {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
syncUserQueueMutationToServer(nextItems, null, 0);
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
}
return true;
}
void refreshWaveformForTrack(nextTrack.id);
void refreshLoudnessForTrack(nextTrack.id);
get().updateReplayGainForCurrentTrack();
if (!keepPlaybackFromPrior) {
playbackReportStart(nextTrack.id, getPlaybackServerId());
queueUndoRestoreAudioEngine({
generation: gen,
track: nextTrack,
queue: nextQueue,
queueIndex: nextIndex,
atSeconds: tRestore,
wantPlaying: playingRestore,
});
}
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
}
syncUserQueueMutationToServer(nextItems, nextTrack, tRestore);
return true;
}
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { QueueItemRef } from '@/features/playback/store/playerStoreTypes';
const getPlayQueueForServerMock = vi.fn();
vi.mock('@/lib/api/subsonicPlayQueue', () => ({
getPlayQueueForServer: (...args: unknown[]) => getPlayQueueForServerMock(...args),
}));
vi.mock('@/utils/server/serverLookup', () => ({
resolveServerIdForIndexKey: (id: string) => id,
}));
vi.mock('@/features/playback/utils/playback/songToTrack', () => ({
songToTrack: (s: { id: string }) => ({
id: s.id,
title: s.id,
artist: '',
album: '',
albumId: '',
duration: 60,
serverId: 'srv-a',
}),
}));
vi.mock('@/features/playback/store/pausedRestorePrepare', () => ({
preparePausedRestoreOnStartup: vi.fn(),
}));
vi.mock('@/features/playback/store/waveformRefresh', () => ({
refreshWaveformForTrack: vi.fn(),
}));
vi.mock('@/features/playback/store/queueSyncUiState', () => ({
clearQueueHandoffPending: vi.fn(),
}));
const playerState = {
queueItems: [] as QueueItemRef[],
queueIndex: 0,
currentTrack: null as { id: string; title: string; artist: string; album: string; albumId: string; duration: number } | null,
currentTime: 0,
isPlaying: false,
};
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: {
getState: () => playerState,
setState: (partial: Partial<typeof playerState>) => {
Object.assign(playerState, partial);
},
},
}));
import { applyServerPlayQueue } from '@/features/playback/store/applyServerPlayQueue';
import {
_resetQueuePlaybackIdleForTest,
getIdlePullGeneration,
isIdleQueuePullSuspended,
touchQueueMutationClock,
} from '@/features/playback/store/queuePlaybackIdle';
describe('applyServerPlayQueue idle guards', () => {
beforeEach(() => {
_resetQueuePlaybackIdleForTest();
getPlayQueueForServerMock.mockReset();
playerState.queueItems = [{ serverId: 'srv-a', trackId: 'local-only' }];
playerState.queueIndex = 0;
playerState.currentTrack = {
id: 'local-only',
title: 'local-only',
artist: '',
album: '',
albumId: '',
duration: 60,
};
playerState.currentTime = 12;
playerState.isPlaying = false;
});
it('does not apply server queue in idle mode while local edits suspend pull', async () => {
getPlayQueueForServerMock.mockResolvedValue({
songs: [{ id: 'remote-a' }, { id: 'remote-b' }],
current: 'remote-a',
position: 5000,
});
touchQueueMutationClock();
const result = await applyServerPlayQueue('srv-a', { mode: 'idle' });
expect(result).toBe('noop');
expect(getPlayQueueForServerMock).not.toHaveBeenCalled();
expect(playerState.queueItems).toEqual([{ serverId: 'srv-a', trackId: 'local-only' }]);
expect(isIdleQueuePullSuspended()).toBe(true);
});
it('ignores stale idle pull responses after a local mutation during fetch', async () => {
const generationAtFetch = getIdlePullGeneration();
getPlayQueueForServerMock.mockImplementation(async () => {
touchQueueMutationClock();
expect(getIdlePullGeneration()).toBe(generationAtFetch + 1);
return {
songs: [{ id: 'remote-a' }],
current: 'remote-a',
position: 0,
};
});
const result = await applyServerPlayQueue('srv-a', { mode: 'idle' });
expect(result).toBe('noop');
expect(playerState.queueItems).toEqual([{ serverId: 'srv-a', trackId: 'local-only' }]);
});
});
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
fingerprintFromLocalQueue,
fingerprintFromServer,
playQueueFingerprintsEqual,
} from '@/features/playback/store/applyServerPlayQueue';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { resetPlayerStore } from '@/test/helpers/storeReset';
describe('playQueueFingerprintsEqual', () => {
beforeEach(() => {
resetPlayerStore();
});
it('compares track order, current id, and position within tolerance', () => {
const a = { trackIds: ['1', '2'], currentId: '1', positionMs: 1000 };
const b = { trackIds: ['1', '2'], currentId: '1', positionMs: 2500 };
expect(playQueueFingerprintsEqual(a, b)).toBe(true);
expect(playQueueFingerprintsEqual(a, { ...b, positionMs: 4000 })).toBe(false);
});
it('fingerprintFromLocalQueue reads the player store', () => {
usePlayerStore.setState({
queueItems: [{ serverId: 'a.test', trackId: 't1' }],
currentTrack: { id: 't1', title: 'T', artist: '', album: 'A', albumId: 'al', duration: 60 },
currentTime: 3.5,
});
expect(fingerprintFromLocalQueue()).toEqual({
trackIds: ['t1'],
currentId: 't1',
positionMs: 3500,
});
});
it('fingerprintFromServer maps Subsonic playQueue fields', () => {
expect(fingerprintFromServer({
songs: [{ id: 'a' }, { id: 'b' }] as never,
current: 'b',
position: 1200,
})).toEqual({
trackIds: ['a', 'b'],
currentId: 'b',
positionMs: 1200,
});
});
});
@@ -0,0 +1,212 @@
import { getPlayQueueForServer, type PlayQueueResult } from '@/lib/api/subsonicPlayQueue';
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
import { bindQueueServerId } from '@/features/playback/utils/playback/playbackServer';
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
import type { Track } from '@/features/playback/store/playerStoreTypes';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { preparePausedRestoreOnStartup } from '@/features/playback/store/pausedRestorePrepare';
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
import {
getIdlePullGeneration,
isIdleQueuePullSuspended,
resumeIdleQueuePull,
clearQueueNaturallyEnded,
} from '@/features/playback/store/queuePlaybackIdle';
import { clearQueueHandoffPending } from '@/features/playback/store/queueSyncUiState';
export type ApplyPlayQueueMode = 'startup' | 'idle' | 'manual';
export type PlayQueueFingerprint = {
trackIds: string[];
currentId: string | null;
positionMs: number;
};
export type ApplyPlayQueueResult = 'applied' | 'noop' | 'empty' | 'error';
const POSITION_TOLERANCE_MS = 2000;
export function fingerprintFromServer(q: PlayQueueResult): PlayQueueFingerprint {
const trackIds = q.songs.map(s => s.id);
const currentId = q.current ?? trackIds[0] ?? null;
return {
trackIds,
currentId,
positionMs: q.position ?? 0,
};
}
export function fingerprintFromLocalQueue(): PlayQueueFingerprint {
const s = usePlayerStore.getState();
return {
trackIds: s.queueItems.map(r => r.trackId),
currentId: s.currentTrack?.id ?? null,
positionMs: Math.floor((s.currentTime ?? 0) * 1000),
};
}
export function playQueueFingerprintsEqual(
a: PlayQueueFingerprint,
b: PlayQueueFingerprint,
positionToleranceMs = POSITION_TOLERANCE_MS,
): boolean {
if (a.currentId !== b.currentId) return false;
if (a.trackIds.length !== b.trackIds.length) return false;
for (let i = 0; i < a.trackIds.length; i++) {
if (a.trackIds[i] !== b.trackIds[i]) return false;
}
return Math.abs(a.positionMs - b.positionMs) <= positionToleranceMs;
}
function resolveServerProfileId(serverId: string): string {
return resolveServerIdForIndexKey(serverId) || serverId;
}
function applyMappedQueue(
mappedTracks: Track[],
q: PlayQueueResult,
serverProfileId: string,
preferServerPosition: boolean,
localTimeFallback: number,
): void {
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) {
currentTrack = mappedTracks[idx];
queueIndex = idx;
}
}
const serverTime = q.position ? q.position / 1000 : 0;
const atSeconds = preferServerPosition
? serverTime
: (serverTime > 0 ? serverTime : localTimeFallback);
seedQueueResolver(serverProfileId, mappedTracks);
bindQueueServerId(serverProfileId);
const queueItems = toQueueItemRefs(serverProfileId, mappedTracks);
const player = usePlayerStore.getState();
const wasPlaying = player.isPlaying;
const sameCurrent = player.currentTrack?.id === currentTrack.id;
usePlayerStore.setState({
queueItems,
queueIndex,
currentTrack,
currentTime: atSeconds,
});
void refreshWaveformForTrack(currentTrack.id);
if (wasPlaying) {
if (!sameCurrent) {
player.playTrack(currentTrack, mappedTracks, true, false, queueIndex);
if (atSeconds > 0.05) {
player.seek(atSeconds / Math.max(currentTrack.duration, 1));
}
} else if (atSeconds > 0.05 && Math.abs(player.currentTime - atSeconds) > 0.5) {
player.seek(atSeconds / Math.max(currentTrack.duration, 1));
}
return;
}
preparePausedRestoreOnStartup(currentTrack, queueItems, queueIndex, atSeconds);
}
export async function applyServerPlayQueue(
serverId: string,
options: {
mode: ApplyPlayQueueMode;
preferServerPosition?: boolean;
pushUndo?: boolean;
},
): Promise<ApplyPlayQueueResult> {
const profileId = resolveServerProfileId(serverId);
if (!profileId) return 'error';
if (options.mode === 'idle' && isIdleQueuePullSuspended()) {
return 'noop';
}
const idleGenerationAtStart = options.mode === 'idle' ? getIdlePullGeneration() : null;
try {
const q = await getPlayQueueForServer(profileId);
if (q.songs.length === 0) return 'empty';
const preferServerPosition = options.preferServerPosition ?? options.mode !== 'startup';
if (options.mode === 'idle') {
if (isIdleQueuePullSuspended()) return 'noop';
if (idleGenerationAtStart !== getIdlePullGeneration()) return 'noop';
const serverFp = fingerprintFromServer(q);
const localFp = fingerprintFromLocalQueue();
if (playQueueFingerprintsEqual(serverFp, localFp)) return 'noop';
}
if (options.pushUndo) {
pushQueueUndoFromGetter(usePlayerStore.getState);
}
const mappedTracks: Track[] = q.songs.map(songToTrack);
const localTime = usePlayerStore.getState().currentTime;
applyMappedQueue(mappedTracks, q, profileId, preferServerPosition, localTime);
clearQueueHandoffPending();
return 'applied';
} catch (e) {
console.error('[psysonic] applyServerPlayQueue failed', e);
return 'error';
}
}
export async function fetchActiveServerPlayQueueFingerprint(): Promise<PlayQueueFingerprint | null> {
const activeId = useAuthStore.getState().activeServerId;
if (!activeId) return null;
try {
const q = await getPlayQueueForServer(activeId);
if (q.songs.length === 0) return null;
return fingerprintFromServer(q);
} catch {
return null;
}
}
export async function pullPlayQueueFromActiveServer(): Promise<ApplyPlayQueueResult> {
const activeId = useAuthStore.getState().activeServerId;
if (!activeId) return 'error';
clearQueueNaturallyEnded();
try {
const q = await getPlayQueueForServer(activeId);
if (q.songs.length === 0) {
resumeIdleQueuePull();
return 'empty';
}
const serverFp = fingerprintFromServer(q);
const localFp = fingerprintFromLocalQueue();
if (playQueueFingerprintsEqual(serverFp, localFp)) {
resumeIdleQueuePull();
return 'noop';
}
const result = await applyServerPlayQueue(activeId, {
mode: 'manual',
preferServerPosition: true,
pushUndo: true,
});
if (result === 'applied' || result === 'noop') {
resumeIdleQueuePull();
}
return result;
} catch (e) {
console.error('[psysonic] pullPlayQueueFromActiveServer failed', e);
return 'error';
}
}
@@ -0,0 +1,649 @@
import { scrobbleSong } from '@/lib/api/subsonicScrobble';
import type { Track } from '@/features/playback/store/playerStoreTypes';
import {
playbackReportPlaying,
playbackReportStart,
playbackReportStopped,
} from '@/features/playback/store/playbackReportSession';
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
import { invoke } from '@tauri-apps/api/core';
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
import { setDeferHotCachePrefetch } from '@/utils/cache/hotCacheGate';
import { notifyLibraryPlaybackHint } from '@/store/libraryPlaybackHint';
import {
playListenSessionFinalize,
playListenSessionOnProgress,
playListenSessionOnTrackSwitched,
playListenSessionOpen,
} from '@/features/playback/store/playListenSession';
import { appendTimelineLeaveTrack } from '@/features/playback/store/timelineSessionHistory';
import { getPerfProbeFlags } from '@/utils/perf/perfFlags';
import { bumpPerfCounter } from '@/utils/perf/perfTelemetry';
import {
getPlaybackCacheServerKey,
getPlaybackIndexKey,
playbackCacheKeyForRef,
playbackProfileIdForRef,
playbackProfileIdForTrack,
} from '@/features/playback/utils/playback/playbackServer';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
import { audioPlayHiResBlendArgs } from '@/utils/audio/hiResCrossfadeResample';
import { showToast } from '@/utils/ui/toast';
import { useAuthStore } from '@/store/authStore';
import { getPlayGeneration, setIsAudioPaused } from '@/features/playback/store/engineState';
import {
clearPreloadingIds,
getBytePreloadingId,
getGaplessPreloadingId,
getLastGaplessSwitchTime,
markGaplessSwitch,
setBytePreloadingId,
setGaplessPreloadingId,
} from '@/features/playback/store/gaplessPreloadState';
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
import {
isReplayGainActive,
loudnessGainDbForEngineBind,
} from '@/features/playback/store/loudnessGainCache';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
import {
emitPlaybackProgress,
getPlaybackProgressSnapshot,
} from '@/features/playback/store/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 '@/features/playback/store/playbackThrottles';
import {
playbackSourceHintForResolvedUrl,
} from '@/features/playback/store/playbackUrlRouting';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
import {
flushQueueSyncToServer,
getLastQueueHeartbeatAt,
syncQueueToServer,
} from '@/features/playback/store/queueSync';
import { clearQueueNaturallyEnded } from '@/features/playback/store/queuePlaybackIdle';
import { isSeekDebouncePending } from '@/features/playback/store/seekDebounce';
import {
SEEK_FALLBACK_VISUAL_GUARD_MS,
getSeekFallbackVisualTarget,
setSeekFallbackVisualTarget,
} from '@/features/playback/store/seekFallbackState';
import {
SEEK_TARGET_GUARD_TIMEOUT_MS,
clearSeekTarget,
getSeekTarget,
getSeekTargetSetAt,
} from '@/features/playback/store/seekTargetState';
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
import { analyzeBoundary, computeWaveformSilence } from '@/utils/waveform/waveformSilence';
import { autodjMaxOverlapCapSec } from '@/utils/playback/autodjOverlapCap';
import {
autodjJsTriggerAtSec,
clampCrossfadeSecs,
computeAutodjJsOverlap,
nextQueueRefForTransition,
shouldJsDriveAutodjTransition,
} from '@/features/playback/utils/playback/autodjAutoAdvance';
import { isInterruptHandoffPending } from '@/features/playback/utils/playback/autodjInterruptPrep';
import { isCrossfadeNextReady, maybeCrossfadeBytePreload } from '@/features/playback/store/crossfadePreload';
import { armCrossfadeDynamicOverlap, getCrossfadeTransition } from '@/features/playback/store/crossfadeTrimCache';
import { armAutodjMixing } from '@/features/playback/store/autodjTransitionUi';
// Silence-aware crossfade (A-tail): guards the early advance to once per play
// generation so a single playback instance triggers at most one trim-advance
// (re-arms automatically on the next play / repeat-all loop, never loops on a
// backward seek within the same playback).
let crossfadeTrimAdvanceGen = -1;
let autodjEngineMixArmGen = -1;
// AutoDJ: mirror of the engine's `autodj_suppress_autocrossfade` flag so we only
// invoke the setter on change. When a content fade is pending for the upcoming
// transition we suppress the engine's autonomous crossfade timer and let the JS
// A-tail advance drive it (gated on the next track being playable) — otherwise
// the engine would start a still-buffering next track and fade over it (a jump).
let autodjSuppressSent: boolean | null = null;
function syncAutodjSuppress(want: boolean): void {
if (autodjSuppressSent === want) return;
autodjSuppressSent = want;
invoke('audio_set_autodj_suppress', { enabled: want }).catch(() => {});
}
/** 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 {
clearQueueNaturallyEnded();
setDeferHotCachePrefetch(false);
resetProgressEmitThrottles();
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
notifyLibraryPlaybackHint('playing');
const { currentTrack: track, queueItems, queueIndex } = usePlayerStore.getState();
if (track) {
const ref = queueItems[queueIndex];
void playListenSessionOpen(track, playbackProfileIdForTrack(track, ref), duration);
// Engine-confirmed play (initial start + resume) — keep live now-playing in
// the `playing` state for servers with the playbackReport extension.
playbackReportPlaying();
}
}
export function handleAudioProgress(
current_time: number,
duration: number,
buffering = false,
): 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 12 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;
if (!store.currentRadio && store.isPlaybackBuffering !== buffering) {
usePlayerStore.setState({ isPlaybackBuffering: buffering });
}
// 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 = buffering ? 0 : 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;
playListenSessionOnProgress(current_time, buffering, dur).catch(() => {});
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: buffering ? 0 : progress,
buffered: 0,
buffering,
});
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.queueItems, track, displayTime);
// Same 15 s cadence keeps the server's now-playing position fresh so it
// can extrapolate accurately between reports (playbackReport extension).
playbackReportPlaying(displayTime);
}
}
// Scrobble at 50%: Music Network + Navidrome (updates play_date / recently played)
if (progress >= 0.5 && !store.scrobbled) {
usePlayerStore.setState({ scrobbled: true });
scrobbleSong(
track.id,
Date.now(),
playbackProfileIdForTrack(track, store.queueItems[store.queueIndex]),
);
void getMusicNetworkRuntimeOrNull()?.dispatchScrobble({
title: track.title,
artist: track.artist,
album: track.album,
duration: track.duration,
timestamp: Date.now(),
});
}
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 for gapless and crossfade.
const {
gaplessEnabled,
crossfadeEnabled,
crossfadeSecs,
crossfadeTrimSilence,
} = useAuthStore.getState();
const remaining = dur - current_time;
// Silence-aware crossfade — current track's trailing silence, derived once
// from its cached waveform. Drives both the early A-tail advance AND a wider
// pre-buffer window (the early advance must not outrun the next track's
// download, or its stream starts late and the fade has nothing to overlap).
const trimActive =
crossfadeEnabled && crossfadeTrimSilence && !gaplessEnabled && !store.currentRadio;
const curTrailSilenceSec = trimActive
? computeWaveformSilence(store.waveformBins, dur).trailSilenceSec
: 0;
// A-tail: start the next track early so the fade overlaps *audible* tail/head.
// Overlap is content-driven ("by fact"); loud→loud always uses the standard
// ~2 s JS blend (not the engine crossfadeSecs slider). When JS drives we
// suppress the engine's autonomous crossfade timer so B is readiness-gated.
let autodjSuppressWant = false;
const nextRef = trimActive && store.isPlaying && store.repeatMode !== 'one'
? nextQueueRefForTransition(store.queueItems, store.queueIndex, store.repeatMode)
: null;
const nextTrackId = nextRef ? resolveQueueTrack(nextRef)?.id : undefined;
if (trimActive && store.isPlaying && store.repeatMode !== 'one') {
if (nextTrackId) {
const cf = clampCrossfadeSecs(crossfadeSecs);
const plan = getCrossfadeTransition(nextTrackId);
let contentOverlap: number;
// Scenario A: does A carry its own recorded fade-out? If so we let it ride
// at full engine gain (no double fade) and bring B up underneath.
let aRidesOwnFade: boolean;
if (plan && plan.overlapSec > 0) {
contentOverlap = plan.overlapSec;
aRidesOwnFade = plan.outgoingFadeSec <= 0.001;
} else {
// No next-track envelope (cold plan) → judge A from its own waveform.
const aShape = analyzeBoundary(store.waveformBins, dur);
contentOverlap = aShape.outroFadeSec;
aRidesOwnFade = aShape.outroFadeSec >= 1.0;
}
if (shouldJsDriveAutodjTransition(curTrailSilenceSec, contentOverlap, cf, aRidesOwnFade)) {
autodjSuppressWant = true;
const maxCapSec = autodjMaxOverlapCapSec(useAuthStore.getState());
const { overlapSec, outgoingFadeSec } = computeAutodjJsOverlap(contentOverlap, aRidesOwnFade, maxCapSec);
const triggerAt = autodjJsTriggerAtSec(dur, curTrailSilenceSec, overlapSec);
const gen = getPlayGeneration();
// Readiness gate: only advance when B's audio is actually available (RAM
// preload slot or local on disk). A cold stream can't sustain a stable
// fade, so we leave the gen guard unset and re-check on later ticks — if
// B readies before A ends we fade then; if never, A plays out (engine
// timer suppressed) and the source-exhaustion end gives a clean cut.
if (
current_time >= triggerAt
&& crossfadeTrimAdvanceGen !== gen
&& isCrossfadeNextReady(
nextTrackId,
playbackProfileIdForRef(nextRef),
playbackCacheKeyForRef(nextRef),
)
) {
crossfadeTrimAdvanceGen = gen;
armCrossfadeDynamicOverlap(nextTrackId, overlapSec, outgoingFadeSec);
armAutodjMixing(overlapSec);
store.next(false);
return;
}
}
} else {
// Queue tail with no successor — play A through its real ending; suppress
// the engine's early crossfade timer so `audio:ended` fires on exhaustion.
autodjSuppressWant = true;
}
}
syncAutodjSuppress(autodjSuppressWant);
if (trimActive && store.isPlaying && !autodjSuppressWant && nextTrackId) {
const cf = clampCrossfadeSecs(crossfadeSecs);
if (remaining > 0 && remaining <= cf) {
const gen = getPlayGeneration();
if (autodjEngineMixArmGen !== gen) {
autodjEngineMixArmGen = gen;
armAutodjMixing(cf);
}
}
}
// Crossfade pre-buffer (next-track byte download + leading-silence probe).
// Self-gating; also invoked right after a seek into the window (see seekAction).
maybeCrossfadeBytePreload(current_time, dur);
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
if (gaplessEnabled) {
const { queueItems, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
// Next track for preload/chain. The resolver bridge keeps the window around
// queueIndex warm, so the next ref is cache-hot; resolveQueueTrack falls
// back to a placeholder (correct trackId, so URL building still works) on a
// cold miss. current track = `track` (full) — never resolved.
const nextRef = repeatMode === 'one'
? null
: (nextIdx < queueItems.length ? queueItems[nextIdx] : (repeatMode === 'all' ? queueItems[0] : null));
const nextTrack = repeatMode === 'one'
? track
: (nextRef ? resolveQueueTrack(nextRef) : 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 = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackCacheServerKey();
const analysisServerId = nextRef
? playbackCacheKeyForRef(nextRef)
: getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
if (
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,
shouldBytePreloadForGaplessBackup,
remaining,
gaplessEnabled,
});
}
invoke('audio_preload', {
url: nextUrl,
durationHint: nextTrack.duration,
analysisTrackId: nextTrack.id,
serverId: analysisServerId || null,
}).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,
// queueItems[nextIdx+1] on its right (resolved; placeholder on a cold miss
// — only its replaygain tags matter, which a placeholder lacks → fallback).
const nextNeighbourRef = nextIdx + 1 < queueItems.length
? queueItems[nextIdx + 1]
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
const nextNeighbour = nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : 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,
...audioPlayHiResBlendArgs(authState),
analysisTrackId: nextTrack.id,
serverId: analysisServerId || null,
}).catch(() => {});
}
}
}
export function handleAudioEnded(): void {
notifyLibraryPlaybackHint('idle');
if (Date.now() - getLastGaplessSwitchTime() < 600) {
return;
}
if (isInterruptHandoffPending()) {
return;
}
void playListenSessionFinalize('ended');
// Track finished — clear live now-playing. A follow-on track (next / repeat)
// opens a fresh session via playbackReportStart.
void playbackReportStopped();
const storeBeforeAdvance = usePlayerStore.getState();
if (storeBeforeAdvance.currentTrack && !storeBeforeAdvance.currentRadio) {
appendTimelineLeaveTrack(
storeBeforeAdvance.currentTrack,
storeBeforeAdvance.queueItems,
storeBeforeAdvance.queueIndex,
);
}
// 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, queueIndex } = usePlayerStore.getState();
setIsAudioPaused(false);
usePlayerStore.setState({
isPlaying: false,
isPlaybackBuffering: false,
progress: 0,
currentTime: 0,
buffered: 0,
});
setTimeout(() => {
void (async () => {
if (repeatMode === 'one' && currentTrack) {
const authState = useAuthStore.getState();
const repeatPromoteSid = getPlaybackCacheServerKey();
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.
// No-arg queue: playTrack keeps the canonical refs and just re-binds.
usePlayerStore.getState().playTrack(currentTrack, undefined, 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 { queueItems, queueIndex, repeatMode, currentTrack, currentRadio } = store;
if (currentTrack && !currentRadio) {
appendTimelineLeaveTrack(currentTrack, queueItems, queueIndex);
}
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 < queueItems.length) {
// The Rust engine already chained this source sample-accurately, so it must
// have been preloaded — meaning the resolver had it cached. resolveQueueTrack
// returns the full Track from cache (placeholder only on an unexpected miss).
nextTrack = resolveQueueTrack(queueItems[nextIdx]);
newIndex = nextIdx;
} else if (repeatMode === 'all' && queueItems.length > 0) {
nextTrack = resolveQueueTrack(queueItems[0]);
newIndex = 0;
}
if (!nextTrack) return;
void playListenSessionOnTrackSwitched(nextTrack);
const switchRef = queueItems[newIndex];
const switchServerId = playbackCacheKeyForRef(switchRef);
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
// Neighbour window for normalization (replaygain album-mode reads prev/next).
// current track on the left, the track after `nextTrack` on the right.
const switchPrev = store.currentTrack;
const switchNextNextRef = newIndex + 1 < queueItems.length ? queueItems[newIndex + 1] : null;
const switchNeighbourWindow: Track[] = [
switchPrev ?? nextTrack,
nextTrack,
...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
];
usePlayerStore.setState({
currentTrack: nextTrack,
waveformBins: null,
...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
normalizationDbgSource: 'track-switched',
normalizationDbgTrackId: nextTrack.id,
queueIndex: newIndex,
isPlaying: true,
isPlaybackBuffering: switchPlaybackSource === 'stream',
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: false,
networkLoved: 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();
// Subsonic-server now-playing follows nowPlayingEnabled; Music Network
// now-playing follows scrobbling, as Last.fm now-playing did (the runtime gates
// on the master toggle, per-account enable and the nowPlaying capability
// internally). playbackReportStart opens the FSM on extension-capable servers
// and falls back to the legacy presence call otherwise (gating is internal).
playbackReportStart(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
const runtime = getMusicNetworkRuntimeOrNull();
void runtime?.dispatchNowPlaying({
title: nextTrack.title,
artist: nextTrack.artist,
album: nextTrack.album,
duration: nextTrack.duration,
timestamp: Date.now(),
});
if (runtime?.getEnrichmentPrimaryId()) {
void runtime
.isTrackLoved({ title: nextTrack.title, artist: nextTrack.artist })
.then(loved => {
usePlayerStore.getState().setNetworkLoved(loved);
});
}
syncQueueToServer(queueItems, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, switchServerId);
}
export function handleAudioError(message: string): void {
console.error('[psysonic] Audio error from backend:', message);
setIsAudioPaused(false);
void playbackReportStopped();
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, isPlaybackBuffering: false });
setTimeout(() => {
if (getPlayGeneration() !== gen) return;
usePlayerStore.getState().next(false);
}, 1500);
}
@@ -0,0 +1,193 @@
import { listen } from '@tauri-apps/api/event';
import { streamUrlTrackId } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { normalizationAlmostEqual } from '@/features/playback/utils/audio/normalizationCompare';
import { normalizeAnalysisTrackId } from '@/features/playback/utils/playback/queueIdentity';
import {
handleAudioEnded,
handleAudioError,
handleAudioPlaying,
handleAudioProgress,
handleAudioTrackSwitched,
type NormalizationStatePayload,
} from '@/features/playback/store/audioEventHandlers';
import {
getCachedLoudnessGain,
hasStableLoudness,
setCachedLoudnessGain,
} from '@/features/playback/store/loudnessGainCache';
import { isTrackInsideLoudnessBackfillWindow } from '@/features/playback/store/loudnessBackfillWindow';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
import {
NORMALIZATION_UI_THROTTLE_MS,
getLastNormalizationUiUpdateAtMs,
markNormalizationUiUpdate,
} from '@/features/playback/store/playbackThrottles';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
import { bumpWaveformRefreshGen } from '@/features/playback/store/waveformRefreshGen';
import { setBytePreloadingId } from '@/features/playback/store/gaplessPreloadState';
type PreloadEventPayload = {
url: string;
trackId?: string | null;
};
/**
* Tauri event listeners for the Rust audio engine + analysis pipeline. Returns
* a cleanup function that unlistens every registered listener.
*/
export function setupAudioEngineListeners(): () => void {
// Dev-only: warn when audio:progress events arrive faster than 10/s.
// This would indicate the Rust emit interval was accidentally lowered.
let _devEventCount = 0;
let _devWindowStart = 0;
const pending = [
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
listen<{ current_time: number; duration: number; buffering?: boolean }>('audio:progress', ({ payload }) => {
if (import.meta.env.DEV) {
_devEventCount++;
const now = Date.now();
if (_devWindowStart === 0) _devWindowStart = now;
if (now - _devWindowStart >= 1000) {
if (_devEventCount > 10) {
console.warn(`[psysonic] audio:progress: ${_devEventCount} events/s (threshold: 10) — check Rust emit interval`);
}
_devEventCount = 0;
_devWindowStart = now;
}
}
handleAudioProgress(payload.current_time, payload.duration, payload.buffering ?? false);
}),
listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => {
const current = usePlayerStore.getState().currentTrack;
if (!current || !payload) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (payloadTrackId && payloadTrackId !== current.id) return;
if (!Number.isFinite(payload.gainDb)) return;
if (hasStableLoudness(current.id)) return;
// Skip when the cached gain is already within ~0.05 dB of the new payload —
// float jitter from the partial-loudness heuristic would otherwise re-trigger
// updateReplayGainForCurrentTrack → audio_update_replay_gain → backend echo
// every PARTIAL_LOUDNESS_EMIT_INTERVAL_MS even when nothing audibly changed.
const existing = getCachedLoudnessGain(current.id);
if (Number.isFinite(existing) && Math.abs(existing! - payload.gainDb) < 0.05) return;
setCachedLoudnessGain(current.id, payload.gainDb);
emitNormalizationDebug('partial-loudness:apply', {
trackId: current.id,
gainDb: payload.gainDb,
targetLufs: payload.targetLufs,
});
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}),
listen<{ trackId: string; isPartial: boolean }>('analysis:waveform-updated', ({ payload }) => {
if (!payload?.trackId) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (!payloadTrackId) return;
const currentRaw = usePlayerStore.getState().currentTrack?.id;
const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null;
if (currentId && payloadTrackId === currentId) {
bumpWaveformRefreshGen(currentRaw!);
void refreshWaveformForTrack(currentRaw!);
void refreshLoudnessForTrack(currentId);
emitNormalizationDebug('backfill:applied', { trackId: currentId });
return;
}
// Library aggressive backfill completes thousands of tracks — only warm loudness
// for the playback window (current + next N). Skip IPC for the rest.
const live = usePlayerStore.getState();
if (
!isTrackInsideLoudnessBackfillWindow(
payloadTrackId,
live.queueItems,
live.queueIndex,
live.currentTrack,
)
) {
return;
}
void refreshLoudnessForTrack(payloadTrackId, { syncPlayingEngine: false });
emitNormalizationDebug('backfill:applied', { trackId: payloadTrackId });
}),
listen<{ trackId: string; serverId: string }>('analysis:enrichment-updated', ({ payload }) => {
if (!payload?.trackId) return;
emitNormalizationDebug('enrichment:applied', {
trackId: normalizeAnalysisTrackId(payload.trackId) ?? payload.trackId,
serverId: payload.serverId,
});
}),
listen<NormalizationStatePayload>('audio:normalization-state', ({ payload }) => {
if (!payload) return;
const engine =
payload.engine === 'loudness' || payload.engine === 'replaygain'
? payload.engine
: 'off';
const nowDb = Number.isFinite(payload.currentGainDb as number) ? (payload.currentGainDb as number) : null;
const targetLufs = Number.isFinite(payload.targetLufs) ? payload.targetLufs : null;
const prev = usePlayerStore.getState();
// Avoid UI flicker from noisy duplicate emits and transient nulls.
if (
engine === prev.normalizationEngineLive
&& normalizationAlmostEqual(nowDb, prev.normalizationNowDb)
&& normalizationAlmostEqual(targetLufs, prev.normalizationTargetLufs, 0.02)
) {
return;
}
if (engine === 'loudness' && nowDb == null && prev.normalizationNowDb != null) {
return;
}
const nowMs = Date.now();
const isFirstNumericGain =
engine === 'loudness'
&& nowDb != null
&& prev.normalizationNowDb == null;
if (
!isFirstNumericGain
&& nowMs - getLastNormalizationUiUpdateAtMs() < NORMALIZATION_UI_THROTTLE_MS
&& engine === prev.normalizationEngineLive
) {
return;
}
markNormalizationUiUpdate(nowMs);
emitNormalizationDebug('event:audio:normalization-state', {
trackId: usePlayerStore.getState().currentTrack?.id ?? null,
payload,
});
usePlayerStore.setState({
normalizationEngineLive: engine,
normalizationNowDb: nowDb,
normalizationTargetLufs: targetLufs,
normalizationDbgSource: 'event:audio:normalization-state',
normalizationDbgLastEventAt: Date.now(),
});
}),
listen<PreloadEventPayload>('audio:preload-ready', ({ payload }) => {
const tid = payload.trackId ?? streamUrlTrackId(payload.url);
if (import.meta.env.DEV) {
console.info('[psysonic][preload-ready]', {
payload,
parsedTrackId: tid,
prevEnginePreloadedTrackId: usePlayerStore.getState().enginePreloadedTrackId,
});
}
if (tid) usePlayerStore.setState({ enginePreloadedTrackId: tid });
else if (import.meta.env.DEV) {
console.warn('[psysonic][preload-ready] could not parse track id from payload URL');
}
}),
listen<PreloadEventPayload>('audio:preload-cancelled', ({ payload }) => {
if (import.meta.env.DEV) {
console.info('[psysonic][preload-cancelled]', payload);
}
setBytePreloadingId(null);
}),
];
return () => {
pending.forEach(p => p.then(unlisten => unlisten()));
};
}
@@ -0,0 +1,95 @@
import { invoke } from '@tauri-apps/api/core';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/utils/audio/loudnessPreAnalysisSlider';
import { useAuthStore } from '@/store/authStore';
import { onAnalysisStorageChanged } from '@/store/analysisSync';
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
import { invokeAudioSetNormalizationDeduped } from '@/features/playback/store/normalizationIpcDedupe';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { clearLoudnessCacheStateForTrackId } from '@/features/playback/store/loudnessGainCache';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
import { bumpWaveformRefreshGen } from '@/features/playback/store/waveformRefreshGen';
/**
* Keeps the Rust audio engine in sync whenever the auth store changes
* (crossfade / gapless / normalization), plus a cross-tab analysis-storage
* listener that refreshes waveform + loudness for the current track. Returns a
* cleanup function.
*/
export function setupAuthSync(): () => void {
const normCfg = useAuthStore.getState();
let prevNormEngine = normCfg.normalizationEngine;
let prevNormTarget = normCfg.loudnessTargetLufs;
let prevPreAnalysis = normCfg.loudnessPreAnalysisAttenuationDb;
const unsubAuth = useAuthStore.subscribe((state) => {
invoke('audio_set_crossfade', {
enabled: state.crossfadeEnabled,
secs: state.crossfadeSecs,
}).catch(() => {});
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
const normChanged =
state.normalizationEngine !== prevNormEngine
|| state.loudnessTargetLufs !== prevNormTarget
|| state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
if (!normChanged) return;
const onlyPreAnalysisChanged =
state.normalizationEngine === prevNormEngine
&& state.loudnessTargetLufs === prevNormTarget
&& state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
const targetLufsChanged =
state.normalizationEngine === 'loudness'
&& state.loudnessTargetLufs !== prevNormTarget;
prevNormEngine = state.normalizationEngine;
prevNormTarget = state.loudnessTargetLufs;
prevPreAnalysis = state.loudnessPreAnalysisAttenuationDb;
usePlayerStore.setState({
normalizationEngineLive: state.normalizationEngine,
normalizationTargetLufs: state.normalizationEngine === 'loudness' ? state.loudnessTargetLufs : null,
normalizationNowDb: state.normalizationEngine === 'loudness'
? usePlayerStore.getState().normalizationNowDb
: null,
normalizationDbgSource: 'auth:normalization-changed',
});
emitNormalizationDebug('auth:normalization-changed', {
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
});
invokeAudioSetNormalizationDeduped({
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb(
state.loudnessPreAnalysisAttenuationDb,
state.loudnessTargetLufs,
),
});
if (state.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (onlyPreAnalysisChanged) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
} else if (currentId) {
if (targetLufsChanged) {
clearLoudnessCacheStateForTrackId(currentId);
}
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
}
} else {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
});
const unsubAnalysisSync = onAnalysisStorageChanged(detail => {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (!currentId) return;
if (detail.trackId && detail.trackId !== currentId) return;
bumpWaveformRefreshGen(currentId);
void refreshWaveformForTrack(currentId);
void refreshLoudnessForTrack(currentId);
});
return () => {
unsubAuth();
unsubAnalysisSync();
};
}
@@ -0,0 +1,125 @@
import { invoke } from '@tauri-apps/api/core';
import { resolvePlaybackCoverScope } from '@/cover/ref';
import { resolveTrackCoverRefFromLibrary } from '@/cover/resolveEntryLibrary';
import { coverArtUrlForDiscord } from '@/cover/integrations/discord';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
/**
* Discord Rich Presence sync. Updates on track change or play/pause toggle —
* no per-tick updates needed, Discord auto-counts up the elapsed timer from the
* start_timestamp we set. Returns a cleanup function.
*/
export function setupDiscordPresence(): () => void {
let discordPrevTrackId: string | null = null;
let discordPrevIsPlaying: boolean | null = null;
let discordPrevTemplateDetails: string | null = null;
let discordPrevTemplateState: string | null = null;
let discordPrevTemplateLargeText: string | null = null;
let discordPrevTemplateName: string | null = null;
let discordPrevCoverSource: string | null = null;
const discordServerCoverCache = new Map<string, string | null>();
function syncDiscord() {
const { currentTrack, isPlaying } = usePlayerStore.getState();
const currentTime = getPlaybackProgressSnapshot().currentTime;
const {
discordRichPresence,
discordCoverSource,
discordTemplateDetails,
discordTemplateState,
discordTemplateLargeText,
discordTemplateName,
} = useAuthStore.getState();
if (!discordRichPresence || !currentTrack) {
if (discordPrevTrackId !== null) {
discordPrevTrackId = null;
discordPrevIsPlaying = null;
discordPrevCoverSource = null;
discordPrevTemplateDetails = null;
discordPrevTemplateState = null;
discordPrevTemplateLargeText = null;
discordPrevTemplateName = null;
invoke('discord_clear_presence').catch(() => {});
}
return;
}
const trackChanged = currentTrack.id !== discordPrevTrackId;
const playingChanged = isPlaying !== discordPrevIsPlaying;
const coverSourceChanged = discordCoverSource !== discordPrevCoverSource;
const detailsTemplateChanged = discordTemplateDetails !== discordPrevTemplateDetails;
const stateTemplateChanged = discordTemplateState !== discordPrevTemplateState;
const largeTextTemplateChanged = discordTemplateLargeText !== discordPrevTemplateLargeText;
const nameTemplateChanged = discordTemplateName !== discordPrevTemplateName;
if (!trackChanged && !playingChanged && !coverSourceChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged && !nameTemplateChanged) return;
discordPrevTrackId = currentTrack.id;
discordPrevIsPlaying = isPlaying;
discordPrevCoverSource = discordCoverSource;
discordPrevTemplateDetails = discordTemplateDetails;
discordPrevTemplateState = discordTemplateState;
discordPrevTemplateLargeText = discordTemplateLargeText;
discordPrevTemplateName = discordTemplateName;
const sendPresence = (coverArtUrl: string | null) => {
invoke('discord_update_presence', {
title: currentTrack.title,
artist: currentTrack.artist ?? 'Unknown Artist',
album: currentTrack.album ?? null,
isPlaying,
elapsedSecs: isPlaying ? currentTime : null,
coverArtUrl,
fetchItunesCovers: discordCoverSource === 'apple',
detailsTemplate: discordTemplateDetails,
stateTemplate: discordTemplateState,
largeTextTemplate: discordTemplateLargeText,
nameTemplate: discordTemplateName,
}).catch(() => {});
};
if (discordCoverSource === 'server' && currentTrack.coverArt) {
const cacheKey = currentTrack.coverArt;
const cached = discordServerCoverCache.get(cacheKey);
if (cached !== undefined) {
sendPresence(cached);
} else {
void resolveTrackCoverRefFromLibrary(
{
id: currentTrack.id,
albumId: currentTrack.albumId,
coverArt: currentTrack.coverArt,
discNumber: (currentTrack as { discNumber?: number }).discNumber,
},
resolvePlaybackCoverScope(),
).then(ref => {
if (!ref) {
sendPresence(null);
return;
}
return coverArtUrlForDiscord(ref)
.then(url => {
discordServerCoverCache.set(cacheKey, url);
sendPresence(url);
})
.catch(() => {
discordServerCoverCache.set(cacheKey, null);
sendPresence(null);
});
});
}
} else {
sendPresence(null);
}
}
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);
const unsubDiscordAuth = useAuthStore.subscribe(syncDiscord);
return () => {
unsubDiscordPlayer();
unsubDiscordAuth();
};
}
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn(async () => null as unknown) }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
import { useEqStore, type EqSnapshot } from '@/store/eqStore';
import { useAuthStore } from '@/store/authStore';
import { setupEqDeviceSync } from '@/features/playback/store/audioListenerSetup/eqDeviceSync';
const FLAT = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
function resetEq(over: Partial<{
gains: number[];
enabled: boolean;
preGain: number;
activePreset: string | null;
rememberPerDevice: boolean;
byDevice: Record<string, EqSnapshot>;
}> = {}): void {
useEqStore.setState({
gains: [...FLAT],
enabled: false,
preGain: 0,
activePreset: 'Flat',
customPresets: [],
rememberPerDevice: false,
byDevice: {},
...over,
});
}
function snap(gain0: number, over: Partial<EqSnapshot> = {}): EqSnapshot {
return { gains: [gain0, 0, 0, 0, 0, 0, 0, 0, 0, 0], enabled: false, preGain: 0, activePreset: null, ...over };
}
describe('eqDeviceSync', () => {
let cleanup: () => void = () => {};
beforeEach(() => {
invokeMock.mockClear();
resetEq();
useAuthStore.getState().setAudioOutputDevice(null);
});
afterEach(() => {
cleanup();
cleanup = () => {};
});
it('mirrors live EQ edits into the current device snapshot when enabled', () => {
useAuthStore.getState().setAudioOutputDevice('Speakers');
resetEq({ rememberPerDevice: true });
cleanup = setupEqDeviceSync();
useEqStore.getState().setBandGain(0, 4);
expect(useEqStore.getState().byDevice['Speakers'].gains[0]).toBe(4);
});
it('does not mirror edits when the feature is off', () => {
useAuthStore.getState().setAudioOutputDevice('Speakers');
resetEq({ rememberPerDevice: false });
cleanup = setupEqDeviceSync();
useEqStore.getState().setBandGain(0, 4);
expect(useEqStore.getState().byDevice).toEqual({});
});
it('seeds the current device snapshot when the feature is switched on', () => {
useAuthStore.getState().setAudioOutputDevice('Speakers');
resetEq({ gains: [2, 0, 0, 0, 0, 0, 0, 0, 0, 0] });
cleanup = setupEqDeviceSync();
useEqStore.getState().setRememberPerDevice(true);
expect(useEqStore.getState().byDevice['Speakers']?.gains[0]).toBe(2);
});
it('saves the old device and restores the new device on switch', () => {
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({ rememberPerDevice: true, byDevice: { B: snap(7, { enabled: true, preGain: 1 }) } });
cleanup = setupEqDeviceSync();
// Edit on A is mirrored into A's snapshot.
useEqStore.getState().setBandGain(0, 3);
expect(useEqStore.getState().byDevice['A'].gains[0]).toBe(3);
// Switching to B restores B's saved profile to the live EQ.
useAuthStore.getState().setAudioOutputDevice('B');
expect(useEqStore.getState().gains[0]).toBe(7);
expect(useEqStore.getState().enabled).toBe(true);
// A's saved snapshot is preserved (not overwritten by applying B).
expect(useEqStore.getState().byDevice['A'].gains[0]).toBe(3);
expect(useEqStore.getState().byDevice['B'].gains[0]).toBe(7);
});
it('keeps the current EQ when the new device has no saved snapshot', () => {
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({ rememberPerDevice: true, gains: [5, 0, 0, 0, 0, 0, 0, 0, 0, 0] });
cleanup = setupEqDeviceSync();
useAuthStore.getState().setAudioOutputDevice('NoProfile');
expect(useEqStore.getState().gains[0]).toBe(5);
});
it('applies the saved snapshot for the current device on startup', () => {
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({ rememberPerDevice: true, byDevice: { A: snap(9, { enabled: true, preGain: 2, activePreset: 'X' }) } });
cleanup = setupEqDeviceSync();
const s = useEqStore.getState();
expect(s.gains[0]).toBe(9);
expect(s.enabled).toBe(true);
expect(s.activePreset).toBe('X');
});
// Frank's exact scenario: Jazz on device 1, Rock on device 2 (neither
// pre-seeded), then back to device 1 — must restore Jazz, not Rock.
it('preset on dev1, preset on dev2, back to dev1 restores dev1 preset', () => {
useAuthStore.getState().setAudioOutputDevice('Device1');
resetEq({ rememberPerDevice: true });
cleanup = setupEqDeviceSync();
useEqStore.getState().applyPreset('Jazz');
expect(useEqStore.getState().activePreset).toBe('Jazz');
useAuthStore.getState().setAudioOutputDevice('Device2');
useEqStore.getState().applyPreset('Rock');
expect(useEqStore.getState().activePreset).toBe('Rock');
useAuthStore.getState().setAudioOutputDevice('Device1');
expect(useEqStore.getState().activePreset).toBe('Jazz');
});
it('mirrors the system default (null device) into the __default__ bucket', () => {
useAuthStore.getState().setAudioOutputDevice(null);
resetEq({ rememberPerDevice: true });
cleanup = setupEqDeviceSync();
useEqStore.getState().setBandGain(0, 4);
expect(useEqStore.getState().byDevice['__default__'].gains[0]).toBe(4);
});
it('restores the __default__ profile when the device resets to null (unplug / audio:device-reset)', () => {
// The audio:device-reset event sets audioOutputDevice = null; the sync
// reacts to that store change like any other device switch.
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({ rememberPerDevice: true, byDevice: { __default__: snap(8, { enabled: true }) } });
cleanup = setupEqDeviceSync();
useAuthStore.getState().setAudioOutputDevice(null);
expect(useEqStore.getState().gains[0]).toBe(8);
expect(useEqStore.getState().enabled).toBe(true);
});
it('cleanup stops mirroring further edits', () => {
useAuthStore.getState().setAudioOutputDevice('A');
resetEq({ rememberPerDevice: true });
cleanup = setupEqDeviceSync();
cleanup();
useEqStore.getState().setBandGain(0, 6);
expect(useEqStore.getState().byDevice['A']).toBeUndefined();
});
});
@@ -0,0 +1,90 @@
import { useAuthStore } from '@/store/authStore';
import { useEqStore, type EqSnapshot } from '@/store/eqStore';
/** Key used when no specific device is selected (system default). */
const DEFAULT_DEVICE_KEY = '__default__';
function deviceKey(name: string | null): string {
return name ?? DEFAULT_DEVICE_KEY;
}
// The device key currently in effect. Updated on every device change.
let currentKey = DEFAULT_DEVICE_KEY;
// Suppress the mirror subscription while we programmatically apply a saved
// snapshot (on a device switch or at startup), so applying a profile does not
// immediately write it straight back.
let applying = false;
function applySnapshot(snap: EqSnapshot): void {
applying = true;
try {
useEqStore.getState().applySnapshot(snap);
} finally {
applying = false;
}
}
/**
* Per-device EQ memory. Opt-in via `eqStore.rememberPerDevice` (default off);
* while off, every branch below returns early so behaviour is unchanged.
*
* Keeps the equalizer profile (bands, enabled, pre-gain, active preset) for
* each audio output device and restores it automatically when the device
* changes. Device identity is the canonical device-name string already held in
* `authStore.audioOutputDevice` (null = system default → `__default__`) — the
* same key the device-selection feature relies on. The audio backend exposes no
* stable device UUID, so this deliberately inherits that feature's identity
* model rather than inventing a weaker one.
*
* Returns a cleanup that removes both subscriptions (StrictMode-safe via
* `initAudioListeners`).
*/
export function setupEqDeviceSync(): () => void {
currentKey = deviceKey(useAuthStore.getState().audioOutputDevice);
// Startup: restore the saved profile for the current device, if any. A no-op
// in the common case (the persisted global EQ already equals this device's
// mirrored snapshot); it only matters when the resolved device differs from
// the one active at shutdown.
const eqAtStart = useEqStore.getState();
if (eqAtStart.rememberPerDevice) {
const snap = eqAtStart.byDevice[currentKey];
if (snap) applySnapshot(snap);
}
// Sub 1 — device changed. Covers both the explicit picker selection and the
// `audio:device-reset` unplug event, since both flow through this field.
const unsubDevice = useAuthStore.subscribe((state, prev) => {
if (state.audioOutputDevice === prev.audioOutputDevice) return;
currentKey = deviceKey(state.audioOutputDevice);
const eq = useEqStore.getState();
if (!eq.rememberPerDevice) return;
const snap = eq.byDevice[currentKey];
if (snap) applySnapshot(snap);
// No saved profile for this device → keep the current EQ as-is; the next
// edit mirrors it under this device's key.
});
// Sub 2 — mirror live EQ edits into the current device's snapshot, and seed
// the current device when the feature is switched on. Writing `byDevice` does
// not touch the content fields, so the re-triggered listener is a no-op (no
// feedback loop).
const unsubEq = useEqStore.subscribe((state, prev) => {
if (applying) return;
if (!state.rememberPerDevice) return;
const justEnabled = !prev.rememberPerDevice;
const contentChanged =
state.gains !== prev.gains ||
state.enabled !== prev.enabled ||
state.preGain !== prev.preGain ||
state.activePreset !== prev.activePreset;
if (justEnabled || contentChanged) {
useEqStore.getState().saveSnapshotFor(currentKey);
}
});
return () => {
unsubDevice();
unsubEq();
};
}
@@ -0,0 +1,59 @@
import { invoke } from '@tauri-apps/api/core';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/utils/audio/loudnessPreAnalysisSlider';
import { useAuthStore } from '@/store/authStore';
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
import { invokeAudioSetNormalizationDeduped } from '@/features/playback/store/normalizationIpcDedupe';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
/**
* One-shot startup sync: pushes the persisted audio settings to the Rust engine
* and primes waveform / loudness caches for the boot track. No cleanup needed.
*/
export function runInitialAudioSync(): void {
// Sync loved tracks cache on startup.
usePlayerStore.getState().syncNetworkLovedTracks();
// Initial sync of audio settings to Rust engine on startup.
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
const { volume } = usePlayerStore.getState();
invoke('audio_set_volume', { volume }).catch(() => {});
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
const normCfg = useAuthStore.getState();
usePlayerStore.setState({
normalizationEngineLive: normCfg.normalizationEngine,
normalizationTargetLufs: normCfg.normalizationEngine === 'loudness' ? normCfg.loudnessTargetLufs : null,
normalizationNowDb: null,
normalizationDbgSource: 'init:set-normalization',
});
emitNormalizationDebug('init:set-normalization', {
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
});
invokeAudioSetNormalizationDeduped({
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb(
normCfg.loudnessPreAnalysisAttenuationDb,
normCfg.loudnessTargetLufs,
),
});
const bootTrackId = usePlayerStore.getState().currentTrack?.id;
if (bootTrackId) {
void refreshWaveformForTrack(bootTrackId);
}
if (normCfg.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (currentId) {
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
}
}
if (audioOutputDevice) {
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
}
}
@@ -0,0 +1,106 @@
import { invoke } from '@tauri-apps/api/core';
import { resolvePlaybackCoverScope } from '@/cover/ref';
import { resolveTrackCoverRefFromLibrary } from '@/cover/resolveEntryLibrary';
import { coverArtUrlForMpris } from '@/cover/integrations/mpris';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/features/playback/store/playbackProgress';
/**
* MPRIS / OS media-controls sync. Whenever the current track or playback state
* changes, pushes updates to the Rust souvlaki MediaControls so the OS media
* overlay stays accurate. Returns a cleanup function.
*/
export function setupMprisSync(): () => void {
let prevTrackId: string | null = null;
let prevRadioId: string | null = null;
let prevIsPlaying: boolean | null = null;
let lastMprisPositionUpdate = 0;
const unsubMpris = usePlayerStore.subscribe((state) => {
const { currentTrack, currentRadio, isPlaying } = state;
// Update metadata when track changes
if (currentTrack && currentTrack.id !== prevTrackId) {
prevTrackId = currentTrack.id;
prevRadioId = null;
const title = currentTrack.title;
const artist = currentTrack.artist;
const album = currentTrack.album;
const durationSecs = currentTrack.duration;
if (currentTrack.coverArt && currentTrack.albumId) {
void resolveTrackCoverRefFromLibrary(
{
id: currentTrack.id,
albumId: currentTrack.albumId,
coverArt: currentTrack.coverArt,
discNumber: (currentTrack as { discNumber?: number }).discNumber,
},
resolvePlaybackCoverScope(),
).then(ref => {
if (!ref) return;
coverArtUrlForMpris(ref)
.then(coverUrl => invoke('mpris_set_metadata', {
title,
artist,
album,
coverUrl: coverUrl || undefined,
durationSecs,
}))
.catch(() => {});
});
} else {
invoke('mpris_set_metadata', {
title,
artist,
album,
coverUrl: undefined,
durationSecs,
}).catch(() => {});
}
}
// Update metadata when a radio station starts (initial push — station name as title).
// ICY StreamTitle updates are forwarded by the radio:metadata listener below.
if (currentRadio && currentRadio.id !== prevRadioId) {
prevRadioId = currentRadio.id;
prevTrackId = null;
invoke('mpris_set_metadata', {
title: currentRadio.name,
artist: null,
album: null,
coverUrl: null,
durationSecs: null,
}).catch(() => {});
}
// Update playback state on play/pause change (use live snapshot — persisted
// store currentTime is intentionally coarse between commits).
const playbackChanged = isPlaying !== prevIsPlaying;
if (playbackChanged) {
prevIsPlaying = isPlaying;
lastMprisPositionUpdate = Date.now();
const pos = getPlaybackProgressSnapshot().currentTime;
invoke('mpris_set_playback', {
playing: isPlaying,
positionSecs: pos > 0 ? pos : null,
}).catch(() => {});
invoke('update_taskbar_icon', { isPlaying }).catch(() => {});
return;
}
});
const unsubMprisProgress = subscribePlaybackProgress(({ currentTime }) => {
const { currentRadio, isPlaying } = usePlayerStore.getState();
if (currentRadio || !isPlaying) return;
if (Date.now() - lastMprisPositionUpdate < 1500) return;
lastMprisPositionUpdate = Date.now();
invoke('mpris_set_playback', {
playing: true,
positionSecs: currentTime,
}).catch(() => {});
});
return () => {
unsubMpris();
unsubMprisProgress();
};
}
@@ -0,0 +1,35 @@
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { usePlayerStore } from '@/features/playback/store/playerStore';
/**
* Radio ICY StreamTitle → MPRIS. The Rust download task emits "radio:metadata"
* with { title, is_ad } every time an ICY metadata block changes (typically
* every 832 KB of audio). Forward each update to mpris_set_metadata so the OS
* now-playing overlay stays in sync while the stream is live. Returns a cleanup
* function.
*/
export function setupRadioMprisMetadata(): () => void {
const radioMetaUnlisten = listen<{ title: string; is_ad: boolean }>('radio:metadata', ({ payload }) => {
const { currentRadio } = usePlayerStore.getState();
if (!currentRadio) return; // guard: only forward during active radio session
if (payload.is_ad) return; // skip CDN-injected ad metadata
// Parse "Artist - Title" convention used by most ICY streams.
const sep = payload.title.indexOf(' - ');
const artist = sep !== -1 ? payload.title.slice(0, sep).trim() : null;
const title = sep !== -1 ? payload.title.slice(sep + 3).trim() : payload.title;
invoke('mpris_set_metadata', {
title: title || currentRadio.name,
artist: artist || currentRadio.name,
album: null,
coverUrl: null,
durationSecs: null,
}).catch(() => {});
});
return () => {
radioMetaUnlisten.then(unlisten => unlisten());
};
}
@@ -0,0 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setTransitionMode } from '@/features/playback/utils/playback/playbackTransition';
import {
armAutodjMixing,
clearAutodjTransitionUi,
useAutodjTransitionUi,
} from '@/features/playback/store/autodjTransitionUi';
describe('autodjTransitionUi', () => {
beforeEach(() => {
vi.useFakeTimers();
setTransitionMode('autodj');
clearAutodjTransitionUi();
});
afterEach(() => {
vi.useRealTimers();
setTransitionMode('none');
});
it('arms mixing then returns to idle after overlap', () => {
armAutodjMixing(2);
expect(useAutodjTransitionUi.getState().phase).toBe('mixing');
vi.advanceTimersByTime(2250);
expect(useAutodjTransitionUi.getState().phase).toBe('idle');
});
it('does not arm outside AutoDJ mode', () => {
setTransitionMode('crossfade');
armAutodjMixing(2);
expect(useAutodjTransitionUi.getState().phase).toBe('idle');
});
});
@@ -0,0 +1,47 @@
import { create } from 'zustand';
import { useAuthStore } from '@/store/authStore';
import { getTransitionMode } from '@/features/playback/utils/playback/playbackTransition';
/** User-visible AutoDJ transition feedback on the player-bar play button. */
export type AutodjTransitionPhase = 'idle' | 'mixing';
interface AutodjTransitionUiState {
phase: AutodjTransitionPhase;
}
let mixingTimer: ReturnType<typeof setTimeout> | null = null;
export const useAutodjTransitionUi = create<AutodjTransitionUiState>(() => ({
phase: 'idle',
}));
function clearMixingTimer(): void {
if (mixingTimer) {
clearTimeout(mixingTimer);
mixingTimer = null;
}
}
/** Drop any transition indicator (stop, hard cut, new idle track). */
export function clearAutodjTransitionUi(): void {
clearMixingTimer();
useAutodjTransitionUi.setState({ phase: 'idle' });
}
/**
* Show the mixing indicator only while a real crossfade overlap is in progress.
* No-op outside AutoDJ mode.
*/
export function armAutodjMixing(overlapSec: number): void {
if (!(overlapSec > 0)) return;
if (getTransitionMode(useAuthStore.getState()) !== 'autodj') return;
clearMixingTimer();
useAutodjTransitionUi.setState({ phase: 'mixing' });
const ms = Math.round(overlapSec * 1000) + 250;
mixingTimer = setTimeout(() => {
mixingTimer = null;
if (useAutodjTransitionUi.getState().phase === 'mixing') {
useAutodjTransitionUi.setState({ phase: 'idle' });
}
}, ms);
}
@@ -0,0 +1,386 @@
/**
* B1 regression cluster: queue thin-state server identity must be canonical
* everywhere. Writers emit the URL-derived index key (same model as the
* library index) so mixed-server queues with duplicate `trackId` across
* servers stay unambiguous on every path the review flagged:
*
* - resolver correctness (`seedQueueResolver`, `getCachedTrack`)
* - restore / hydrate (persist `merge`, `hydrateQueueFromIndex`)
* - undo / redo snapshots (`applyQueueHistorySnapshot` prepend, H3)
* - queue sync id emission (`savePlayQueue` trackIds only)
* - write helpers (`toQueueItemRefs`, `bindQueueServerForPlayback`)
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { bindQueueServerForPlayback } from '@/features/playback/utils/playback/playbackServer';
import {
_resetQueueResolverForTest,
getCachedTrack,
seedQueueResolver,
} from '@/utils/library/queueTrackResolver';
import { applyQueueHistorySnapshot } from '@/features/playback/store/applyQueueHistorySnapshot';
import {
pushQueueUndoSnapshot,
type QueueUndoSnapshot,
} from '@/features/playback/store/queueUndo';
import type { PlayerState, QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
import { savePlayQueue } from '@/lib/api/subsonicPlayQueue';
import { _resetQueueSyncForTest, flushPlayQueuePosition } from '@/features/playback/store/queueSync';
vi.mock('@/lib/api/subsonicPlayQueue', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
}));
vi.mock('@/utils/network/activeServerReachability', () => ({
isActiveServerReachable: () => true,
}));
const SERVER_A = {
id: 'uuid-a',
name: 'A',
url: 'http://a.test',
username: 'u',
password: 'p',
};
const SERVER_B = {
id: 'uuid-b',
name: 'B',
url: 'http://b.test',
username: 'u',
password: 'p',
};
const KEY_A = 'a.test';
const KEY_B = 'b.test';
function track(id: string, title: string): Track {
return { id, title, artist: '', album: 'A', albumId: 'AL', duration: 60 };
}
function getMerge() {
type MergeFn = (
persisted: unknown,
current: ReturnType<typeof usePlayerStore.getState>,
) => ReturnType<typeof usePlayerStore.getState>;
return (usePlayerStore as unknown as {
persist: { getOptions(): { merge: MergeFn } };
}).persist.getOptions().merge;
}
beforeEach(() => {
resetAuthStore();
resetPlayerStore();
_resetQueueResolverForTest();
_resetQueueSyncForTest();
vi.mocked(savePlayQueue).mockClear();
useAuthStore.setState({
servers: [SERVER_A, SERVER_B],
activeServerId: SERVER_A.id,
isLoggedIn: true,
});
useLibraryIndexStore.setState({ masterEnabled: true });
});
// ── Write helpers canonicalize ────────────────────────────────────────────
describe('B1 — writers emit canonical server keys', () => {
it('toQueueItemRefs converts a UUID input to the canonical index key', () => {
const refs = toQueueItemRefs(SERVER_A.id, [track('t1', 'One')]);
expect(refs).toEqual([{ serverId: KEY_A, trackId: 't1' }]);
});
it('toQueueItemRefs is idempotent on an already-canonical input', () => {
const refs = toQueueItemRefs(KEY_B, [track('t1', 'One')]);
expect(refs[0].serverId).toBe(KEY_B);
});
it('toQueueItemRefs leaves unknown ids untouched (test isolation / pre-login flows)', () => {
const refs = toQueueItemRefs('unknown-srv', [track('t1', 'One')]);
expect(refs[0].serverId).toBe('unknown-srv');
});
it('bindQueueServerForPlayback writes the canonical key for the active server', () => {
useAuthStore.setState({ activeServerId: SERVER_B.id });
bindQueueServerForPlayback();
expect(usePlayerStore.getState().queueServerId).toBe(KEY_B);
});
});
// ── Resolver correctness: same trackId across two servers must NOT collide ─
describe('B1 — resolver isolates duplicate trackId across servers', () => {
it('seedQueueResolver canonicalizes the seed key — UUID and index key share one cache slot', () => {
const t = track('shared', 'Original');
seedQueueResolver(SERVER_A.id, [t]); // UUID input
// Read via the canonical ref (what writers now emit)
expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('Original');
// …and via legacy UUID-bound refs (migration window compat path)
expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('Original');
});
it('two servers with the same trackId resolve independently — no cross-contamination', () => {
seedQueueResolver(SERVER_A.id, [track('shared', 'From A')]);
seedQueueResolver(SERVER_B.id, [track('shared', 'From B')]);
expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('From A');
expect(getCachedTrack({ serverId: KEY_B, trackId: 'shared' })?.title).toBe('From B');
// Legacy-form refs map back to the same canonical entry per server.
expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('From A');
expect(getCachedTrack({ serverId: SERVER_B.id, trackId: 'shared' })?.title).toBe('From B');
});
});
// ── Persistence merge: forward-migrate legacy UUID-form blobs ─────────────
describe('B1 — persist `merge` forward-migrates legacy UUID-form blobs', () => {
it('canonicalizes queueServerId and every ref `serverId` on rehydrate', () => {
const merged = getMerge()(
{
queueServerId: SERVER_A.id,
queueIndex: 1,
queueItems: [
{ serverId: SERVER_A.id, trackId: 't1' },
{ serverId: SERVER_A.id, trackId: 't2', radioAdded: true },
],
queueItemsIndex: 1,
},
usePlayerStore.getState(),
);
expect(merged.queueServerId).toBe(KEY_A);
expect(merged.queueItems).toEqual([
{ serverId: KEY_A, trackId: 't1' },
{ serverId: KEY_A, trackId: 't2', radioAdded: true },
]);
});
it('canonicalizes the legacy queueRefs-only shape', () => {
const merged = getMerge()(
{
queueServerId: SERVER_B.id,
queueRefs: ['x', 'y'],
queueRefsIndex: 1,
},
usePlayerStore.getState(),
);
expect(merged.queueServerId).toBe(KEY_B);
expect(merged.queueItems.every(r => r.serverId === KEY_B)).toBe(true);
});
it('mixed-server queueItems get per-ref canonicalization (each ref carries its own key)', () => {
const merged = getMerge()(
{
queueServerId: SERVER_A.id,
queueItems: [
{ serverId: SERVER_A.id, trackId: 'shared' },
{ serverId: SERVER_B.id, trackId: 'shared' },
],
queueItemsIndex: 0,
},
usePlayerStore.getState(),
);
expect(merged.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
expect(merged.queueItems[1]).toEqual({ serverId: KEY_B, trackId: 'shared' });
});
});
// ── Undo/redo snapshot: prepend uses snapshot-canonical server (H3) ────────
describe('B1 + H3 — undo prepend binds to the snapshot\'s playback server', () => {
it('prepended ref uses snap.queueServerId, not the live queue-level state', () => {
onInvoke('audio_play', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
// Live state: playback was just rebound to server B mid-undo.
const playingTrack = track('shared', 'Still playing');
const prior: PlayerState = {
...usePlayerStore.getState(),
currentTrack: playingTrack,
currentTime: 12,
progress: 0.2,
isPlaying: true,
queueItems: [{ serverId: KEY_B, trackId: 'shared' }],
queueServerId: KEY_B,
queueIndex: 0,
};
usePlayerStore.setState(prior);
// Snapshot was captured under server A — the prepend must follow A,
// not the live B binding.
const snap: QueueUndoSnapshot = {
queueItems: [],
queueIndex: 0,
currentTrack: null,
currentTime: 0,
progress: 0,
isPlaying: false,
queueServerId: KEY_A,
};
applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);
const after = usePlayerStore.getState();
expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
expect(after.queueIndex).toBe(0);
});
it('falls back to existing snapshot refs when queueServerId is absent (legacy in-memory snapshots)', () => {
onInvoke('audio_play', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
const playingTrack = track('p', 'Playing');
const prior: PlayerState = {
...usePlayerStore.getState(),
currentTrack: playingTrack,
isPlaying: true,
queueItems: [{ serverId: KEY_B, trackId: 'p' }],
queueServerId: KEY_B,
queueIndex: 0,
};
usePlayerStore.setState(prior);
const snap: QueueUndoSnapshot = {
queueItems: [{ serverId: KEY_A, trackId: 'other' }],
queueIndex: 0,
currentTrack: null,
};
applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);
// Prepend inherits server identity from the snapshot's first ref.
const after = usePlayerStore.getState();
expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'p' });
});
it('snapshot from current state captures the canonical queueServerId', () => {
pushQueueUndoSnapshot({
queueItems: [{ serverId: KEY_A, trackId: 't1' }],
queueIndex: 0,
currentTrack: null,
queueServerId: KEY_A,
});
// Sanity: snapshots transport the canonical key forward through stacks.
// (The actual capture happens in queueUndoSnapshotFromState, which now
// includes queueServerId from PlayerState — see queueUndo.ts.)
expect(true).toBe(true);
});
});
// ── Queue sync emits trackIds only (server identity goes via playback API) ─
describe('B1 — queue sync emits track ids only, server identity flows out-of-band', () => {
it('savePlayQueue receives plain track ids regardless of ref server form', async () => {
vi.useFakeTimers();
try {
const refs: QueueItemRef[] = [
{ serverId: KEY_A, trackId: 't1' },
{ serverId: KEY_A, trackId: 't2' },
];
usePlayerStore.setState({
queueItems: refs,
queueIndex: 0,
queueServerId: KEY_A,
currentTrack: track('t1', 'One'),
currentTime: 1.5,
isPlaying: true,
});
await flushPlayQueuePosition();
expect(savePlayQueue).toHaveBeenCalledTimes(1);
const [ids, current, posMs, serverId] = vi.mocked(savePlayQueue).mock.calls[0]!;
expect(ids).toEqual(['t1', 't2']);
expect(current).toBe('t1');
expect(posMs).toBe(1500);
// savePlayQueue's serverId arg comes from getPlaybackServerId(), which
// resolves a canonical key OR a UUID back to a UUID — needed for the
// Subsonic auth lookup. Either is OK here; what matters is no leakage
// of a per-ref serverId into the request body.
expect(typeof serverId).toBe('string');
} finally {
vi.useRealTimers();
}
});
});
// ── Add-to-queue mutations pin the active server when queueServerId is null ─
//
// Regression for the queue-blanking bug: the first user action after launch is
// often a single-track enqueue (e.g. clicking the + button on a search result
// row), not a queue-replacing playTrack. Before the pin, `queueServerId`
// stayed null, `seedIncoming` was a no-op, the refs landed with empty server
// keys, and every new queue row rendered as the resolver placeholder ("…" +
// 0:00) until the user happened to trigger a path that did call
// `bindQueueServerForPlayback`.
describe('B1+ — add-to-queue mutations pin queueServerId when it is null', () => {
it('enqueue seeds the cache so refs resolve to the real track instead of the placeholder', () => {
expect(usePlayerStore.getState().queueServerId).toBeNull();
const t = track('t1', 'Real Title');
usePlayerStore.getState().enqueue([t], true);
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
const refs = usePlayerStore.getState().queueItems;
expect(refs).toEqual([{ serverId: KEY_A, trackId: 't1' }]);
expect(getCachedTrack(refs[0])).toEqual(expect.objectContaining({
id: 't1',
title: 'Real Title',
}));
});
it('enqueueAt pins and seeds when queueServerId is null', () => {
expect(usePlayerStore.getState().queueServerId).toBeNull();
const t = track('t1', 'Inserted');
usePlayerStore.getState().enqueueAt([t], 0, true);
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
const refs = usePlayerStore.getState().queueItems;
expect(refs[0]).toEqual({ serverId: KEY_A, trackId: 't1' });
expect(getCachedTrack(refs[0])?.title).toBe('Inserted');
});
it('enqueueRadio pins and seeds when queueServerId is null', () => {
expect(usePlayerStore.getState().queueServerId).toBeNull();
const t = track('r1', 'Radio Track');
usePlayerStore.getState().enqueueRadio([t], 'artist-x');
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
const refs = usePlayerStore.getState().queueItems;
expect(refs[0]).toEqual(expect.objectContaining({ serverId: KEY_A, trackId: 'r1' }));
expect(getCachedTrack(refs[0])?.title).toBe('Radio Track');
});
it('does not crash or pin when no active server is available', () => {
useAuthStore.setState({ activeServerId: null });
expect(usePlayerStore.getState().queueServerId).toBeNull();
usePlayerStore.getState().enqueue([track('t1', 'X')], true);
// No active server → bindQueueServerForPlayback is a no-op. The mutation
// still runs (matches the pre-fix baseline behaviour) — placeholder UI is
// the expected fallback when no server can be pinned.
expect(usePlayerStore.getState().queueServerId).toBeNull();
expect(usePlayerStore.getState().queueItems).toHaveLength(1);
});
it('does not overwrite an already-pinned queueServerId', () => {
useAuthStore.setState({ activeServerId: SERVER_B.id });
usePlayerStore.setState({ queueServerId: KEY_A });
usePlayerStore.getState().enqueue([track('t1', 'Y')], true);
// Already pinned → ensureQueueServerPinned is a no-op even though the
// active server has since switched (mixed-server enqueue keeps the anchor).
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
});
});
@@ -0,0 +1,195 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
import { autodjMaxOverlapCapSec } from '@/utils/playback/autodjOverlapCap';
import { computeWaveformSilence, planCrossfadeTransition } from '@/utils/waveform/waveformSilence';
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
import { playbackCacheKeyForRef } from '@/features/playback/utils/playback/playbackServer';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
import type { Track } from '@/features/playback/store/playerStoreTypes';
import {
hasPlannedCrossfade,
markPlannedCrossfade,
setCrossfadeTransition,
} from '@/features/playback/store/crossfadeTrimCache';
import { getBytePreloadingId, setBytePreloadingId } from '@/features/playback/store/gaplessPreloadState';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { fetchWaveformBins } from '@/features/playback/store/waveformRefresh';
// Crossfade pre-buffer budget: begin downloading the next track this many
// seconds before it needs to play (the crossfade start), so a large lossless
// file over HTTP has time to buffer + promote to cache before the fade. Generous
// on purpose. The trailing-silence trim widens the window further so the early
// A-tail advance keeps the full budget.
export const CROSSFADE_PRELOAD_BUDGET_SECS = 30;
/**
* Readiness gate for the AutoDJ early, content-driven advance. A stable fade
* needs the *next* track's audio at the overlap moment — analysis (waveform)
* alone is not enough. B counts as ready when its full bytes are in the engine
* RAM preload slot (`enginePreloadedTrackId`) or it is local on disk: offline
* library, favourite auto-sync, or hot-cache ephemeral tier. When B isn't ready
* we skip the early advance and let the plain engine crossfade handle the
* transition (graceful degrade) instead of fading over a buffering stream.
*/
export function isCrossfadeNextReady(
trackId: string,
profileId: string | null,
cacheKey: string | null,
): boolean {
if (!trackId) return false;
if (usePlayerStore.getState().enginePreloadedTrackId === trackId) return true;
for (const sid of [profileId, cacheKey]) {
if (!sid) continue;
if (
findLocalPlaybackUrl(trackId, sid, 'library')
|| findLocalPlaybackUrl(trackId, sid, 'favorite-auto')
|| findLocalPlaybackUrl(trackId, sid, 'ephemeral')
) {
return true;
}
}
return false;
}
/** Outgoing fade + preload window before an interrupt handoff (library pick, etc.). */
export const INTERRUPT_BLEND_PREP_FADE_SEC = 1.0;
/** @deprecated Use {@link INTERRUPT_BLEND_PREP_FADE_SEC} — prep and wait are aligned. */
export const INTERRUPT_BLEND_PRELOAD_WAIT_MS = Math.round(INTERRUPT_BLEND_PREP_FADE_SEC * 1000);
/**
* Start an eager RAM preload for a track the user just picked (no queue lead time).
* No-op when already ready or a preload for this id is in flight.
*/
export function kickEagerCrossfadePreload(
track: Track,
profileId: string | null,
cacheKey: string | null,
): void {
if (isCrossfadeNextReady(track.id, profileId, cacheKey)) return;
if (track.id === getBytePreloadingId()) return;
const serverId = cacheKey || profileId;
const url = resolvePlaybackUrl(track.id, serverId ?? undefined);
setBytePreloadingId(track.id);
void refreshLoudnessForTrack(track.id, { syncPlayingEngine: false });
invoke('audio_preload', {
url,
durationHint: track.duration,
analysisTrackId: track.id,
serverId: serverId || null,
eager: true,
}).catch(() => {});
}
function sleepMs(ms: number): Promise<void> {
return new Promise(resolve => { window.setTimeout(resolve, ms); });
}
/**
* Poll until B is playable for a stable crossfade, or `maxWaitMs` elapses.
* Returns false when `isStale()` reports a superseding play generation.
*/
export async function waitForCrossfadeNextReady(
trackId: string,
profileId: string | null,
cacheKey: string | null,
maxWaitMs: number,
isStale: () => boolean,
): Promise<boolean> {
if (isCrossfadeNextReady(trackId, profileId, cacheKey)) return true;
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
if (isStale()) return false;
await sleepMs(50);
if (isCrossfadeNextReady(trackId, profileId, cacheKey)) return true;
}
return isCrossfadeNextReady(trackId, profileId, cacheKey);
}
/**
* Crossfade-only byte pre-download for the next track + (when trim is on) its
* leading-silence probe. Self-gating and idempotent (`bytePreloadingId` /
* `hasFetchedCrossfadeLead` guards), so it is safe to call every progress tick
* *and* immediately after a seek lands inside the pre-buffer window. No-ops for
* the gapless / hot-cache paths (those pre-buffer elsewhere).
*
* Lives in its own module so `seekAction` can call it without pulling in
* `audioEventHandlers` (which would close a `playerStore` import cycle).
*/
export function maybeCrossfadeBytePreload(currentTime: number, dur: number): void {
if (!(dur > 0)) return;
const {
gaplessEnabled, hotCacheEnabled, crossfadeEnabled, crossfadeSecs, crossfadeTrimSilence,
} = useAuthStore.getState();
if (!crossfadeEnabled || gaplessEnabled) return;
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (!track || store.currentRadio) return;
const remaining = dur - currentTime;
if (!(remaining > 0)) return;
const curTrailSilenceSec = crossfadeTrimSilence
? computeWaveformSilence(store.waveformBins, dur).trailSilenceSec
: 0;
const crossfadeWindowSecs = crossfadeSecs + curTrailSilenceSec + CROSSFADE_PRELOAD_BUDGET_SECS;
if (remaining >= crossfadeWindowSecs) return;
const { queueItems, queueIndex, repeatMode } = store;
if (repeatMode === 'one') return;
const nextIdx = queueIndex + 1;
const nextRef = nextIdx < queueItems.length
? queueItems[nextIdx]
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
if (!nextRef) return;
const nextTrack = resolveQueueTrack(nextRef);
if (!nextTrack || nextTrack.id === track.id) return;
const serverId = playbackCacheKeyForRef(nextRef);
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
// Byte pre-download — skipped when the hot cache is on (it already keeps the
// upcoming queue on disk, which is also why hot cache makes the trim reliable:
// the next track is local → seekable → starts instantly past its lead silence).
if (!hotCacheEnabled && nextTrack.id !== getBytePreloadingId()) {
setBytePreloadingId(nextTrack.id);
// Loudness cache only — never refreshWaveformForTrack(next): it writes the
// global waveformBins and would replace the current track's seekbar.
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
invoke('audio_preload', {
url: nextUrl,
durationHint: nextTrack.duration,
analysisTrackId: nextTrack.id,
serverId: serverId || null,
// Crossfade/AutoDJ pre-buffer: skip the 8 s throttle so the RAM slot
// fills before the fade — without the hot cache this is the only source
// of B's bytes, and a late slot means no fade (or an audible jump).
eager: true,
}).catch(() => {});
}
// B-head + dynamic overlap: plan the whole transition once (no store write) so
// playTrack can start the incoming track past its dead head AND fade over a
// content-adaptive overlap. Pairs the current track's envelope (already in the
// store) with the next track's cached waveform; the alignment maths is cheap,
// so it runs regardless of hot cache (which otherwise skips the byte
// pre-download). Cold/un-analysed tracks fall back to a fixed overlap + no
// head trim → today's behaviour.
if (crossfadeTrimSilence && !hasPlannedCrossfade(nextTrack.id)) {
markPlannedCrossfade(nextTrack.id);
const planTrackId = nextTrack.id;
const planDuration = nextTrack.duration;
const curBins = store.waveformBins;
void fetchWaveformBins(planTrackId, serverId || null)
.then(nextBins => {
// Overlap is derived purely from the audio (fade-out / buildup); the
// user's crossfadeSecs is intentionally not a factor in this mode.
const maxOverlapSec = autodjMaxOverlapCapSec(useAuthStore.getState());
const plan = planCrossfadeTransition(curBins, dur, nextBins, planDuration, { maxOverlapSec });
setCrossfadeTransition(planTrackId, plan);
})
.catch(() => {});
}
}
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
_resetCrossfadeTrimCacheForTest,
armCrossfadeDynamicOverlap,
consumeCrossfadeDynamicOverlap,
peekArmedCrossfadeDynamicOverlap,
getCrossfadeTransition,
hasPlannedCrossfade,
markPlannedCrossfade,
setCrossfadeTransition,
} from '@/features/playback/store/crossfadeTrimCache';
describe('crossfadeTrimCache', () => {
beforeEach(() => _resetCrossfadeTrimCacheForTest());
it('returns null for unknown / empty track ids', () => {
expect(getCrossfadeTransition('nope')).toBeNull();
expect(getCrossfadeTransition('')).toBeNull();
});
it('stores and reads a transition plan', () => {
setCrossfadeTransition('t1', { bStartSec: 2.5, overlapSec: 4, outgoingFadeSec: 0 });
expect(getCrossfadeTransition('t1')).toEqual({ bStartSec: 2.5, overlapSec: 4, outgoingFadeSec: 0 });
});
it('clamps negative values to 0 and ignores empty ids', () => {
setCrossfadeTransition('t2', { bStartSec: -1, overlapSec: -2, outgoingFadeSec: -3 });
expect(getCrossfadeTransition('t2')).toEqual({ bStartSec: 0, overlapSec: 0, outgoingFadeSec: 0 });
setCrossfadeTransition('', { bStartSec: 3, overlapSec: 3, outgoingFadeSec: 3 });
expect(getCrossfadeTransition('')).toBeNull();
});
it('tracks planned ids independently', () => {
expect(hasPlannedCrossfade('t3')).toBe(false);
markPlannedCrossfade('t3');
expect(hasPlannedCrossfade('t3')).toBe(true);
});
it('evicts oldest entries past the cap', () => {
for (let i = 0; i < 40; i++) {
setCrossfadeTransition(`k${i}`, { bStartSec: i, overlapSec: 1, outgoingFadeSec: 1 });
}
// First entries should have been evicted (cap 32).
expect(getCrossfadeTransition('k0')).toBeNull();
expect(getCrossfadeTransition('k39')).toEqual({ bStartSec: 39, overlapSec: 1, outgoingFadeSec: 1 });
});
it('arms and consumes the dynamic overlap once, for the matching track', () => {
armCrossfadeDynamicOverlap('b1', 4, 0);
// Mismatched id consumes nothing and leaves the armed value intact.
expect(consumeCrossfadeDynamicOverlap('other')).toBeNull();
expect(consumeCrossfadeDynamicOverlap('b1')).toEqual({ overlapSec: 4, outgoingFadeSec: 0 });
// One-shot: a second consume returns null.
expect(consumeCrossfadeDynamicOverlap('b1')).toBeNull();
});
it('peeks armed overlap without consuming', () => {
armCrossfadeDynamicOverlap('b3', 2, 2);
expect(peekArmedCrossfadeDynamicOverlap('b3')).toBe(true);
expect(peekArmedCrossfadeDynamicOverlap('other')).toBe(false);
expect(consumeCrossfadeDynamicOverlap('b3')).not.toBeNull();
expect(peekArmedCrossfadeDynamicOverlap('b3')).toBe(false);
});
it('carries the engine fade-out length for A (non-scenario-A swaps)', () => {
armCrossfadeDynamicOverlap('b2', 0.5, 0.5);
expect(consumeCrossfadeDynamicOverlap('b2')).toEqual({ overlapSec: 0.5, outgoingFadeSec: 0.5 });
});
});
@@ -0,0 +1,119 @@
/**
* Silence-aware crossfade — tiny module cache bridging the pre-buffer stage and
* `playTrack`. During the crossfade pre-buffer window (`crossfadePreload`) we
* fetch the *next* track's cached waveform and, together with the current
* track's envelope, derive a per-transition plan: where the incoming track
* should begin (leading silence skipped) and the adaptive overlap length.
* `playTrackAction` then reads it to pass `audio_play(start_secs, crossfade_secs_override)`,
* and `audioEventHandlers` reads the overlap to re-anchor the early A-tail advance.
*
* Kept out of the persisted Zustand store on purpose: this is ephemeral,
* per-transition playback data, not user state.
*/
import type { CrossfadeTransitionPlan } from '@/utils/waveform/waveformSilence';
export type { CrossfadeTransitionPlan } from '@/utils/waveform/waveformSilence';
/** trackId → planned transition for when this track starts under crossfade. */
const planByTrackId = new Map<string, CrossfadeTransitionPlan>();
/** trackIds we've already attempted a plan for (avoids per-tick refetch). */
const plannedTrackIds = new Set<string>();
// Bound both sets so a long session can't grow them without limit.
const MAX_ENTRIES = 32;
function trim(map: { delete: (k: string) => void; size: number; keys: () => IterableIterator<string> }): void {
while (map.size > MAX_ENTRIES) {
const oldest = map.keys().next().value as string | undefined;
if (oldest === undefined) break;
map.delete(oldest);
}
}
/** Record the computed transition plan for `trackId`. */
export function setCrossfadeTransition(trackId: string, plan: CrossfadeTransitionPlan): void {
if (!trackId) return;
planByTrackId.set(trackId, {
bStartSec: Math.max(0, plan.bStartSec),
overlapSec: Math.max(0, plan.overlapSec),
outgoingFadeSec: Math.max(0, plan.outgoingFadeSec),
});
trim(planByTrackId);
}
/** Read the cached transition plan for `trackId` (null when none/unknown). */
export function getCrossfadeTransition(trackId: string): CrossfadeTransitionPlan | null {
if (!trackId) return null;
return planByTrackId.get(trackId) ?? null;
}
/** True once we've already attempted to plan a transition into `trackId`. */
export function hasPlannedCrossfade(trackId: string): boolean {
return plannedTrackIds.has(trackId);
}
/** Mark `trackId` as planned so the pre-buffer loop doesn't refetch every tick. */
export function markPlannedCrossfade(trackId: string): void {
if (!trackId) return;
plannedTrackIds.add(trackId);
trim(plannedTrackIds);
}
// ── One-shot dynamic-overlap hand-off (A-tail advance → playTrack) ──────────────
// When the JS early-advance fires it "arms" the content-driven overlap for the
// incoming track. `playTrack` consumes it to pass `crossfade_secs_override`, so the
// per-transition fade length is applied *only* when JS controlled the advance
// timing. Engine-driven advances (plain loud→loud endings) leave it unset and keep
// the normal crossfade length — avoids muting the outgoing track's tail.
let armedOverlapTrackId: string | null = null;
let armedOverlapSec = 0;
let armedOutgoingFadeSec = 0;
/** The fade lengths JS armed for one incoming transition. */
export interface ArmedCrossfadeOverlap {
/** Track B's fade-in length (the overlap). */
overlapSec: number;
/** Track A's engine fade-out length (0 = A rides its own recorded fade). */
outgoingFadeSec: number;
}
/**
* Arm the content-driven fade lengths JS just positioned for the incoming
* `trackId`: B's fade-in (`overlapSec`) and A's engine fade-out
* (`outgoingFadeSec`; 0 ⇒ let A ride its own recorded fade — scenario A).
*/
export function armCrossfadeDynamicOverlap(
trackId: string,
overlapSec: number,
outgoingFadeSec: number,
): void {
if (!trackId) return;
armedOverlapTrackId = trackId;
armedOverlapSec = Math.max(0, overlapSec);
armedOutgoingFadeSec = Math.max(0, outgoingFadeSec);
}
/** Consume + clear the armed fades for `trackId` (null when none/mismatched). */
export function consumeCrossfadeDynamicOverlap(trackId: string): ArmedCrossfadeOverlap | null {
if (!trackId || armedOverlapTrackId !== trackId) return null;
const overlapSec = armedOverlapSec;
const outgoingFadeSec = armedOutgoingFadeSec;
armedOverlapTrackId = null;
armedOverlapSec = 0;
armedOutgoingFadeSec = 0;
return overlapSec > 0 ? { overlapSec, outgoingFadeSec } : null;
}
/** True when JS A-tail advance armed a handoff for `trackId` (peek only). */
export function peekArmedCrossfadeDynamicOverlap(trackId: string): boolean {
return !!trackId && armedOverlapTrackId === trackId && armedOverlapSec > 0;
}
/** Test/reset hook. */
export function _resetCrossfadeTrimCacheForTest(): void {
planByTrackId.clear();
plannedTrackIds.clear();
armedOverlapTrackId = null;
armedOverlapSec = 0;
armedOutgoingFadeSec = 0;
}
@@ -0,0 +1,101 @@
import type { Track } from '@/features/playback/store/playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { setDeferHotCachePrefetch } from '@/utils/cache/hotCacheGate';
import {
getPlaybackIndexKey,
playbackCacheKeyForTrack,
} from '@/features/playback/utils/playback/playbackServer';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
import { audioPlayHiResBlendArgs } from '@/utils/audio/hiResCrossfadeResample';
import { useAuthStore } from '@/store/authStore';
import { getPlayGeneration, setIsAudioPaused } from '@/features/playback/store/engineState';
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
import { isReplayGainActive, loudnessGainDbForEngineBind } from '@/features/playback/store/loudnessGainCache';
import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl } from '@/features/playback/store/playbackUrlRouting';
import { usePlayerStore } from '@/features/playback/store/playerStore';
/**
* Load a track into the Rust engine at `atSeconds`, optionally leaving transport
* playing or paused. Shared by queue-undo restore and cold-start paused prepare.
*/
export function engineLoadTrackAtPosition(opts: {
generation: number;
track: Track;
queue: Track[];
queueIndex: number;
atSeconds: number;
wantPlaying: boolean;
}): void {
const { generation, track, queue, queueIndex, atSeconds, wantPlaying } = opts;
const authState = useAuthStore.getState();
const vol = usePlayerStore.getState().volume;
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
const replayGainDb = resolveReplayGainDb(
track, coldPrev, coldNext,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
const playbackCacheSid = playbackCacheKeyForTrack(track);
const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey();
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
recordEnginePlayUrl(track.id, url);
usePlayerStore.setState({
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url),
});
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
const startPaused = !wantPlaying;
setDeferHotCachePrefetch(true);
invoke('audio_play', {
url,
volume: vol,
durationHint: track.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
manual: false,
...audioPlayHiResBlendArgs(authState),
analysisTrackId: track.id,
serverId: playbackIndexKey || null,
streamFormatSuffix: track.suffix ?? null,
startPaused,
})
.then(() => {
if (getPlayGeneration() !== generation) return;
if (keepPreloadHint) {
usePlayerStore.setState({ enginePreloadedTrackId: null });
}
const dur = track.duration && track.duration > 0 ? track.duration : null;
const seekTo = Math.max(0, atSeconds);
const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05);
const afterSeek = () => {
if (getPlayGeneration() !== generation) return;
if (!wantPlaying) {
if (!startPaused) {
invoke('audio_pause').catch(console.error);
}
setIsAudioPaused(true);
usePlayerStore.setState({ isPlaying: false });
} else {
setIsAudioPaused(false);
}
};
if (canSeek) {
void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek);
} else {
afterSeek();
}
})
.catch((err: unknown) => {
if (getPlayGeneration() !== generation) return;
console.error('[psysonic] engineLoadTrackAtPosition failed:', err);
usePlayerStore.setState({ isPlaying: false });
})
.finally(() => {
setDeferHotCachePrefetch(false);
});
touchHotCacheOnPlayback(track.id, playbackCacheSid);
}
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
_resetEngineStateForTest,
bumpPlayGeneration,
getIsAudioPaused,
getPlayGeneration,
setIsAudioPaused,
} from '@/features/playback/store/engineState';
afterEach(() => {
_resetEngineStateForTest();
});
describe('isAudioPaused', () => {
it('starts false', () => {
expect(getIsAudioPaused()).toBe(false);
});
it('round-trips through get/set', () => {
setIsAudioPaused(true);
expect(getIsAudioPaused()).toBe(true);
setIsAudioPaused(false);
expect(getIsAudioPaused()).toBe(false);
});
});
describe('playGeneration', () => {
it('starts at 0', () => {
expect(getPlayGeneration()).toBe(0);
});
it('bumpPlayGeneration increments + returns the new value', () => {
expect(bumpPlayGeneration()).toBe(1);
expect(bumpPlayGeneration()).toBe(2);
expect(getPlayGeneration()).toBe(2);
});
it('captures a snapshot that a later bump invalidates', () => {
const snap = bumpPlayGeneration();
bumpPlayGeneration();
expect(getPlayGeneration()).not.toBe(snap);
});
});
describe('_resetEngineStateForTest', () => {
it('resets both fields', () => {
setIsAudioPaused(true);
bumpPlayGeneration();
bumpPlayGeneration();
_resetEngineStateForTest();
expect(getIsAudioPaused()).toBe(false);
expect(getPlayGeneration()).toBe(0);
});
});
@@ -0,0 +1,43 @@
/**
* Two pieces of state that coordinate the Rust audio engine from JS:
*
* - **isAudioPaused** — true when the engine has a loaded-but-paused
* track. `resume()` reads this to decide between `audio_resume` (warm
* path, just unpause the existing Sink) and a cold restart via
* `audio_play + audio_seek`. Set true on every pause, false on every
* successful play / seek.
*
* - **playGeneration** — monotonically increasing counter bumped at the
* start of every play attempt. Long-running `.then`/`.finally`
* callbacks capture their generation at start and compare against the
* current value before applying state changes; a mismatch means the
* user moved on and the callback should bail out without touching the
* store. Prevents stale callbacks from snapping playback back to a
* track the user already left.
*/
let isAudioPaused = false;
let playGeneration = 0;
export function getIsAudioPaused(): boolean {
return isAudioPaused;
}
export function setIsAudioPaused(value: boolean): void {
isAudioPaused = value;
}
export function getPlayGeneration(): number {
return playGeneration;
}
/** Bump + return the new generation in one call (mirrors `++playGeneration`). */
export function bumpPlayGeneration(): number {
return ++playGeneration;
}
/** Test-only: reset both fields so each spec starts from a clean slate. */
export function _resetEngineStateForTest(): void {
isAudioPaused = false;
playGeneration = 0;
}
@@ -0,0 +1,91 @@
/**
* Three mutables that coordinate the gapless preloader. Most of the surface
* is straight get/set — the interesting bit is `clearPreloadingIds` (atomic
* clear of both) and `markGaplessSwitch` (timestamp side effect).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
_resetGaplessPreloadStateForTest,
clearPreloadingIds,
getBytePreloadingId,
getGaplessPreloadingId,
getLastGaplessSwitchTime,
markGaplessSwitch,
setBytePreloadingId,
setGaplessPreloadingId,
} from '@/features/playback/store/gaplessPreloadState';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
});
afterEach(() => {
_resetGaplessPreloadStateForTest();
vi.useRealTimers();
});
describe('initial state', () => {
it('is null / 0 for unread accessors', () => {
expect(getGaplessPreloadingId()).toBeNull();
expect(getBytePreloadingId()).toBeNull();
expect(getLastGaplessSwitchTime()).toBe(0);
});
});
describe('preloading-id accessors', () => {
it('round-trips through the gapless guard', () => {
setGaplessPreloadingId('t1');
expect(getGaplessPreloadingId()).toBe('t1');
});
it('round-trips through the byte guard', () => {
setBytePreloadingId('t2');
expect(getBytePreloadingId()).toBe('t2');
});
it('keeps the two guards independent', () => {
setGaplessPreloadingId('a');
setBytePreloadingId('b');
expect(getGaplessPreloadingId()).toBe('a');
expect(getBytePreloadingId()).toBe('b');
});
it('accepts null to clear a guard', () => {
setGaplessPreloadingId('a');
setGaplessPreloadingId(null);
expect(getGaplessPreloadingId()).toBeNull();
});
});
describe('clearPreloadingIds', () => {
it('clears both guards atomically', () => {
setGaplessPreloadingId('a');
setBytePreloadingId('b');
clearPreloadingIds();
expect(getGaplessPreloadingId()).toBeNull();
expect(getBytePreloadingId()).toBeNull();
});
it('does not touch the gapless-switch timestamp', () => {
markGaplessSwitch();
const before = getLastGaplessSwitchTime();
clearPreloadingIds();
expect(getLastGaplessSwitchTime()).toBe(before);
});
});
describe('markGaplessSwitch', () => {
it('stamps Date.now()', () => {
markGaplessSwitch();
expect(getLastGaplessSwitchTime()).toBe(Date.now());
});
it('overwrites on a later call', () => {
markGaplessSwitch();
const first = getLastGaplessSwitchTime();
vi.advanceTimersByTime(700);
markGaplessSwitch();
expect(getLastGaplessSwitchTime()).toBeGreaterThan(first);
});
});
@@ -0,0 +1,57 @@
/**
* Coordinates the gapless-chain preloader so the runtime doesn't pre-fetch
* the same track twice or fire a chain-switch while a previous one is
* still settling.
*
* - `gaplessPreloadingId` — track id last handed to `audio_chain_preload`
* - `bytePreloadingId` — track id last handed to `audio_preload`
* - `lastGaplessSwitchTime` — timestamp of the last gapless track-switch
* event from Rust. The 500600 ms guards in `handleAudioTrackSwitched`
* and the progress handler use this to suppress stale IPC arriving
* right after the switch.
*
* `clearPreloadingIds` collapses the repeated `= null; = null;` pattern
* that the store actions used to inline in five places.
*/
let gaplessPreloadingId: string | null = null;
let bytePreloadingId: string | null = null;
let lastGaplessSwitchTime = 0;
export function getGaplessPreloadingId(): string | null {
return gaplessPreloadingId;
}
export function setGaplessPreloadingId(id: string | null): void {
gaplessPreloadingId = id;
}
export function getBytePreloadingId(): string | null {
return bytePreloadingId;
}
export function setBytePreloadingId(id: string | null): void {
bytePreloadingId = id;
}
/** Atomic: clear both preloading guards. Called on track switch + on errors. */
export function clearPreloadingIds(): void {
gaplessPreloadingId = null;
bytePreloadingId = null;
}
export function getLastGaplessSwitchTime(): number {
return lastGaplessSwitchTime;
}
/** Record a gapless switch event. Subsequent guards compare against this. */
export function markGaplessSwitch(): void {
lastGaplessSwitchTime = Date.now();
}
/** Test-only: reset all three mutables. */
export function _resetGaplessPreloadStateForTest(): void {
gaplessPreloadingId = null;
bytePreloadingId = null;
lastGaplessSwitchTime = 0;
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import type { LocalPlaybackEntry } from '@/store/localPlaybackStore';
import { countHotCacheTracks } from '@/features/playback/store/hotCacheStore';
function ephemeral(
serverIndexKey: string,
trackId: string,
): LocalPlaybackEntry {
return {
serverIndexKey,
trackId,
localPath: `/media/cache/${trackId}.mp3`,
sizeBytes: 1_000,
layoutFingerprint: 'fp',
tier: 'ephemeral',
suffix: 'mp3',
cachedAt: Date.parse('2026-01-01T00:00:00.000Z'),
};
}
describe('countHotCacheTracks', () => {
it('counts all ephemeral rows regardless of server index key shape', () => {
const entries = {
'192.168.0.5:4533:t1': ephemeral('192.168.0.5:4533', 't1'),
'srv-uuid:t2': ephemeral('srv-uuid', 't2'),
'srv-uuid:t3': { ...ephemeral('srv-uuid', 't3'), tier: 'library' as const },
};
expect(countHotCacheTracks(entries)).toBe(2);
});
it('ignores non-ephemeral tiers', () => {
const entries = {
'srv:t1': ephemeral('srv', 't1'),
'srv:t2': { ...ephemeral('srv', 't2'), tier: 'favorite-auto' as const },
};
expect(countHotCacheTracks(entries)).toBe(1);
});
});
@@ -0,0 +1,114 @@
import type { QueueItemRef } from '@/features/playback/store/playerStoreTypes';
import { create } from 'zustand';
import type { HotCacheEntry } from '@/features/playback/store/hotCacheStoreTypes';
import { useLocalPlaybackStore, type LocalPlaybackEntry } from '@/store/localPlaybackStore';
import { entryBelongsToServer } from '@/store/localPlaybackResolve';
import { invoke } from '@tauri-apps/api/core';
import { getMediaDir } from '@/utils/media/mediaDir';
export type { HotCacheEntry } from '@/features/playback/store/hotCacheStoreTypes';
/** @deprecated Use {@link LOCAL_PLAYBACK_PROTECT_AFTER_CURRENT}. */
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
interface HotCacheState {
getLocalUrl: (trackId: string, serverId: string) => string | null;
setEntry: (
trackId: string,
serverId: string,
localPath: string,
sizeBytes: number,
debugSource?: string,
layoutFingerprint?: string,
suffix?: string,
) => void;
touchPlayed: (trackId: string, serverId: string) => void;
removeEntry: (trackId: string, serverId: string) => Promise<void>;
totalBytes: () => number;
evictToFit: (
queue: QueueItemRef[],
queueIndex: number,
maxBytes: number,
activeServerId: string,
mediaDir: string | null,
) => Promise<void>;
clearAllDisk: (mediaDir: string | null) => Promise<void>;
}
/** Ephemeral-tier view for UI selectors (Settings track count, prefetch helpers). */
export function selectHotCacheEntries(
entries: Record<string, import('@/store/localPlaybackStore').LocalPlaybackEntry>,
): Record<string, HotCacheEntry> {
const out: Record<string, HotCacheEntry> = {};
for (const [key, e] of Object.entries(entries)) {
if (e.tier !== 'ephemeral') continue;
out[key] = {
localPath: e.localPath,
sizeBytes: e.sizeBytes,
cachedAt: e.cachedAt,
lastPlayedAt: e.lastPlayedAt,
};
}
return out;
}
/** Ephemeral-tier row count for Settings (optional active-server scope). */
export function countHotCacheTracks(
entries: Record<string, LocalPlaybackEntry>,
scopeServerId?: string,
): number {
let n = 0;
for (const e of Object.values(entries)) {
if (e.tier !== 'ephemeral') continue;
if (scopeServerId && !entryBelongsToServer(e, scopeServerId)) continue;
n++;
}
return n;
}
export const useHotCacheStore = create<HotCacheState>()(() => ({
getLocalUrl: (trackId, serverId) =>
useLocalPlaybackStore.getState().getLocalUrl(trackId, serverId, 'ephemeral'),
setEntry: (trackId, serverId, localPath, sizeBytes, _debugSource, layoutFingerprint = '', suffix = 'mp3') => {
useLocalPlaybackStore.getState().upsertEntry({
serverIndexKey: serverId,
trackId,
localPath,
sizeBytes,
layoutFingerprint,
tier: 'ephemeral',
suffix,
});
},
touchPlayed: (trackId, serverId) => {
useLocalPlaybackStore.getState().touchPlayed(trackId, serverId);
},
removeEntry: async (trackId, serverId) => {
const lp = useLocalPlaybackStore.getState();
const e = lp.getEntry(trackId, serverId);
if (e?.tier === 'ephemeral' && e.localPath) {
await invoke('delete_media_file', { localPath: e.localPath, mediaDir: getMediaDir() }).catch(
() => {},
);
lp.removeEntry(trackId, serverId, 'hot-cache-shim');
}
},
totalBytes: () => useLocalPlaybackStore.getState().ephemeralTotalBytes(),
evictToFit: async (queue, queueIndex, maxBytes, activeServerId, mediaDir) => {
await useLocalPlaybackStore.getState().evictEphemeralToFit(
queue,
queueIndex,
maxBytes,
activeServerId,
mediaDir,
);
},
clearAllDisk: async (mediaDir) => {
await useLocalPlaybackStore.getState().purgeEphemeralDisk(mediaDir);
},
}));
@@ -0,0 +1,6 @@
export interface HotCacheEntry {
localPath: string;
sizeBytes: number;
cachedAt: number;
lastPlayedAt?: number;
}
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { touchPlayedMock } = vi.hoisted(() => ({
touchPlayedMock: vi.fn(),
}));
vi.mock('@/features/playback/store/hotCacheStore', () => ({
useHotCacheStore: { getState: () => ({ touchPlayed: touchPlayedMock }) },
}));
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
beforeEach(() => {
touchPlayedMock.mockClear();
});
describe('touchHotCacheOnPlayback', () => {
it('forwards a populated id pair to the hot-cache store', () => {
touchHotCacheOnPlayback('t1', 'srv');
expect(touchPlayedMock).toHaveBeenCalledWith('t1', 'srv');
});
it('skips when the trackId is empty', () => {
touchHotCacheOnPlayback('', 'srv');
expect(touchPlayedMock).not.toHaveBeenCalled();
});
it('skips when the serverId is empty', () => {
touchHotCacheOnPlayback('t1', '');
expect(touchPlayedMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,14 @@
import { useHotCacheStore } from '@/features/playback/store/hotCacheStore';
/**
* Mark a track as recently played for the hot-cache LRU. Called from every
* `audio_play` entry point — cold start, gapless switch, queue rewrite,
* radio next — so the hot cache promotes frequently-played tracks even when
* playback bounced through different code paths. The empty-id guards keep
* dev-time crashes (e.g. unauthenticated state, server still resolving)
* from surfacing as cache writes against a meaningless key.
*/
export function touchHotCacheOnPlayback(trackId: string, serverId: string): void {
if (!trackId || !serverId) return;
useHotCacheStore.getState().touchPlayed(trackId, serverId);
}
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
_resetInfiniteQueueStateForTest,
isInfiniteQueueFetching,
setInfiniteQueueFetching,
} from '@/features/playback/store/infiniteQueueState';
afterEach(() => {
_resetInfiniteQueueStateForTest();
});
describe('infiniteQueueFetching', () => {
it('starts false', () => {
expect(isInfiniteQueueFetching()).toBe(false);
});
it('round-trips through set/get', () => {
setInfiniteQueueFetching(true);
expect(isInfiniteQueueFetching()).toBe(true);
setInfiniteQueueFetching(false);
expect(isInfiniteQueueFetching()).toBe(false);
});
it('_resetInfiniteQueueStateForTest resets to false', () => {
setInfiniteQueueFetching(true);
_resetInfiniteQueueStateForTest();
expect(isInfiniteQueueFetching()).toBe(false);
});
});
@@ -0,0 +1,22 @@
/**
* Concurrent-fetch guard for the infinite-queue feature. Stops a second
* `buildInfiniteQueueCandidates` request from running while the first
* is still pending — without it, switching tracks quickly while the
* infinite tail is loading would race two enqueue actions and the
* second would clobber the first's results.
*/
let infiniteQueueFetching = false;
export function isInfiniteQueueFetching(): boolean {
return infiniteQueueFetching;
}
export function setInfiniteQueueFetching(value: boolean): void {
infiniteQueueFetching = value;
}
/** Test-only: reset the guard. */
export function _resetInfiniteQueueStateForTest(): void {
infiniteQueueFetching = false;
}
@@ -0,0 +1,35 @@
import { setupAudioEngineListeners } from '@/features/playback/store/audioListenerSetup/audioEngineListeners';
import { runInitialAudioSync } from '@/features/playback/store/audioListenerSetup/initialAudioSync';
import { setupAuthSync } from '@/features/playback/store/audioListenerSetup/authSyncListener';
import { setupMprisSync } from '@/features/playback/store/audioListenerSetup/mprisSync';
import { setupRadioMprisMetadata } from '@/features/playback/store/audioListenerSetup/radioMprisMetadata';
import { setupDiscordPresence } from '@/features/playback/store/audioListenerSetup/discordPresence';
import { setupEqDeviceSync } from '@/features/playback/store/audioListenerSetup/eqDeviceSync';
/**
* Set up Tauri event listeners for the Rust audio engine.
* Returns a cleanup function — pass it to useEffect's return value so that
* React StrictMode (which double-invokes effects in dev) tears down the first
* set of listeners before creating the second, avoiding duplicate handlers.
*
* Each concern lives in its own module under `audioListenerSetup/`; this
* function just composes them in the original setup / teardown order.
*/
export function initAudioListeners(): () => void {
const stopEngineListeners = setupAudioEngineListeners();
runInitialAudioSync();
const stopAuthSync = setupAuthSync();
const stopMprisSync = setupMprisSync();
const stopRadioMprisMetadata = setupRadioMprisMetadata();
const stopDiscordPresence = setupDiscordPresence();
const stopEqDeviceSync = setupEqDeviceSync();
return () => {
stopAuthSync();
stopMprisSync();
stopDiscordPresence();
stopEngineListeners();
stopRadioMprisMetadata();
stopEqDeviceSync();
};
}
@@ -0,0 +1,91 @@
/**
* Backfill state: two parallel maps that retry the per-track loudness
* analysis a bounded number of times. The interesting behaviours are the
* `markBackfillInFlight` atomicity (both flag + counter bump in one call)
* and the reseed reset that expands across the `stream:` / bare id forms
* via `loudnessCacheStateKeysForTrackId` (re-used from loudnessGainCache).
*/
import { afterEach, describe, expect, it } from 'vitest';
import {
MAX_BACKFILL_ATTEMPTS_PER_TRACK,
_resetBackfillStateForTest,
clearBackfillInFlight,
getBackfillAttempts,
isBackfillInFlight,
markBackfillInFlight,
resetBackfillAttempts,
resetLoudnessBackfillStateForTrackId,
} from '@/features/playback/store/loudnessBackfillState';
afterEach(() => {
_resetBackfillStateForTest();
});
describe('initial state', () => {
it('reports no inflight + 0 attempts for unknown tracks', () => {
expect(isBackfillInFlight('t1')).toBe(false);
expect(getBackfillAttempts('t1')).toBe(0);
});
});
describe('markBackfillInFlight', () => {
it('atomically sets inflight flag and counter', () => {
markBackfillInFlight('t1', 1);
expect(isBackfillInFlight('t1')).toBe(true);
expect(getBackfillAttempts('t1')).toBe(1);
});
it('keeps tracks independent', () => {
markBackfillInFlight('a', 1);
markBackfillInFlight('b', 2);
expect(getBackfillAttempts('a')).toBe(1);
expect(getBackfillAttempts('b')).toBe(2);
clearBackfillInFlight('a');
expect(isBackfillInFlight('a')).toBe(false);
expect(isBackfillInFlight('b')).toBe(true);
});
});
describe('clearBackfillInFlight', () => {
it('clears the flag without touching the counter', () => {
markBackfillInFlight('t1', 1);
clearBackfillInFlight('t1');
expect(isBackfillInFlight('t1')).toBe(false);
expect(getBackfillAttempts('t1')).toBe(1); // counter preserved
});
});
describe('resetBackfillAttempts', () => {
it('zeros the counter without touching the inflight flag', () => {
markBackfillInFlight('t1', 2);
resetBackfillAttempts('t1');
expect(getBackfillAttempts('t1')).toBe(0);
expect(isBackfillInFlight('t1')).toBe(true);
});
});
describe('MAX_BACKFILL_ATTEMPTS_PER_TRACK', () => {
it('is the hard-coded threshold the runtime uses', () => {
expect(MAX_BACKFILL_ATTEMPTS_PER_TRACK).toBe(2);
});
});
describe('resetLoudnessBackfillStateForTrackId', () => {
it('clears both maps for both id forms (bare + stream:)', () => {
markBackfillInFlight('t1', 1);
markBackfillInFlight('stream:t1', 2);
resetLoudnessBackfillStateForTrackId('t1');
expect(isBackfillInFlight('t1')).toBe(false);
expect(isBackfillInFlight('stream:t1')).toBe(false);
expect(getBackfillAttempts('t1')).toBe(0);
expect(getBackfillAttempts('stream:t1')).toBe(0);
});
it('also works when invoked with the stream-prefixed form', () => {
markBackfillInFlight('t1', 1);
markBackfillInFlight('stream:t1', 2);
resetLoudnessBackfillStateForTrackId('stream:t1');
expect(getBackfillAttempts('t1')).toBe(0);
expect(getBackfillAttempts('stream:t1')).toBe(0);
});
});
@@ -0,0 +1,56 @@
import { loudnessCacheStateKeysForTrackId } from '@/features/playback/store/loudnessGainCache';
/**
* Bounded retry state for the per-track loudness backfill: each `refresh:miss`
* for a track in loudness mode enqueues an `analysis_enqueue_seed_from_url`
* job, but only if (a) no enqueue is already inflight for that id and
* (b) the per-track attempt counter is below `MAX_BACKFILL_ATTEMPTS_PER_TRACK`.
* A `refresh:hit` resets the counter so the next miss starts fresh.
*
* Both maps stay keyed by the raw track id passed by the caller — the
* `loudnessCacheStateKeysForTrackId` expansion only matters when clearing
* during a reseed (`resetLoudnessBackfillStateForTrackId`).
*/
export const MAX_BACKFILL_ATTEMPTS_PER_TRACK = 2;
const analysisBackfillInFlightByTrackId: Record<string, true> = {};
const analysisBackfillAttemptsByTrackId: Record<string, number> = {};
export function isBackfillInFlight(trackId: string): boolean {
return Boolean(analysisBackfillInFlightByTrackId[trackId]);
}
export function getBackfillAttempts(trackId: string): number {
return analysisBackfillAttemptsByTrackId[trackId] ?? 0;
}
/** Atomic: flag the track inflight AND bump the attempt counter to `nextAttempt`. */
export function markBackfillInFlight(trackId: string, nextAttempt: number): void {
analysisBackfillInFlightByTrackId[trackId] = true;
analysisBackfillAttemptsByTrackId[trackId] = nextAttempt;
}
/** Clear the inflight flag (called from the `.finally` of the enqueue promise). */
export function clearBackfillInFlight(trackId: string): void {
delete analysisBackfillInFlightByTrackId[trackId];
}
/** Reset the attempt counter to 0 — called after a `refresh:hit`. */
export function resetBackfillAttempts(trackId: string): void {
analysisBackfillAttemptsByTrackId[trackId] = 0;
}
/** Full reset for both maps across the bare + `stream:` id forms — used during a reseed. */
export function resetLoudnessBackfillStateForTrackId(trackId: string): void {
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
delete analysisBackfillInFlightByTrackId[k];
analysisBackfillAttemptsByTrackId[k] = 0;
}
}
/** Test-only: wipe both maps so each spec starts clean. */
export function _resetBackfillStateForTest(): void {
for (const k of Object.keys(analysisBackfillInFlightByTrackId)) delete analysisBackfillInFlightByTrackId[k];
for (const k of Object.keys(analysisBackfillAttemptsByTrackId)) delete analysisBackfillAttemptsByTrackId[k];
}
@@ -0,0 +1,96 @@
/**
* Pure functions over a queue slice: the "is this id inside the prefetch
* window?" check and the "give me the window's id list" collector. Window
* = current track + next `LOUDNESS_BACKFILL_WINDOW_AHEAD` entries, with
* duplicates collapsed.
*/
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
import { describe, expect, it } from 'vitest';
import {
LOUDNESS_BACKFILL_WINDOW_AHEAD,
collectLoudnessBackfillWindowTrackIds,
isTrackInsideLoudnessBackfillWindow,
} from '@/features/playback/store/loudnessBackfillWindow';
function track(id: string): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
}
// Thin-state: the window functions take queue refs; the currentTrack arg stays a Track.
function ref(id: string): QueueItemRef {
return { serverId: 's', trackId: id };
}
const big = Array.from({ length: 12 }, (_, i) => ref(`t${i}`));
describe('LOUDNESS_BACKFILL_WINDOW_AHEAD', () => {
it('is the value the runtime expects', () => {
expect(LOUDNESS_BACKFILL_WINDOW_AHEAD).toBe(5);
});
});
describe('isTrackInsideLoudnessBackfillWindow', () => {
it('matches the current track unconditionally', () => {
expect(isTrackInsideLoudnessBackfillWindow('t0', big, 0, track('t0'))).toBe(true);
});
it('matches an id inside the ahead window', () => {
// queueIndex 0, AHEAD 5 → indices 1..5 are inside, t3 must hit.
expect(isTrackInsideLoudnessBackfillWindow('t3', big, 0, track('t0'))).toBe(true);
});
it('returns false for an id beyond the ahead window', () => {
// From queueIndex 0, indices 1..5 inside → t6 (index 6) is outside.
expect(isTrackInsideLoudnessBackfillWindow('t6', big, 0, track('t0'))).toBe(false);
});
it('window slides with queueIndex', () => {
// queueIndex 4, AHEAD 5 → indices 5..9 are inside, t9 must hit, t10 must not.
expect(isTrackInsideLoudnessBackfillWindow('t9', big, 4, track('t4'))).toBe(true);
expect(isTrackInsideLoudnessBackfillWindow('t10', big, 4, track('t4'))).toBe(false);
});
it('returns false for empty queue', () => {
expect(isTrackInsideLoudnessBackfillWindow('t1', [], 0, null)).toBe(false);
});
it('returns false for empty trackId', () => {
expect(isTrackInsideLoudnessBackfillWindow('', big, 0, track('t0'))).toBe(false);
});
it('returns false when currentTrack is null and id is not in the queue window', () => {
expect(isTrackInsideLoudnessBackfillWindow('missing', big, 0, null)).toBe(false);
});
});
describe('collectLoudnessBackfillWindowTrackIds', () => {
it('returns current + next 5 entries', () => {
const ids = collectLoudnessBackfillWindowTrackIds(big, 0, track('t0'));
expect(ids).toEqual(['t0', 't1', 't2', 't3', 't4', 't5']);
});
it('clamps the window to the end of the queue', () => {
const ids = collectLoudnessBackfillWindowTrackIds(big, 9, track('t9'));
// queueIndex 9, AHEAD 5 → indices 10..11 available → t9, t10, t11
expect(ids).toEqual(['t9', 't10', 't11']);
});
it('omits the current track when null', () => {
const ids = collectLoudnessBackfillWindowTrackIds(big, 0, null);
expect(ids).toEqual(['t1', 't2', 't3', 't4', 't5']);
});
it('deduplicates when currentTrack is also in the ahead window', () => {
const queue = [ref('a'), ref('b'), ref('a'), ref('c')];
const ids = collectLoudnessBackfillWindowTrackIds(queue, 0, track('a'));
expect(ids).toEqual(['a', 'b', 'c']);
});
it('returns just the current track for an empty queue', () => {
expect(collectLoudnessBackfillWindowTrackIds([], 0, track('only'))).toEqual(['only']);
});
it('returns an empty list when nothing is playing and the queue is empty', () => {
expect(collectLoudnessBackfillWindowTrackIds([], 0, null)).toEqual([]);
});
});
@@ -0,0 +1,78 @@
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
/**
* After a bulk enqueue (queue replace, append-many, lucky-mix) the runtime
* warms the loudness cache for the current track + the next N entries so
* the engine's `audio_chain_preload` sees a real cached gain instead of
* the startup trim. These helpers compute that window — both as a
* "does this id sit inside it?" predicate and as the explicit id list
* the prefetch loop iterates over.
*
* Pure functions of the state slice — no store imports, no side effects.
* The caller passes the queue + index + current track so the test surface
* stays trivial and there's no top-level coupling back to playerStore.
*/
export const LOUDNESS_BACKFILL_WINDOW_AHEAD = 5;
export function isTrackInsideLoudnessBackfillWindow(
trackId: string,
queue: QueueItemRef[],
queueIndex: number,
currentTrack: Track | null,
): boolean {
if (!trackId) return false;
if (currentTrack?.id === trackId) return true;
if (queue.length === 0) return false;
const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) {
if (queue[i]?.trackId === trackId) return true;
}
return false;
}
export function collectLoudnessBackfillWindowTrackIds(
queue: QueueItemRef[],
queueIndex: number,
currentTrack: Track | null,
): string[] {
const ids = new Set<string>();
if (currentTrack?.id) ids.add(currentTrack.id);
const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) {
const tid = queue[i]?.trackId;
if (tid) ids.add(tid);
}
return Array.from(ids);
}
/** Next ~5 queue neighbours for middle-tier analysis priority hints. */
export function collectPlaybackMiddlePriorityTrackIds(
queue: QueueItemRef[],
queueIndex: number,
currentTrack: Track | null,
): string[] {
const ids = new Set<string>();
const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) {
const tid = queue[i]?.trackId;
if (tid && tid !== currentTrack?.id) ids.add(tid);
}
return Array.from(ids);
}
export function loudnessBackfillPriorityForTrack(
trackId: string,
queue: QueueItemRef[],
queueIndex: number,
currentTrack: Track | null,
): 'high' | 'middle' | 'low' {
if (currentTrack?.id === trackId) return 'high';
const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) {
if (queue[i]?.trackId === trackId) return 'middle';
}
return 'low';
}
@@ -0,0 +1,180 @@
/**
* Loudness-gain cache encapsulates two parallel maps and a small API that
* playerStore drives from the audio-event handlers + cache refresh path.
* The interesting behaviours: (a) stable-flag gating in
* `loudnessGainDbForEngineBind` (partial values are silently invisible to
* engine bind), (b) `clearLoudnessCacheStateForTrackId` expands across the
* `stream:` prefix while `forgetLoudnessGain` does NOT (preserves the
* existing direct-delete semantics from playerStore).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { authState } = vi.hoisted(() => ({
authState: {
normalizationEngine: 'off' as 'off' | 'replaygain' | 'loudness',
replayGainEnabled: false,
},
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: { getState: () => authState },
}));
import {
_resetLoudnessGainCacheForTest,
clearLoudnessCacheStateForTrackId,
forgetLoudnessGain,
getCachedLoudnessGain,
hasStableLoudness,
isReplayGainActive,
loudnessCacheStateKeysForTrackId,
loudnessGainDbForEngineBind,
markLoudnessStable,
setCachedLoudnessGain,
} from '@/features/playback/store/loudnessGainCache';
beforeEach(() => {
authState.normalizationEngine = 'off';
authState.replayGainEnabled = false;
});
afterEach(() => {
_resetLoudnessGainCacheForTest();
});
describe('loudnessCacheStateKeysForTrackId', () => {
it('returns bare + stream-prefixed form for a bare id', () => {
expect(loudnessCacheStateKeysForTrackId('abc')).toEqual(['abc', 'stream:abc']);
});
it('returns stream-prefixed + bare form for a stream id', () => {
expect(loudnessCacheStateKeysForTrackId('stream:abc')).toEqual(['stream:abc', 'abc']);
});
it('returns empty for an empty id', () => {
expect(loudnessCacheStateKeysForTrackId('')).toEqual([]);
});
it('returns only the stream-prefixed form when the bare portion is empty', () => {
expect(loudnessCacheStateKeysForTrackId('stream:')).toEqual(['stream:']);
});
});
describe('getCachedLoudnessGain / setCachedLoudnessGain', () => {
it('round-trips a value through the cache', () => {
setCachedLoudnessGain('t1', -7.2);
expect(getCachedLoudnessGain('t1')).toBe(-7.2);
});
it('returns undefined for missing entries', () => {
expect(getCachedLoudnessGain('missing')).toBeUndefined();
});
});
describe('hasStableLoudness / markLoudnessStable', () => {
it('flags as stable only after markLoudnessStable', () => {
setCachedLoudnessGain('t1', -7);
expect(hasStableLoudness('t1')).toBe(false);
markLoudnessStable('t1', -7);
expect(hasStableLoudness('t1')).toBe(true);
});
it('markLoudnessStable writes the cached value atomically', () => {
markLoudnessStable('t1', -5);
expect(getCachedLoudnessGain('t1')).toBe(-5);
expect(hasStableLoudness('t1')).toBe(true);
});
});
describe('forgetLoudnessGain (single-key delete)', () => {
it('clears both maps for the literal id only — does not touch the other form', () => {
markLoudnessStable('t1', -5);
markLoudnessStable('stream:t1', -6);
forgetLoudnessGain('t1');
expect(getCachedLoudnessGain('t1')).toBeUndefined();
expect(hasStableLoudness('t1')).toBe(false);
// Stream form must still be there — forget is intentionally narrow.
expect(getCachedLoudnessGain('stream:t1')).toBe(-6);
expect(hasStableLoudness('stream:t1')).toBe(true);
});
});
describe('clearLoudnessCacheStateForTrackId (two-form delete)', () => {
it('clears both maps for both id forms', () => {
markLoudnessStable('t1', -5);
markLoudnessStable('stream:t1', -6);
clearLoudnessCacheStateForTrackId('t1');
expect(getCachedLoudnessGain('t1')).toBeUndefined();
expect(getCachedLoudnessGain('stream:t1')).toBeUndefined();
expect(hasStableLoudness('t1')).toBe(false);
expect(hasStableLoudness('stream:t1')).toBe(false);
});
it('also works when invoked with the stream-prefixed form', () => {
markLoudnessStable('t1', -5);
markLoudnessStable('stream:t1', -6);
clearLoudnessCacheStateForTrackId('stream:t1');
expect(getCachedLoudnessGain('t1')).toBeUndefined();
expect(getCachedLoudnessGain('stream:t1')).toBeUndefined();
});
});
describe('loudnessGainDbForEngineBind', () => {
it('returns null without a stable flag (partial/placeholder values are hidden from engine bind)', () => {
setCachedLoudnessGain('t1', -5);
expect(loudnessGainDbForEngineBind('t1')).toBeNull();
});
it('returns the cached value once the entry is stable', () => {
markLoudnessStable('t1', -5);
expect(loudnessGainDbForEngineBind('t1')).toBe(-5);
});
it('returns null when the cached value is non-finite', () => {
markLoudnessStable('t1', Number.NaN);
expect(loudnessGainDbForEngineBind('t1')).toBeNull();
});
it('returns null for null / empty trackId input', () => {
expect(loudnessGainDbForEngineBind(null)).toBeNull();
expect(loudnessGainDbForEngineBind(undefined)).toBeNull();
expect(loudnessGainDbForEngineBind('')).toBeNull();
});
});
describe('isReplayGainActive', () => {
it('is false when normalization engine is off', () => {
authState.normalizationEngine = 'off';
authState.replayGainEnabled = true;
expect(isReplayGainActive()).toBe(false);
});
it('is false when engine is replaygain but flag is disabled', () => {
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = false;
expect(isReplayGainActive()).toBe(false);
});
it('is true only when engine is replaygain AND the flag is enabled', () => {
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = true;
expect(isReplayGainActive()).toBe(true);
});
it('is false when engine is loudness (different normalization mode)', () => {
authState.normalizationEngine = 'loudness';
authState.replayGainEnabled = true;
expect(isReplayGainActive()).toBe(false);
});
});
describe('_resetLoudnessGainCacheForTest', () => {
it('wipes both maps', () => {
markLoudnessStable('t1', -5);
markLoudnessStable('t2', -6);
_resetLoudnessGainCacheForTest();
expect(getCachedLoudnessGain('t1')).toBeUndefined();
expect(getCachedLoudnessGain('t2')).toBeUndefined();
expect(hasStableLoudness('t1')).toBe(false);
});
});
@@ -0,0 +1,92 @@
import { useAuthStore } from '@/store/authStore';
/**
* In-memory cache of the per-track loudness normalization gain (dB). Two
* parallel maps:
*
* - `cachedLoudnessGainByTrackId` — the dB value last computed (from an
* `analysis_get_loudness_for_track` row, a partial-loudness event, or
* a placeholder-until-cache value).
* - `stableLoudnessGainByTrackId` — `true` once the value has been
* promoted to the final cached/analysis-confirmed form. Engine bind
* only trusts entries flagged stable; partial / placeholder values
* deliberately omit the flag so Rust uses its pre-trim default until
* the analysis catches up.
*
* Keys can land in either the bare Subsonic id form or the `stream:`
* prefixed form depending on which event surface wrote the entry —
* `loudnessCacheStateKeysForTrackId` returns the two forms a caller may
* need to look up or clear together.
*/
const cachedLoudnessGainByTrackId: Record<string, number> = {};
const stableLoudnessGainByTrackId: Record<string, true> = {};
/** Returns the two-form key list (bare id + `stream:<id>`) for paired lookups. */
export function loudnessCacheStateKeysForTrackId(trackId: string): string[] {
if (!trackId) return [];
const out: string[] = [trackId];
if (trackId.startsWith('stream:')) {
const bare = trackId.slice('stream:'.length);
if (bare) out.push(bare);
} else {
out.push(`stream:${trackId}`);
}
return out;
}
export function getCachedLoudnessGain(trackId: string): number | undefined {
return cachedLoudnessGainByTrackId[trackId];
}
export function setCachedLoudnessGain(trackId: string, gainDb: number): void {
cachedLoudnessGainByTrackId[trackId] = gainDb;
}
export function hasStableLoudness(trackId: string): boolean {
return Boolean(stableLoudnessGainByTrackId[trackId]);
}
/** Atomic: write the cached value AND mark it stable (analysis-confirmed). */
export function markLoudnessStable(trackId: string, gainDb: number): void {
cachedLoudnessGainByTrackId[trackId] = gainDb;
stableLoudnessGainByTrackId[trackId] = true;
}
/** Drop both maps for the literal track id (no stream-form expansion). */
export function forgetLoudnessGain(trackId: string): void {
delete cachedLoudnessGainByTrackId[trackId];
delete stableLoudnessGainByTrackId[trackId];
}
/** Drop both maps for each form of the track id (bare + `stream:<id>`). */
export function clearLoudnessCacheStateForTrackId(trackId: string): void {
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
delete cachedLoudnessGainByTrackId[k];
delete stableLoudnessGainByTrackId[k];
}
}
/**
* Pass to `audio_play` / `audio_chain_preload` only — DB-backed gain. Omit
* partial hints so Rust uses pre-trim until `analysis:loudness-partial` +
* `audio_update_replay_gain`.
*/
export function loudnessGainDbForEngineBind(trackId: string | undefined | null): number | null {
if (!trackId) return null;
if (!stableLoudnessGainByTrackId[trackId]) return null;
const v = cachedLoudnessGainByTrackId[trackId];
return Number.isFinite(v) ? v : null;
}
/** True when ReplayGain is selected AND user has it enabled in Settings. */
export function isReplayGainActive(): boolean {
const a = useAuthStore.getState();
return a.normalizationEngine === 'replaygain' && a.replayGainEnabled;
}
/** Test-only: wipe both maps so each spec starts clean. */
export function _resetLoudnessGainCacheForTest(): void {
for (const k of Object.keys(cachedLoudnessGainByTrackId)) delete cachedLoudnessGainByTrackId[k];
for (const k of Object.keys(stableLoudnessGainByTrackId)) delete stableLoudnessGainByTrackId[k];
}
@@ -0,0 +1,80 @@
/**
* `prefetchLoudnessForEnqueuedTracks` warms the loudness cache for the
* current + next-N tracks after a bulk enqueue. Tests pin the engine
* guard, the window collection, and the no-sync-engine flag on each
* refresh call.
*/
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const auth = { normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness' };
const player = { currentTrack: null as Track | null };
return {
auth,
player,
refreshMock: vi.fn(async () => undefined),
collectMock: vi.fn((_q: QueueItemRef[], _i: number, _c: Track | null): string[] => []),
};
});
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: { getState: () => hoisted.player },
}));
vi.mock('@/features/playback/store/loudnessRefresh', () => ({
refreshLoudnessForTrack: hoisted.refreshMock,
}));
vi.mock('@/features/playback/store/loudnessBackfillWindow', () => ({
collectLoudnessBackfillWindowTrackIds: hoisted.collectMock,
}));
import { prefetchLoudnessForEnqueuedTracks } from '@/features/playback/store/loudnessPrefetch';
function track(id: string): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
}
// Thin-state: prefetchLoudnessForEnqueuedTracks takes queue refs now.
function ref(id: string): QueueItemRef {
return { serverId: 's', trackId: id };
}
beforeEach(() => {
hoisted.auth.normalizationEngine = 'loudness';
hoisted.player.currentTrack = null;
hoisted.refreshMock.mockClear();
hoisted.collectMock.mockReset();
hoisted.collectMock.mockReturnValue([]);
});
describe('prefetchLoudnessForEnqueuedTracks', () => {
it("is a no-op when engine isn't loudness", () => {
hoisted.auth.normalizationEngine = 'off';
hoisted.collectMock.mockReturnValueOnce(['t1']);
prefetchLoudnessForEnqueuedTracks([ref('t1')], 0);
expect(hoisted.refreshMock).not.toHaveBeenCalled();
expect(hoisted.collectMock).not.toHaveBeenCalled();
});
it('forwards each window id to refreshLoudnessForTrack with syncPlayingEngine=false', () => {
hoisted.collectMock.mockReturnValueOnce(['t1', 't2', 't3']);
prefetchLoudnessForEnqueuedTracks([ref('t1'), ref('t2'), ref('t3')], 0);
expect(hoisted.refreshMock).toHaveBeenCalledTimes(3);
expect(hoisted.refreshMock).toHaveBeenCalledWith('t1', { syncPlayingEngine: false });
expect(hoisted.refreshMock).toHaveBeenCalledWith('t2', { syncPlayingEngine: false });
expect(hoisted.refreshMock).toHaveBeenCalledWith('t3', { syncPlayingEngine: false });
});
it('passes the queue + currentTrack through to the window collector', () => {
hoisted.player.currentTrack = track('cur');
const q = [ref('cur'), ref('next')];
prefetchLoudnessForEnqueuedTracks(q, 0);
expect(hoisted.collectMock).toHaveBeenCalledWith(q, 0, hoisted.player.currentTrack);
});
it('handles empty window list gracefully', () => {
hoisted.collectMock.mockReturnValueOnce([]);
prefetchLoudnessForEnqueuedTracks([], 0);
expect(hoisted.refreshMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,27 @@
import type { QueueItemRef } from '@/features/playback/store/playerStoreTypes';
import { useAuthStore } from '@/store/authStore';
import { collectLoudnessBackfillWindowTrackIds } from '@/features/playback/store/loudnessBackfillWindow';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { usePlayerStore } from '@/features/playback/store/playerStore';
/**
* After a bulk enqueue (queue replace, append-many, lucky-mix) warm the
* loudness cache for the current track + the next N entries so the
* gapless `audio_chain_preload` payload sees a real cached gain instead
* of the startup trim. No-op when normalization isn't on `loudness` —
* other engines don't need the cache populated proactively.
*
* Calls don't sync the playing engine (`syncPlayingEngine: false`) — the
* already-playing track is unaffected; we're only filling the cache for
* the upcoming ones.
*/
export function prefetchLoudnessForEnqueuedTracks(
mergedQueue: QueueItemRef[],
queueIndex: number,
): void {
if (useAuthStore.getState().normalizationEngine !== 'loudness') return;
const currentTrack = usePlayerStore.getState().currentTrack;
const ids = collectLoudnessBackfillWindowTrackIds(mergedQueue, queueIndex, currentTrack);
for (const id of ids) {
void refreshLoudnessForTrack(id, { syncPlayingEngine: false });
}
}
@@ -0,0 +1,197 @@
/**
* `refreshLoudnessForTrack` orchestrates the loudness analysis fetch:
* coalesce concurrent calls, distinguish hit vs miss, enqueue backfill
* within bounds, suppress stale-target results. The individual helpers
* (cache, backfill state, window predicate, debug emit) are tested in
* their own modules — this file pins the orchestration only.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const auth = {
loudnessTargetLufs: -14,
normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness',
};
const playerState = {
queue: [] as Array<{ id: string }>,
queueIndex: 0,
currentTrack: null as { id: string } | null,
updateReplayGainForCurrentTrack: vi.fn(),
};
return {
auth,
playerState,
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as unknown),
buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`),
redactMock: vi.fn((s: string) => s),
playerSetStateMock: vi.fn(),
emitDebugMock: vi.fn(),
forgetLoudnessMock: vi.fn(),
markLoudnessStableMock: vi.fn(),
getBackfillAttemptsMock: vi.fn(() => 0),
isBackfillInFlightMock: vi.fn(() => false),
markBackfillInFlightMock: vi.fn(),
clearBackfillInFlightMock: vi.fn(),
resetBackfillAttemptsMock: vi.fn(),
isTrackInsideWindowMock: vi.fn(() => true),
};
});
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('@/lib/api/subsonicStreamUrl', () => ({ buildStreamUrl: hoisted.buildStreamUrlMock }));
vi.mock('@/utils/server/redactSubsonicUrl', () => ({ redactSubsonicUrlForLog: hoisted.redactMock }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: {
getState: () => hoisted.playerState,
setState: hoisted.playerSetStateMock,
},
}));
vi.mock('@/features/playback/store/normalizationDebug', () => ({ emitNormalizationDebug: hoisted.emitDebugMock }));
vi.mock('@/features/playback/store/loudnessGainCache', () => ({
forgetLoudnessGain: hoisted.forgetLoudnessMock,
markLoudnessStable: hoisted.markLoudnessStableMock,
}));
vi.mock('@/features/playback/store/loudnessBackfillState', () => ({
MAX_BACKFILL_ATTEMPTS_PER_TRACK: 2,
clearBackfillInFlight: hoisted.clearBackfillInFlightMock,
getBackfillAttempts: hoisted.getBackfillAttemptsMock,
isBackfillInFlight: hoisted.isBackfillInFlightMock,
markBackfillInFlight: hoisted.markBackfillInFlightMock,
resetBackfillAttempts: hoisted.resetBackfillAttemptsMock,
}));
vi.mock('@/features/playback/store/loudnessBackfillWindow', () => ({
LOUDNESS_BACKFILL_WINDOW_AHEAD: 5,
isTrackInsideLoudnessBackfillWindow: hoisted.isTrackInsideWindowMock,
loudnessBackfillPriorityForTrack: vi.fn(() => 'middle'),
}));
import {
_resetLoudnessRefreshInflightForTest,
refreshLoudnessForTrack,
} from '@/features/playback/store/loudnessRefresh';
beforeEach(() => {
_resetLoudnessRefreshInflightForTest();
hoisted.auth.loudnessTargetLufs = -14;
hoisted.auth.normalizationEngine = 'loudness';
hoisted.playerState.queue = [];
hoisted.playerState.queueIndex = 0;
hoisted.playerState.currentTrack = null;
hoisted.invokeMock.mockReset();
hoisted.invokeMock.mockResolvedValue(null);
hoisted.playerSetStateMock.mockClear();
hoisted.emitDebugMock.mockClear();
hoisted.forgetLoudnessMock.mockClear();
hoisted.markLoudnessStableMock.mockClear();
hoisted.markBackfillInFlightMock.mockClear();
hoisted.clearBackfillInFlightMock.mockClear();
hoisted.resetBackfillAttemptsMock.mockClear();
hoisted.getBackfillAttemptsMock.mockReset();
hoisted.getBackfillAttemptsMock.mockReturnValue(0);
hoisted.isBackfillInFlightMock.mockReset();
hoisted.isBackfillInFlightMock.mockReturnValue(false);
hoisted.isTrackInsideWindowMock.mockReset();
hoisted.isTrackInsideWindowMock.mockReturnValue(true);
hoisted.playerState.updateReplayGainForCurrentTrack = vi.fn();
});
describe('refreshLoudnessForTrack', () => {
it('is a no-op for empty trackId', async () => {
await refreshLoudnessForTrack('');
expect(hoisted.invokeMock).not.toHaveBeenCalled();
});
it('coalesces concurrent calls for the same key into one inflight promise', async () => {
hoisted.invokeMock.mockResolvedValue(null);
const p1 = refreshLoudnessForTrack('t1');
const p2 = refreshLoudnessForTrack('t1');
await Promise.all([p1, p2]);
// One analysis_get_loudness_for_track call shared between both awaiters.
const getCalls = hoisted.invokeMock.mock.calls.filter(c => c[0] === 'analysis_get_loudness_for_track');
expect(getCalls).toHaveLength(1);
});
it('marks loudness stable on a hit row', async () => {
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 123 });
await refreshLoudnessForTrack('t1');
expect(hoisted.markLoudnessStableMock).toHaveBeenCalledWith('t1', -7);
expect(hoisted.resetBackfillAttemptsMock).toHaveBeenCalledWith('t1');
});
it('forgets the cached value on a miss row', async () => {
hoisted.invokeMock.mockResolvedValueOnce(null);
await refreshLoudnessForTrack('t1');
expect(hoisted.forgetLoudnessMock).toHaveBeenCalledWith('t1');
});
it('enqueues a backfill when conditions are met (loudness engine, not inflight, attempts < max, in window)', async () => {
hoisted.invokeMock.mockResolvedValueOnce(null);
await refreshLoudnessForTrack('t1');
expect(hoisted.markBackfillInFlightMock).toHaveBeenCalledWith('t1', 1);
const enqueueCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'analysis_enqueue_seed_from_url');
expect(enqueueCall).toBeDefined();
});
it('skips backfill when outside the prefetch window', async () => {
hoisted.invokeMock.mockResolvedValueOnce(null);
hoisted.isTrackInsideWindowMock.mockReturnValueOnce(false);
await refreshLoudnessForTrack('t1');
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
const enqueueCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'analysis_enqueue_seed_from_url');
expect(enqueueCall).toBeUndefined();
});
it('skips backfill when attempts already at max', async () => {
hoisted.invokeMock.mockResolvedValueOnce(null);
hoisted.getBackfillAttemptsMock.mockReturnValueOnce(2);
await refreshLoudnessForTrack('t1');
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
const throttledCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'backfill:throttled');
expect(throttledCalls.length).toBeGreaterThan(0);
});
it('skips backfill when already inflight', async () => {
hoisted.invokeMock.mockResolvedValueOnce(null);
hoisted.isBackfillInFlightMock.mockReturnValueOnce(true);
await refreshLoudnessForTrack('t1');
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
});
it('discards results and retries when the LUFS target changes mid-flight', async () => {
hoisted.invokeMock.mockImplementationOnce(async () => {
hoisted.auth.loudnessTargetLufs = -10; // target changes during await
return { recommendedGainDb: -5, targetLufs: -14, updatedAt: 1 };
});
hoisted.invokeMock.mockResolvedValueOnce(null); // retry returns miss
await refreshLoudnessForTrack('t1');
// markLoudnessStable should NOT have been called from the first invocation —
// result is discarded because target changed.
expect(hoisted.markLoudnessStableMock).not.toHaveBeenCalled();
const staleCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'refresh:stale-target');
expect(staleCalls).toHaveLength(1);
// Drain pending recursive retries spawned via `void refreshLoudnessForTrack(...)`
// so they don't bleed into the next test's mock queue.
for (let i = 0; i < 10; i++) await Promise.resolve();
});
it('skips engine update when syncPlayingEngine is false', async () => {
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 1 });
await refreshLoudnessForTrack('t1', { syncPlayingEngine: false });
expect(hoisted.playerState.updateReplayGainForCurrentTrack).not.toHaveBeenCalled();
});
it('calls updateReplayGainForCurrentTrack by default on hit', async () => {
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 1 });
await refreshLoudnessForTrack('t1');
expect(hoisted.playerState.updateReplayGainForCurrentTrack).toHaveBeenCalledTimes(1);
});
it('forgets cache + emits refresh:error on a thrown invoke', async () => {
hoisted.invokeMock.mockRejectedValueOnce(new Error('rust busy'));
await refreshLoudnessForTrack('t1');
expect(hoisted.forgetLoudnessMock).toHaveBeenCalledWith('t1');
const errCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'refresh:error');
expect(errCalls).toHaveLength(1);
});
});
@@ -0,0 +1,157 @@
import { buildStreamUrl } from '@/lib/api/subsonicStreamUrl';
import { invoke } from '@tauri-apps/api/core';
import { getPlaybackIndexKey } from '@/features/playback/utils/playback/playbackServer';
import { redactSubsonicUrlForLog } from '@/utils/server/redactSubsonicUrl';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
import {
forgetLoudnessGain,
markLoudnessStable,
} from '@/features/playback/store/loudnessGainCache';
import {
MAX_BACKFILL_ATTEMPTS_PER_TRACK,
clearBackfillInFlight,
getBackfillAttempts,
isBackfillInFlight,
markBackfillInFlight,
resetBackfillAttempts,
} from '@/features/playback/store/loudnessBackfillState';
import {
LOUDNESS_BACKFILL_WINDOW_AHEAD,
isTrackInsideLoudnessBackfillWindow,
loudnessBackfillPriorityForTrack,
} from '@/features/playback/store/loudnessBackfillWindow';
/** Subsonic-server loudness-cache row as Rust hands it back. */
type LoudnessCachePayload = {
integratedLufs: number;
truePeak: number;
recommendedGainDb: number;
targetLufs: number;
updatedAt: number;
};
/**
* Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode
* pair. The `analysis:waveform-updated` listener fires refreshWaveform +
* refreshLoudness in parallel for every full-track analysis completion;
* without coalescing, gapless preload + current-track completion can
* stack two SQLite reads + two state writes.
*/
const loudnessRefreshInflight = new Map<string, Promise<void>>();
/**
* Fetch the loudness gain for `trackId` from Rust and apply it to the
* loudness-gain cache + player-store debug fields. When `syncPlayingEngine`
* is false (default true), the engine is NOT asked to update its
* replay-gain — used when prefetching neighbour tracks.
*
* Coalesces by (trackId, syncEngine, target) so concurrent calls share a
* single inflight promise.
*/
export async function refreshLoudnessForTrack(
trackId: string,
opts?: { syncPlayingEngine?: boolean },
): Promise<void> {
if (!trackId) return;
const syncEngine = opts?.syncPlayingEngine !== false;
const target = useAuthStore.getState().loudnessTargetLufs;
const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}|${target}`;
const existing = loudnessRefreshInflight.get(inflightKey);
if (existing) return existing;
const job = (async () => { await runRefreshLoudnessForTrack(trackId, syncEngine); })()
.finally(() => { loudnessRefreshInflight.delete(inflightKey); });
loudnessRefreshInflight.set(inflightKey, job);
return job;
}
async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): Promise<void> {
emitNormalizationDebug('refresh:start', { trackId });
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
try {
const requestedTarget = useAuthStore.getState().loudnessTargetLufs;
const serverId = getPlaybackIndexKey() || null;
const row = await invoke<LoudnessCachePayload | null>('analysis_get_loudness_for_track', {
trackId,
targetLufs: requestedTarget,
serverId,
});
if (useAuthStore.getState().loudnessTargetLufs !== requestedTarget) {
emitNormalizationDebug('refresh:stale-target', { trackId, requestedTarget });
void refreshLoudnessForTrack(trackId, { syncPlayingEngine: syncEngine });
return;
}
if (!row || !Number.isFinite(row.recommendedGainDb)) {
forgetLoudnessGain(trackId);
emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null });
const auth = useAuthStore.getState();
const attempts = getBackfillAttempts(trackId);
if (auth.normalizationEngine === 'loudness'
&& !isBackfillInFlight(trackId)
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
const live = usePlayerStore.getState();
if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queueItems, live.queueIndex, live.currentTrack)) {
emitNormalizationDebug('backfill:skipped-outside-window', {
trackId,
queueIndex: live.queueIndex,
aheadWindow: LOUDNESS_BACKFILL_WINDOW_AHEAD,
});
return;
}
markBackfillInFlight(trackId, attempts + 1);
const url = buildStreamUrl(trackId);
const priority = loudnessBackfillPriorityForTrack(
trackId,
live.queueItems,
live.queueIndex,
live.currentTrack,
);
emitNormalizationDebug('backfill:enqueue', {
trackId,
url: redactSubsonicUrlForLog(url),
attempt: attempts + 1,
priority,
});
void invoke('analysis_enqueue_seed_from_url', { trackId, url, serverId, priority })
.then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 }))
.catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) }))
.finally(() => {
clearBackfillInFlight(trackId);
});
} else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
emitNormalizationDebug('backfill:throttled', { trackId, attempts });
}
usePlayerStore.setState({
normalizationDbgSource: 'refresh:miss',
normalizationDbgTrackId: trackId,
normalizationDbgCacheGainDb: null,
normalizationDbgCacheTargetLufs: Number.isFinite(row?.targetLufs as number) ? (row?.targetLufs as number) : null,
normalizationDbgCacheUpdatedAt: Number.isFinite(row?.updatedAt as number) ? (row?.updatedAt as number) : null,
});
return;
}
markLoudnessStable(trackId, row.recommendedGainDb);
resetBackfillAttempts(trackId);
emitNormalizationDebug('refresh:hit', { trackId, row });
usePlayerStore.setState({
normalizationDbgSource: 'refresh:hit',
normalizationDbgTrackId: trackId,
normalizationDbgCacheGainDb: row.recommendedGainDb,
normalizationDbgCacheTargetLufs: Number.isFinite(row.targetLufs) ? row.targetLufs : null,
normalizationDbgCacheUpdatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : null,
});
if (syncEngine) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
} catch {
forgetLoudnessGain(trackId);
emitNormalizationDebug('refresh:error', { trackId });
usePlayerStore.setState({ normalizationDbgSource: 'refresh:error', normalizationDbgTrackId: trackId });
}
}
/** Test-only: drop pending refresh promises so each spec starts clean. */
export function _resetLoudnessRefreshInflightForTest(): void {
loudnessRefreshInflight.clear();
}
@@ -0,0 +1,151 @@
/**
* `reseedLoudnessForTrackId` orchestrates a full analysis re-run for one
* track: invalidate waveform gen, clear loudness + backfill state, wipe
* server rows, kick a forced seed. The orchestration is what's worth
* pinning — each individual helper is already tested in its own module.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const authState = {
normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness',
loudnessTargetLufs: -14,
};
const playerSnapshot: {
currentTrack: { id: string } | null;
updateReplayGainForCurrentTrack: ReturnType<typeof vi.fn>;
} = {
currentTrack: null,
updateReplayGainForCurrentTrack: vi.fn(),
};
return {
authState,
playerSnapshot,
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => undefined),
buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`),
bumpWaveformRefreshGenMock: vi.fn(),
clearLoudnessCacheMock: vi.fn(),
resetBackfillStateMock: vi.fn(),
playerSetStateMock: vi.fn(),
};
});
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('@/lib/api/subsonicStreamUrl', () => ({ buildStreamUrl: hoisted.buildStreamUrlMock }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.authState } }));
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: {
getState: () => hoisted.playerSnapshot,
setState: hoisted.playerSetStateMock,
},
}));
vi.mock('@/features/playback/store/waveformRefreshGen', () => ({
bumpWaveformRefreshGen: hoisted.bumpWaveformRefreshGenMock,
}));
vi.mock('@/features/playback/store/loudnessGainCache', () => ({
clearLoudnessCacheStateForTrackId: hoisted.clearLoudnessCacheMock,
}));
vi.mock('@/features/playback/store/loudnessBackfillState', () => ({
resetLoudnessBackfillStateForTrackId: hoisted.resetBackfillStateMock,
}));
import { reseedLoudnessForTrackId } from '@/features/playback/store/loudnessReseed';
beforeEach(() => {
hoisted.authState.normalizationEngine = 'loudness';
hoisted.authState.loudnessTargetLufs = -14;
hoisted.playerSnapshot.currentTrack = null;
hoisted.invokeMock.mockReset();
hoisted.invokeMock.mockResolvedValue(undefined);
hoisted.buildStreamUrlMock.mockClear();
hoisted.bumpWaveformRefreshGenMock.mockClear();
hoisted.clearLoudnessCacheMock.mockClear();
hoisted.resetBackfillStateMock.mockClear();
hoisted.playerSetStateMock.mockClear();
hoisted.playerSnapshot.updateReplayGainForCurrentTrack = vi.fn();
});
describe('reseedLoudnessForTrackId', () => {
it('is a no-op for empty trackId', async () => {
await reseedLoudnessForTrackId('');
expect(hoisted.invokeMock).not.toHaveBeenCalled();
expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled();
});
it("is a no-op when normalization engine isn't loudness", async () => {
hoisted.authState.normalizationEngine = 'off';
await reseedLoudnessForTrackId('t1');
expect(hoisted.invokeMock).not.toHaveBeenCalled();
expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled();
});
it('runs the full reseed pipeline in order', async () => {
await reseedLoudnessForTrackId('t1');
expect(hoisted.bumpWaveformRefreshGenMock).toHaveBeenCalledWith('t1');
expect(hoisted.clearLoudnessCacheMock).toHaveBeenCalledWith('t1');
expect(hoisted.resetBackfillStateMock).toHaveBeenCalledWith('t1');
const invokeCalls = hoisted.invokeMock.mock.calls.map(c => c[0]);
expect(invokeCalls).toEqual([
'analysis_delete_waveform_for_track',
'analysis_delete_loudness_for_track',
'analysis_enqueue_seed_from_url',
]);
expect(hoisted.invokeMock.mock.calls[2][1]).toEqual({
trackId: 't1',
url: 'https://mock/stream/t1',
force: true,
serverId: null,
});
});
it('blanks the seekbar only when the reseed target is the current track', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
await reseedLoudnessForTrackId('t1');
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
expect(setStateCalls).toContainEqual({ waveformBins: null });
});
it('does NOT blank the seekbar when reseeding a different track', async () => {
hoisted.playerSnapshot.currentTrack = { id: 'other' };
await reseedLoudnessForTrackId('t1');
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
expect(setStateCalls).not.toContainEqual({ waveformBins: null });
});
it('resets live normalization-state to placeholder values', async () => {
hoisted.authState.loudnessTargetLufs = -10;
await reseedLoudnessForTrackId('t1');
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
expect(setStateCalls).toContainEqual({
normalizationNowDb: null,
normalizationTargetLufs: -10,
normalizationEngineLive: 'loudness',
});
});
it('continues past errors in delete-waveform', async () => {
hoisted.invokeMock
.mockRejectedValueOnce(new Error('waveform delete failed'))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
await reseedLoudnessForTrackId('t1');
expect(hoisted.invokeMock).toHaveBeenCalledTimes(3);
});
it('continues past errors in delete-loudness', async () => {
hoisted.invokeMock
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('loudness delete failed'))
.mockResolvedValueOnce(undefined);
await reseedLoudnessForTrackId('t1');
expect(hoisted.invokeMock).toHaveBeenCalledTimes(3);
});
it('swallows the final enqueue-seed error', async () => {
hoisted.invokeMock
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('enqueue failed'));
await expect(reseedLoudnessForTrackId('t1')).resolves.toBeUndefined();
});
});
@@ -0,0 +1,69 @@
import { buildStreamUrl } from '@/lib/api/subsonicStreamUrl';
import { invoke } from '@tauri-apps/api/core';
import { getPlaybackIndexKey } from '@/features/playback/utils/playback/playbackServer';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { bumpWaveformRefreshGen } from '@/features/playback/store/waveformRefreshGen';
import { clearLoudnessCacheStateForTrackId } from '@/features/playback/store/loudnessGainCache';
import { resetLoudnessBackfillStateForTrackId } from '@/features/playback/store/loudnessBackfillState';
/**
* Tear down every cached piece of analysis for a track and re-enqueue a
* forced reseed. Used by the Settings „Re-analyse this track" action.
*
* Sequence:
* 1. Skip if loudness engine isn't active (re-analysis only makes sense
* when normalization actually consumes the result).
* 2. Invalidate the waveform refresh generation so any in-flight read
* for this id is discarded, and blank the seekbar bins immediately if
* the track is current.
* 3. Wipe both loudness-cache maps (both forms of the id) + the backfill
* retry state.
* 4. Reset the live normalization-state to placeholder values so the UI
* doesn't show stale dB until the new analysis lands.
* 5. Delete the on-disk waveform + loudness rows via Rust.
* 6. Re-issue `updateReplayGainForCurrentTrack` so the engine drops
* whatever gain it was holding for this track.
* 7. Enqueue a forced seed via `analysis_enqueue_seed_from_url`.
*
* Best-effort throughout — Rust errors are logged but never thrown.
*/
export async function reseedLoudnessForTrackId(trackId: string): Promise<void> {
if (!trackId) return;
const auth = useAuthStore.getState();
if (auth.normalizationEngine !== 'loudness') return;
bumpWaveformRefreshGen(trackId);
if (usePlayerStore.getState().currentTrack?.id === trackId) {
usePlayerStore.setState({ waveformBins: null });
}
clearLoudnessCacheStateForTrackId(trackId);
resetLoudnessBackfillStateForTrackId(trackId);
usePlayerStore.setState({
normalizationNowDb: null,
normalizationTargetLufs: auth.loudnessTargetLufs,
normalizationEngineLive: 'loudness',
});
const serverId = getPlaybackIndexKey() || null;
try {
await invoke('analysis_delete_waveform_for_track', { trackId, serverId });
} catch (e) {
console.error('[psysonic] analysis_delete_waveform_for_track failed:', e);
}
try {
await invoke('analysis_delete_loudness_for_track', { trackId, serverId });
} catch (e) {
console.error('[psysonic] analysis_delete_loudness_for_track failed:', e);
}
usePlayerStore.getState().updateReplayGainForCurrentTrack();
const url = buildStreamUrl(trackId);
try {
await invoke('analysis_enqueue_seed_from_url', {
trackId,
url,
force: true,
serverId,
});
} catch (e) {
console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e);
}
}
+180
View File
@@ -0,0 +1,180 @@
import { applyServerPlayQueue } from '@/features/playback/store/applyServerPlayQueue';
import { invoke } from '@tauri-apps/api/core';
import i18n from '@/lib/i18n';
import { showToast } from '@/utils/ui/toast';
import { useAuthStore } from '@/store/authStore';
import {
bumpPlayGeneration,
setIsAudioPaused,
} from '@/features/playback/store/engineState';
import { clearPreloadingIds } from '@/features/playback/store/gaplessPreloadState';
import { reseedLoudnessForTrackId } from '@/features/playback/store/loudnessReseed';
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
import { shouldRebindPlaybackToHotCache } from '@/features/playback/store/playbackUrlRouting';
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
import { syncUserQueueMutationToServer } from '@/features/playback/store/queueSync';
import {
clearRadioReconnectTimer,
playRadioStream,
setRadioVolume,
} from '@/features/playback/store/radioPlayer';
import { clearAllPlaybackScheduleTimers } from '@/features/playback/store/scheduleTimers';
import { clearSeekDebounce } from '@/features/playback/store/seekDebounce';
import {
clearSeekFallbackRetry,
setSeekFallbackVisualTarget,
} from '@/features/playback/store/seekFallbackState';
import { clearSeekTarget } from '@/features/playback/store/seekTargetState';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
type GetState = () => PlayerState;
/**
* Heterogeneous "misc" cluster — seven small-to-medium actions that
* don't fit the more focused factories (transport / queue / Last.fm /
* UI state):
*
* - `playRadio` — switches the player into HTML5 radio mode (Rust
* engine stopped, queue cleared, ICY stream resolved + played).
* - `previous` — Subsonic-style back: restart current track if past
* 3 s, otherwise jump to the previous queue index.
* - `setVolume` — clamps + propagates to Rust engine and radio sink.
* - `setProgress` — pure UI state update used by progress polling.
* - `initializeFromServerQueue` — startup queue restore from
* Navidrome's `getPlayQueue` endpoint.
* - `reanalyzeLoudnessForTrack` — toast + reseed the loudness cache
* for a single track.
* - `reseedQueueForInstantMix` — replaces the queue with a single
* track when "Instant Mix" is triggered on the currently-playing
* song.
*/
export function createMiscActions(set: SetState, get: GetState): Pick<
PlayerState,
| 'playRadio'
| 'previous'
| 'setVolume'
| 'setProgress'
| 'initializeFromServerQueue'
| 'reanalyzeLoudnessForTrack'
| 'reseedQueueForInstantMix'
> {
return {
playRadio: async (station) => {
const { volume } = get();
bumpPlayGeneration();
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
setIsAudioPaused(false);
clearRadioReconnectTimer();
clearPreloadingIds();
clearSeekFallbackRetry();
clearSeekDebounce(); clearSeekTarget();
// Stop Rust engine in case a regular track was playing.
invoke('audio_stop').catch(() => {});
// Resolve PLS/M3U playlist URLs to the actual stream URL before handing
// to HTML5 <audio> — the browser cannot play playlist files directly.
const streamUrl = await invoke<string>('resolve_stream_url', { url: station.streamUrl })
.catch(() => station.streamUrl);
const { replayGainFallbackDb } = useAuthStore.getState();
const fallbackFactor = replayGainFallbackDb !== 0 ? Math.pow(10, replayGainFallbackDb / 20) : 1;
playRadioStream(streamUrl, Math.min(1, volume * fallbackFactor)).catch((err: unknown) => {
console.error('[psysonic] radio HTML5 play failed:', err);
showToast('Radio stream error', 3000, 'error');
set({ isPlaying: false, currentRadio: null });
});
set({
currentRadio: station,
currentTrack: null,
waveformBins: null,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
currentPlaybackSource: null,
queueItems: [],
queueIndex: 0,
isPlaying: true,
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: true, // no scrobbling for radio
});
},
previous: () => {
const { queueItems, queueIndex, currentTrack } = get();
const currentTime = getPlaybackProgressSnapshot().currentTime;
if (currentTime > 3) {
// Restart current track from the beginning.
const authState = useAuthStore.getState();
const sid = authState.activeServerId ?? '';
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() });
// No-arg queue: keep the canonical refs, restart in place.
get().playTrack(currentTrack, undefined, true);
return;
}
invoke('audio_seek', { seconds: 0 }).catch(console.error);
set({ progress: 0, currentTime: 0 });
return;
}
const prevIdx = queueIndex - 1;
if (prevIdx >= 0 && queueItems[prevIdx]) {
// Resolve the previous ref (resolver cache → placeholder); pass undefined
// for the queue arg so playTrack just moves the index.
get().playTrack(resolveQueueTrack(queueItems[prevIdx]), undefined, true, false, prevIdx);
}
},
setVolume: (v) => {
const clamped = Math.max(0, Math.min(1, v));
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
setRadioVolume(clamped);
set({ volume: clamped });
},
setProgress: (t, duration) => {
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
},
initializeFromServerQueue: async () => {
const activeId = useAuthStore.getState().activeServerId;
if (!activeId) return;
await applyServerPlayQueue(activeId, { mode: 'startup' });
},
reanalyzeLoudnessForTrack: async (trackId: string) => {
try {
showToast(i18n.t('queue.recalculatingLoudnessWaveform'), 2000, 'info');
} catch {
// no-op
}
await reseedLoudnessForTrackId(trackId);
},
reseedQueueForInstantMix: (track) => {
const s = get();
if (s.currentTrack?.id !== track.id) {
get().playTrack(track, [track]);
return;
}
pushQueueUndoFromGetter(get);
const wasPlaying = s.isPlaying;
const sid = s.queueServerId ?? '';
if (sid) seedQueueResolver(sid, [track]);
const newItems = toQueueItemRefs(sid, [track]);
set({
queueItems: newItems,
queueIndex: 0,
currentTrack: track,
});
syncUserQueueMutationToServer(newItems, track, s.currentTime);
if (!wasPlaying) get().resume();
},
};
}
@@ -0,0 +1,65 @@
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
type GetState = () => PlayerState;
/**
* Loved-track actions routed through the Music Network runtime (enrichment
* primary). `networkLovedCache` is keyed by `${title}::${artist}` (not track id)
* so other queue rows showing the same song update too. `syncNetworkLovedTracks`
* merges the primary's loved list with the local cache — local likes win.
*
* The love write itself is best-effort on the runtime; the cache update is
* optimistic so the UI reflects the toggle immediately.
*/
export function createNetworkLoveActions(set: SetState, get: GetState): Pick<
PlayerState,
'toggleNetworkLove' | 'setNetworkLoved' | 'setNetworkLovedForSong' | 'syncNetworkLovedTracks'
> {
return {
toggleNetworkLove: () => {
const { currentTrack, networkLoved } = get();
const runtime = getMusicNetworkRuntimeOrNull();
if (!currentTrack || !runtime?.getEnrichmentPrimaryId()) return;
const newLoved = !networkLoved;
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
set(s => ({ networkLoved: newLoved, networkLovedCache: { ...s.networkLovedCache, [cacheKey]: newLoved } }));
void runtime.setTrackLoved({ title: currentTrack.title, artist: currentTrack.artist }, newLoved);
},
setNetworkLoved: (v) => {
const { currentTrack } = get();
if (currentTrack) {
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
set(s => ({ networkLoved: v, networkLovedCache: { ...s.networkLovedCache, [cacheKey]: v } }));
} else {
set({ networkLoved: v });
}
},
syncNetworkLovedTracks: async () => {
const runtime = getMusicNetworkRuntimeOrNull();
if (!runtime?.getEnrichmentPrimaryId()) return;
const newCache = await runtime.syncLovedTracks();
// Merge with existing cache (local likes take precedence).
set(s => ({ networkLovedCache: { ...newCache, ...s.networkLovedCache } }));
const { currentTrack } = get();
if (currentTrack) {
const loved = newCache[`${currentTrack.title}::${currentTrack.artist}`] ?? false;
set({ networkLoved: loved });
}
},
setNetworkLovedForSong: (title, artist, v) => {
const cacheKey = `${title}::${artist}`;
const isCurrentTrack = get().currentTrack?.title === title && get().currentTrack?.artist === artist;
set(s => ({
networkLovedCache: { ...s.networkLovedCache, [cacheKey]: v },
...(isCurrentTrack ? { networkLoved: v } : {}),
}));
},
};
}
@@ -0,0 +1,67 @@
/**
* Loved-track cache keyed by `${title}::${artist}` — provider-agnostic (the key
* format predates and survives the Music Network rename). Kept out of the main
* `psysonic-player` blob so a large queue cannot block writes (thin-state #872).
*
* Migration: reads fall back from the current key to the legacy
* `psysonic_lastfm_loved_cache`, then to the legacy player blob, so no loved
* state is lost across the Last.fm → Music Network rename.
*/
const CACHE_STORAGE_KEY = 'psysonic_network_loved_cache';
const LEGACY_CACHE_STORAGE_KEY = 'psysonic_lastfm_loved_cache';
const LEGACY_PLAYER_STORAGE_KEY = 'psysonic-player';
function sanitizeCache(raw: unknown): Record<string, boolean> {
if (!raw || typeof raw !== 'object') return {};
const out: Record<string, boolean> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (typeof key === 'string' && key.length > 0 && typeof value === 'boolean') {
out[key] = value;
}
}
return out;
}
function readKey(key: string): Record<string, boolean> | null {
try {
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const sanitized = sanitizeCache(JSON.parse(raw));
return Object.keys(sanitized).length > 0 ? sanitized : null;
} catch {
return null;
}
}
function readLegacyCacheFromPlayerBlob(): Record<string, boolean> | null {
try {
const raw = window.localStorage.getItem(LEGACY_PLAYER_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: { lastfmLovedCache?: unknown; networkLovedCache?: unknown } };
const cache = parsed.state?.networkLovedCache ?? parsed.state?.lastfmLovedCache;
if (!cache) return null;
const sanitized = sanitizeCache(cache);
return Object.keys(sanitized).length > 0 ? sanitized : null;
} catch {
return null;
}
}
export function readInitialNetworkLovedCache(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
return (
readKey(CACHE_STORAGE_KEY)
?? readKey(LEGACY_CACHE_STORAGE_KEY)
?? readLegacyCacheFromPlayerBlob()
?? {}
);
}
export function persistNetworkLovedCache(cache: Record<string, boolean>): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(sanitizeCache(cache)));
} catch {
// best-effort
}
}
+285
View File
@@ -0,0 +1,285 @@
import { getSimilarSongs2, getTopSongs } from '@/lib/api/subsonicArtists';
import { invoke } from '@tauri-apps/api/core';
import { buildInfiniteQueueCandidates } from '@/features/playback/utils/playback/buildInfiniteQueueCandidates';
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
import { ensureQueueServerPinned } from '@/features/playback/utils/playback/playbackServer';
import { useAuthStore } from '@/store/authStore';
import { setIsAudioPaused } from '@/features/playback/store/engineState';
import {
isInfiniteQueueFetching,
setInfiniteQueueFetching,
} from '@/features/playback/store/infiniteQueueState';
import { isInOrbitSession } from '@/store/orbitRuntime';
import type { PlayerState, QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
import {
addRadioSessionSeen,
getCurrentRadioArtistId,
hasRadioSessionSeen,
isRadioFetching,
setRadioFetching,
} from '@/features/playback/store/radioSessionState';
import { finalizePlayQueueAtTrackEnd } from '@/features/playback/store/queueSync';
import { applySkipStarOnManualNext } from '@/features/playback/store/skipStarRating';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
type GetState = () => PlayerState;
/**
* Queue-exhausted radio / infinite-queue refill: append the freshly fetched
* tracks to the canonical `queueItems`, seed the resolver with them (so they
* resolve without a network round-trip), then play the first appended track at
* its new tail index. Refs in / Track for the play call only — thin-state.
*/
function appendTracksAndPlayFirst(set: SetState, get: GetState, fresh: Track[]): void {
if (fresh.length === 0) return;
// Pin the server *before* reading state so the appended refs (and the
// resolver seed) carry the canonical server key — otherwise queue rows for
// the appended tracks render as the resolver placeholder. See PR #892.
const serverId = ensureQueueServerPinned();
const state = get();
if (serverId) seedQueueResolver(serverId, fresh);
const incoming: QueueItemRef[] = toQueueItemRefs(serverId, fresh);
const playAt = state.queueItems.length;
// Append the refs first so playTrack (queue arg undefined) reads them off the
// canonical list and its targetQueueIndex validates against the new tail.
set({ queueItems: [...state.queueItems, ...incoming] });
get().playTrack(fresh[0], undefined, false, false, playAt);
}
/** Repeat-off queue tail: stop transport and finalize server play queue at EOF. */
function stopAtNaturalQueueEnd(set: SetState, get: GetState): void {
const { currentTrack, queueItems } = get();
if (currentTrack && queueItems.length > 0) {
void finalizePlayQueueAtTrackEnd(queueItems, currentTrack);
}
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
}
/**
* Advance to the next track. Three top-level outcomes:
*
* 1. **Has next slot** — `playTrack` the queue's `queueIndex + 1`,
* then proactively top up auto-added (infinite-queue) and
* radio-added tracks when ≤ 2 of each remain ahead. Both top-ups
* are skipped inside an Orbit session — the host owns the queue,
* and a silent local extension would drift the guest off the host
* or pop the bulk-add modal at the next track-end fallback.
*
* 2. **Queue exhausted, repeat=all** — wrap back to index 0.
*
* 3. **Queue exhausted, repeat=off** — stop, unless:
* - The current track is radio-flagged → fetch a fresh radio batch
* and continue.
* - Infinite queue is enabled → fetch more candidates and continue.
* - Orbit session active → stop locally and let `useOrbitGuest`
* sync to the host's next track.
*/
export function runNext(set: SetState, get: GetState, manual: boolean): void {
const { queueItems, queueIndex, repeatMode, currentTrack } = get();
applySkipStarOnManualNext(currentTrack, manual);
const nextIdx = queueIndex + 1;
if (nextIdx < queueItems.length) {
// Resolver bridge keeps the [queueIndex-50, +200] window warm, so the next
// ref is cache-hot here; resolveQueueTrack falls back to a placeholder
// (carrying the correct trackId) on a cold miss so playback still starts.
const nextRef = queueItems[nextIdx];
const nextTrack = resolveQueueTrack(nextRef);
get().playTrack(nextTrack, undefined, manual, false, nextIdx);
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
// so the queue never runs dry without a visible loading pause.
// Skipped while in Orbit — the host's queue is the source of
// truth there, and any silent local extension would either
// drift this client off the host or pop the bulk-add modal at
// the next track-end fallback.
const { infiniteQueueEnabled } = useAuthStore.getState();
if (infiniteQueueEnabled && repeatMode === 'off' && !isInfiniteQueueFetching() && !isInOrbitSession()) {
const remainingAuto = queueItems.slice(nextIdx + 1).filter(r => r.autoAdded).length;
if (remainingAuto <= 2) {
setInfiniteQueueFetching(true);
const existingIds = new Set(get().queueItems.map(r => r.trackId));
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
// Re-check at resolution time — the user may have joined
// an Orbit session between scheduling and resolving.
if (isInOrbitSession()) return;
if (newTracks.length > 0) {
// Pin before set so the appended refs carry the canonical server
// key; without this the auto-added rows render as '…' / 0:00
// when the queue was populated without a queue-replacing playTrack
// (see PR #892).
const serverId = ensureQueueServerPinned();
set(state => {
if (serverId) seedQueueResolver(serverId, newTracks);
const newItems = [...state.queueItems, ...toQueueItemRefs(serverId, newTracks)];
return { queueItems: newItems };
});
}
}).catch(() => {}).finally(() => { setInfiniteQueueFetching(false); });
}
}
// Proactively top up radio tracks when ≤ 2 remain — independent of the
// infinite-queue setting, but still skipped in Orbit: the radio top-up
// appends unrelated tracks and trims queue history, which would drift a
// guest off the host's playlist (same rationale as the infinite-queue
// branch above).
if (nextRef.radioAdded && !isRadioFetching() && !isInOrbitSession()) {
const remainingRadio = queueItems.slice(nextIdx + 1).filter(r => r.radioAdded).length;
if (remainingRadio <= 2) {
// H2: nextTrack may be a placeholder if its ref is still cold — empty
// artist/artistId would seed `getSimilarSongs2('')` and silently
// return nothing, leaving radio dry. Prefer the just-played
// currentTrack (always fully resolved in playerStore) and the stored
// radio seed artist; fall back to nextTrack metadata only when those
// are missing. Skip the top-up entirely when no stable seed exists
// rather than firing a non-deterministic empty request.
const seedArtistId =
currentTrack?.artistId
?? getCurrentRadioArtistId()
?? nextTrack.artistId
?? null;
const seedArtistName = currentTrack?.artist || nextTrack.artist;
if (seedArtistId && seedArtistName) {
setRadioFetching(true);
Promise.all([getSimilarSongs2(seedArtistId), getTopSongs(seedArtistName)])
.then(([similar, top]) => {
// Re-check — the user may have joined an Orbit session between
// scheduling this fetch and its resolution (mirrors the
// infinite-queue branch). The finally() still clears the flag.
if (isInOrbitSession()) return;
const existingIds = new Set(get().queueItems.map(r => r.trackId));
// Lead with similar (other artists) for variety; top tracks
// of the upcoming artist are only a fallback when similar
// is empty. Single-pass loop dedupes against the live queue,
// the session seen-set, and intra-batch overlap (issue #500).
const sourceList = similar.length > 0 ? similar : top;
const fresh: Track[] = [];
for (const raw of sourceList) {
if (fresh.length >= 10) break;
const t = songToTrack(raw);
if (existingIds.has(t.id) || hasRadioSessionSeen(t.id)) continue;
addRadioSessionSeen(t.id);
fresh.push({ ...t, radioAdded: true as const });
}
if (fresh.length > 0) {
// Trim played tracks from the front to keep the queue bounded.
// Without trimming the queue grows unboundedly, making every
// Zustand persist write larger and causing UI freezes over time.
// Keep the last HISTORY_KEEP played tracks so the user can still
// navigate backwards a few songs. Trimmed ids stay in the seen-set.
const HISTORY_KEEP = 5;
// Pin before set; same reasoning as the infinite top-up above.
const serverId = ensureQueueServerPinned();
set(state => {
if (serverId) seedQueueResolver(serverId, fresh);
const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP);
const newItems = [
...state.queueItems.slice(trimStart),
...toQueueItemRefs(serverId, fresh),
];
return {
queueItems: newItems,
queueIndex: state.queueIndex - trimStart,
};
});
}
})
.catch(() => {})
.finally(() => { setRadioFetching(false); });
}
}
}
} else if (repeatMode === 'all' && queueItems.length > 0) {
const firstTrack = resolveQueueTrack(queueItems[0]);
get().playTrack(firstTrack, undefined, manual, false, 0);
} else {
// ── Orbit short-circuit ──
// The host owns the shared queue. The radio / infinite-queue
// fallbacks below would either pop the orbitBulkGuard modal (with a
// 6-track add) or silently inject unrelated tracks into the local
// player and drift the guest off the host. Stop instead and let the
// next pull tick in `useOrbitGuest` sync to the host's next track.
// Covers any active orbit phase (`active` / `joining` / `starting`)
// so a fetch scheduled mid-join doesn't slip through.
if (isInOrbitSession()) {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return;
}
// Queue exhausted. Check radio first (independent of infinite queue setting),
// then infinite queue, then stop.
if (currentTrack?.radioAdded && !isRadioFetching()) {
const artistId = currentTrack.artistId ?? getCurrentRadioArtistId() ?? null;
if (artistId) {
setRadioFetching(true);
Promise.all([getSimilarSongs2(artistId), getTopSongs(currentTrack.artist)])
.then(([similar, top]) => {
setRadioFetching(false);
// The user may have joined an Orbit session while this
// fetch was in flight — bail without touching the queue.
if (isInOrbitSession()) {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return;
}
const existingIds = new Set(get().queueItems.map(r => r.trackId));
// Same source preference + dedup contract as the proactive
// top-up: similar first, top only as a fallback (issue #500).
const sourceList = similar.length > 0 ? similar : top;
const fresh: Track[] = [];
for (const raw of sourceList) {
if (fresh.length >= 10) break;
const t = songToTrack(raw);
if (existingIds.has(t.id) || hasRadioSessionSeen(t.id)) continue;
addRadioSessionSeen(t.id);
fresh.push({ ...t, radioAdded: true as const });
}
if (fresh.length > 0) {
appendTracksAndPlayFirst(set, get, fresh);
} else {
stopAtNaturalQueueEnd(set, get);
}
})
.catch(() => {
setRadioFetching(false);
stopAtNaturalQueueEnd(set, get);
});
return;
}
}
const { infiniteQueueEnabled } = useAuthStore.getState();
if (infiniteQueueEnabled && repeatMode === 'off') {
if (isInfiniteQueueFetching()) return;
setInfiniteQueueFetching(true);
const existingIds = new Set(get().queueItems.map(r => r.trackId));
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
setInfiniteQueueFetching(false);
// The user may have joined an Orbit session while this
// fetch was in flight — bail without invoking playTrack.
if (isInOrbitSession()) {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return;
}
if (newTracks.length === 0) {
stopAtNaturalQueueEnd(set, get);
return;
}
appendTracksAndPlayFirst(set, get, newTracks);
}).catch(() => {
setInfiniteQueueFetching(false);
stopAtNaturalQueueEnd(set, get);
});
} else {
stopAtNaturalQueueEnd(set, get);
}
}
}
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Control points for the test.
const { inOrbit, getSimilarSongs2, getTopSongs } = vi.hoisted(() => ({
inOrbit: { value: false },
getSimilarSongs2: vi.fn(() => Promise.resolve([])),
getTopSongs: vi.fn(() => Promise.resolve([])),
}));
vi.mock('@/lib/api/subsonicArtists', () => ({ getSimilarSongs2, getTopSongs }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(() => Promise.resolve()) }));
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
isInOrbitSession: () => inOrbit.value,
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: { getState: () => ({ infiniteQueueEnabled: false }) },
}));
vi.mock('@/features/playback/store/radioSessionState', () => ({
addRadioSessionSeen: vi.fn(),
getCurrentRadioArtistId: () => null,
hasRadioSessionSeen: () => false,
isRadioFetching: () => false,
setRadioFetching: vi.fn(),
}));
vi.mock('@/features/playback/store/infiniteQueueState', () => ({
isInfiniteQueueFetching: () => false,
setInfiniteQueueFetching: vi.fn(),
}));
vi.mock('@/features/playback/store/engineState', () => ({ setIsAudioPaused: vi.fn() }));
vi.mock('@/features/playback/store/skipStarRating', () => ({ applySkipStarOnManualNext: vi.fn() }));
vi.mock('@/utils/library/queueTrackView', () => ({
resolveQueueTrack: (ref: { trackId: string }) => ({
id: ref.trackId,
artistId: 'a1',
artist: 'Artist',
}),
}));
vi.mock('@/features/playback/utils/playback/buildInfiniteQueueCandidates', () => ({
buildInfiniteQueueCandidates: vi.fn(() => Promise.resolve([])),
}));
vi.mock('@/features/playback/utils/playback/songToTrack', () => ({ songToTrack: (s: unknown) => s }));
vi.mock('@/features/playback/utils/playback/playbackServer', () => ({ ensureQueueServerPinned: () => null }));
vi.mock('@/utils/library/queueTrackResolver', () => ({ seedQueueResolver: vi.fn() }));
vi.mock('@/utils/library/queueItemRef', () => ({ toQueueItemRefs: () => [] }));
import { runNext } from '@/features/playback/store/nextAction';
function fakeGet() {
// index 0 → next is the radioAdded ref at index 1; nothing radio ahead of it,
// so the ≤2-remaining proactive top-up is eligible.
const queueItems = [
{ trackId: 't0', radioAdded: true },
{ trackId: 't1', radioAdded: true },
{ trackId: 't2', radioAdded: false },
];
return {
queueItems,
queueIndex: 0,
repeatMode: 'off' as const,
currentTrack: { id: 't0', artistId: 'a1', artist: 'Artist', radioAdded: true },
playTrack: vi.fn(),
};
}
beforeEach(() => {
inOrbit.value = false;
getSimilarSongs2.mockClear();
getTopSongs.mockClear();
});
describe('runNext — radio proactive top-up Orbit lockout', () => {
it('fires the radio top-up when not in an Orbit session', () => {
const get = fakeGet as unknown as () => never;
runNext(vi.fn(), get, /* manual */ false);
expect(getSimilarSongs2).toHaveBeenCalledTimes(1);
});
it('skips the radio top-up while in an Orbit session', () => {
inOrbit.value = true;
const get = fakeGet as unknown as () => never;
runNext(vi.fn(), get, /* manual */ false);
expect(getSimilarSongs2).not.toHaveBeenCalled();
expect(getTopSongs).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { authState, invokeMock } = vi.hoisted(() => ({
authState: { loggingMode: 'off' as 'off' | 'debug' | string },
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => undefined),
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => authState } }));
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
beforeEach(() => {
authState.loggingMode = 'off';
invokeMock.mockClear();
invokeMock.mockResolvedValue(undefined);
});
describe('emitNormalizationDebug', () => {
it('is a no-op when logging mode is not debug', () => {
emitNormalizationDebug('refresh:start', { trackId: 't1' });
expect(invokeMock).not.toHaveBeenCalled();
});
it('forwards a JSON payload to frontend_debug_log in debug mode', () => {
authState.loggingMode = 'debug';
emitNormalizationDebug('refresh:start', { trackId: 't1' });
expect(invokeMock).toHaveBeenCalledTimes(1);
const [cmd, args] = invokeMock.mock.calls[0];
expect(cmd).toBe('frontend_debug_log');
expect(args).toMatchObject({
scope: 'normalization',
message: JSON.stringify({ step: 'refresh:start', details: { trackId: 't1' } }),
});
});
it('serializes calls without details too', () => {
authState.loggingMode = 'debug';
emitNormalizationDebug('plain-step');
const args = invokeMock.mock.calls[0][1] as { message: string };
expect(JSON.parse(args.message)).toEqual({ step: 'plain-step' });
});
it('swallows invoke rejections (best-effort instrumentation)', async () => {
authState.loggingMode = 'debug';
invokeMock.mockRejectedValueOnce(new Error('rust busy'));
expect(() => emitNormalizationDebug('refresh:start')).not.toThrow();
// Give the rejected promise a tick to settle without throwing.
await Promise.resolve();
});
});
@@ -0,0 +1,19 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
/**
* Forward a structured normalization-pipeline trace to the Rust-side debug
* log file when Settings → Logging is set to **Debug**. A no-op otherwise,
* so the dozens of call sites in `playerStore` (refresh / backfill / engine
* sync / track-switch instrumentation) carry zero cost in normal mode.
*
* Errors invoking the Rust command are swallowed — this is best-effort
* instrumentation, not a playback dependency.
*/
export function emitNormalizationDebug(step: string, details?: Record<string, unknown>): void {
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', {
scope: 'normalization',
message: JSON.stringify({ step, details }),
}).catch(() => {});
}
@@ -0,0 +1,130 @@
/**
* IPC dedupers — each helper collapses repeat calls within a time-bounded
* window. The interesting behaviour is the engine-mode contribution to the
* replay-gain dedupe key (so changing the LUFS target re-fires even when
* the cached dB stays the same) and the null-aware number formatter.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { authState, invokeMock } = vi.hoisted(() => ({
authState: {
normalizationEngine: 'off' as 'off' | 'replaygain' | 'loudness',
loudnessTargetLufs: -14,
loudnessPreAnalysisAttenuationDb: 0,
},
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => undefined),
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => authState } }));
vi.mock('@/utils/audio/loudnessPreAnalysisSlider', () => ({
effectiveLoudnessPreAnalysisAttenuationDb: (attenuation: number) => attenuation,
}));
import {
_resetNormalizationIpcDedupeForTest,
invokeAudioSetNormalizationDeduped,
invokeAudioUpdateReplayGainDeduped,
} from '@/features/playback/store/normalizationIpcDedupe';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
invokeMock.mockClear();
authState.normalizationEngine = 'off';
authState.loudnessTargetLufs = -14;
authState.loudnessPreAnalysisAttenuationDb = 0;
});
afterEach(() => {
_resetNormalizationIpcDedupeForTest();
vi.useRealTimers();
});
describe('invokeAudioSetNormalizationDeduped', () => {
const payload = { engine: 'loudness', targetLufs: -14, preAnalysisAttenuationDb: 0 };
it('passes the first call through', () => {
invokeAudioSetNormalizationDeduped(payload);
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock).toHaveBeenCalledWith('audio_set_normalization', payload);
});
it('skips a repeat with the same payload inside the 450 ms window', () => {
invokeAudioSetNormalizationDeduped(payload);
vi.advanceTimersByTime(449);
invokeAudioSetNormalizationDeduped(payload);
expect(invokeMock).toHaveBeenCalledTimes(1);
});
it('fires again once the 450 ms window elapses', () => {
invokeAudioSetNormalizationDeduped(payload);
vi.advanceTimersByTime(450);
invokeAudioSetNormalizationDeduped(payload);
expect(invokeMock).toHaveBeenCalledTimes(2);
});
it('fires when any payload field changes within the window', () => {
invokeAudioSetNormalizationDeduped(payload);
invokeAudioSetNormalizationDeduped({ ...payload, targetLufs: -10 });
invokeAudioSetNormalizationDeduped({ ...payload, engine: 'replaygain' });
invokeAudioSetNormalizationDeduped({ ...payload, preAnalysisAttenuationDb: -2 });
expect(invokeMock).toHaveBeenCalledTimes(4);
});
});
describe('invokeAudioUpdateReplayGainDeduped', () => {
const payload = {
volume: 0.8,
replayGainDb: -6,
replayGainPeak: 0.9,
loudnessGainDb: -3,
preGainDb: 0,
fallbackDb: -6,
};
it('passes the first call through', () => {
invokeAudioUpdateReplayGainDeduped(payload);
expect(invokeMock).toHaveBeenCalledWith('audio_update_replay_gain', payload);
});
it('skips a repeat with the same payload inside the 250 ms window', () => {
invokeAudioUpdateReplayGainDeduped(payload);
vi.advanceTimersByTime(249);
invokeAudioUpdateReplayGainDeduped(payload);
expect(invokeMock).toHaveBeenCalledTimes(1);
});
it('fires again once the 250 ms window elapses', () => {
invokeAudioUpdateReplayGainDeduped(payload);
vi.advanceTimersByTime(250);
invokeAudioUpdateReplayGainDeduped(payload);
expect(invokeMock).toHaveBeenCalledTimes(2);
});
it('re-fires when the LUFS target changes even if the dB payload stays the same', () => {
authState.normalizationEngine = 'loudness';
authState.loudnessTargetLufs = -14;
invokeAudioUpdateReplayGainDeduped(payload);
authState.loudnessTargetLufs = -10;
invokeAudioUpdateReplayGainDeduped(payload);
expect(invokeMock).toHaveBeenCalledTimes(2);
});
it('treats null and non-finite gain values as the dedupe-string "null"', () => {
invokeAudioUpdateReplayGainDeduped({ ...payload, replayGainDb: null });
invokeAudioUpdateReplayGainDeduped({ ...payload, replayGainDb: Number.NaN });
// Same dedupe-key (both serialize to "null") + same window → second call dropped.
expect(invokeMock).toHaveBeenCalledTimes(1);
});
});
describe('_resetNormalizationIpcDedupeForTest', () => {
it('lets the next call through again after a reset', () => {
const payload = { engine: 'off', targetLufs: -14, preAnalysisAttenuationDb: 0 };
invokeAudioSetNormalizationDeduped(payload);
_resetNormalizationIpcDedupeForTest();
invokeAudioSetNormalizationDeduped(payload);
expect(invokeMock).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,92 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/utils/audio/loudnessPreAnalysisSlider';
/**
* Two IPC entry points to the Rust normalization pipeline that get hammered
* by analysis ticks, queue rewrites, and React-StrictMode double mounts —
* each carries its own time-bounded de-duplicator so the same payload sent
* within a short window collapses into a single `invoke`.
*
* - `invokeAudioSetNormalizationDeduped` — `audio_set_normalization`
* (engine + target + pre-attenuation). 450 ms window.
* - `invokeAudioUpdateReplayGainDeduped` — `audio_update_replay_gain`
* (per-track gain + peak). 250 ms window. The dedupe key picks up the
* LUFS target / pre-trim implicitly so Rust still recomputes when the
* user changes the target even if JS happens to forward the same dB.
*/
let lastNormAudioInvokeKey = '';
let lastNormAudioInvokeAtMs = 0;
const NORMALIZATION_DEDUPE_WINDOW_MS = 450;
export function invokeAudioSetNormalizationDeduped(payload: {
engine: string;
targetLufs: number;
preAnalysisAttenuationDb: number;
}): void {
const key = `${payload.engine}|${payload.targetLufs}|${payload.preAnalysisAttenuationDb}`;
const now = Date.now();
if (key === lastNormAudioInvokeKey && now - lastNormAudioInvokeAtMs < NORMALIZATION_DEDUPE_WINDOW_MS) {
return;
}
lastNormAudioInvokeKey = key;
lastNormAudioInvokeAtMs = now;
void invoke('audio_set_normalization', payload).catch(() => {});
}
let lastRgInvokeKey = '';
let lastRgInvokeAtMs = 0;
const REPLAY_GAIN_DEDUPE_WINDOW_MS = 250;
export function invokeAudioUpdateReplayGainDeduped(payload: {
volume: number;
replayGainDb: number | null;
replayGainPeak: number | null;
loudnessGainDb: number | null;
preGainDb: number;
fallbackDb: number;
}): void {
const auth = useAuthStore.getState();
/** Must vary when LUFS target / pre-trim changes: Rust recomputes in `audio_update_replay_gain` even if JS still sends the same cached dB. */
const preEff =
auth.normalizationEngine === 'loudness'
? effectiveLoudnessPreAnalysisAttenuationDb(
auth.loudnessPreAnalysisAttenuationDb,
auth.loudnessTargetLufs,
)
: auth.loudnessPreAnalysisAttenuationDb;
const normDedupeKey =
auth.normalizationEngine === 'loudness'
? `loudness|tgt=${auth.loudnessTargetLufs}|pre=${preEff.toFixed(2)}`
: auth.normalizationEngine === 'replaygain'
? 'replaygain'
: 'off';
const fmt = (v: number | null) => (v == null || !Number.isFinite(v) ? 'null' : v.toFixed(3));
const key = [
normDedupeKey,
payload.volume.toFixed(4),
fmt(payload.replayGainDb),
fmt(payload.replayGainPeak),
fmt(payload.loudnessGainDb),
payload.preGainDb.toFixed(2),
payload.fallbackDb.toFixed(2),
].join('|');
const now = Date.now();
if (key === lastRgInvokeKey && now - lastRgInvokeAtMs < REPLAY_GAIN_DEDUPE_WINDOW_MS) {
return;
}
lastRgInvokeKey = key;
lastRgInvokeAtMs = now;
invoke('audio_update_replay_gain', payload).catch(console.error);
}
/** Test-only: clear the cached dedupe state so each spec starts fresh. */
export function _resetNormalizationIpcDedupeForTest(): void {
lastNormAudioInvokeKey = '';
lastNormAudioInvokeAtMs = 0;
lastRgInvokeKey = '';
lastRgInvokeAtMs = 0;
}
@@ -0,0 +1,136 @@
/**
* Derived normalization snapshot — three engine branches and a couple of
* replaygain corner cases (no tag → fallback, neighbour-track context for
* album-mode resolution). useAuthStore is mocked through a hoisted state
* object so each test can flip flags without rebuilding the store.
*/
import type { Track } from '@/features/playback/store/playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { authState } = vi.hoisted(() => ({
authState: {
normalizationEngine: 'off' as 'off' | 'replaygain' | 'loudness',
loudnessTargetLufs: -14,
replayGainEnabled: false,
replayGainMode: 'track' as 'track' | 'album',
replayGainPreGainDb: 0,
replayGainFallbackDb: -6,
},
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: { getState: () => authState },
}));
vi.mock('@/features/playback/utils/audio/resolveReplayGainDb', () => ({
resolveReplayGainDb: vi.fn(
(track: Track) => (track as Track & { _testGain?: number | null })._testGain ?? null,
),
}));
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
function track(id: string, gain: number | null = null): Track {
return {
id,
title: id,
artist: 'A',
album: 'X',
albumId: 'X',
duration: 100,
...(gain !== null ? { _testGain: gain } : {}),
} as Track;
}
beforeEach(() => {
authState.normalizationEngine = 'off';
authState.loudnessTargetLufs = -14;
authState.replayGainEnabled = false;
authState.replayGainMode = 'track';
authState.replayGainPreGainDb = 0;
authState.replayGainFallbackDb = -6;
});
describe("engine='off'", () => {
it('returns a fully-null snapshot', () => {
expect(deriveNormalizationSnapshot(track('a'), [track('a')], 0)).toEqual({
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
});
});
});
describe("engine='loudness'", () => {
it('returns the configured target LUFS and clears nowDb', () => {
authState.normalizationEngine = 'loudness';
authState.loudnessTargetLufs = -10;
expect(deriveNormalizationSnapshot(track('a'), [track('a')], 0)).toEqual({
normalizationNowDb: null,
normalizationTargetLufs: -10,
normalizationEngineLive: 'loudness',
});
});
});
describe("engine='replaygain'", () => {
it("falls through to 'off' when replayGainEnabled is false", () => {
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = false;
expect(deriveNormalizationSnapshot(track('a'), [track('a')], 0).normalizationEngineLive).toBe('off');
});
it('uses the resolved gain + pre-gain when a tag is present', () => {
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = true;
authState.replayGainPreGainDb = 2;
const snap = deriveNormalizationSnapshot(track('a', -8), [track('a', -8)], 0);
expect(snap.normalizationNowDb).toBe(-6); // -8 + 2
expect(snap.normalizationEngineLive).toBe('replaygain');
expect(snap.normalizationTargetLufs).toBeNull();
});
it('uses the fallback dB when no tag is resolvable', () => {
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = true;
authState.replayGainFallbackDb = -3;
const snap = deriveNormalizationSnapshot(track('a'), [track('a')], 0);
expect(snap.normalizationNowDb).toBe(-3);
expect(snap.normalizationEngineLive).toBe('replaygain');
});
it('passes neighbour-track context for album-mode resolution', async () => {
const { resolveReplayGainDb } = await import('@/features/playback/utils/audio/resolveReplayGainDb');
const spy = vi.mocked(resolveReplayGainDb);
spy.mockClear();
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = true;
authState.replayGainMode = 'album';
const queue = [track('prev'), track('current'), track('next')];
deriveNormalizationSnapshot(queue[1], queue, 1);
expect(spy).toHaveBeenCalledWith(queue[1], queue[0], queue[2], true, 'album');
});
it('passes null for prev when queueIndex is 0', async () => {
const { resolveReplayGainDb } = await import('@/features/playback/utils/audio/resolveReplayGainDb');
const spy = vi.mocked(resolveReplayGainDb);
spy.mockClear();
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = true;
const queue = [track('a'), track('b')];
deriveNormalizationSnapshot(queue[0], queue, 0);
expect(spy.mock.calls[0][1]).toBeNull();
expect(spy.mock.calls[0][2]).toEqual(queue[1]);
});
it('passes null for next when queueIndex is the last slot', async () => {
const { resolveReplayGainDb } = await import('@/features/playback/utils/audio/resolveReplayGainDb');
const spy = vi.mocked(resolveReplayGainDb);
spy.mockClear();
authState.normalizationEngine = 'replaygain';
authState.replayGainEnabled = true;
const queue = [track('a'), track('b')];
deriveNormalizationSnapshot(queue[1], queue, 1);
expect(spy.mock.calls[0][1]).toEqual(queue[0]);
expect(spy.mock.calls[0][2]).toBeNull();
});
});
@@ -0,0 +1,50 @@
import type { PlayerState, Track } from '@/features/playback/store/playerStoreTypes';
import { useAuthStore } from '@/store/authStore';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
/**
* Compute the normalization fields that should land in the next state commit
* when the runtime switches tracks or rewrites the queue. Three branches:
*
* - **loudness** — clear the visible dB until the engine pushes a real
* `audio:normalization-state` event back; surface the user's target LUFS.
* - **replaygain (enabled)** — derive the dB from track/album tags via
* `resolveReplayGainDb`, add the pre-gain, fall back to the configured
* fallback when no tag is available.
* - **off** (or replaygain disabled) — everything null, engine `'off'`.
*/
export function deriveNormalizationSnapshot(
track: Track,
queue: Track[],
queueIndex: number,
): Pick<
PlayerState,
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
> {
const auth = useAuthStore.getState();
const engine = auth.normalizationEngine;
if (engine === 'loudness') {
const target = auth.loudnessTargetLufs;
return {
// Clears stale UI until `audio:normalization-state` / refresh catches up.
normalizationNowDb: null,
normalizationTargetLufs: target,
normalizationEngineLive: 'loudness',
};
}
if (engine === 'replaygain' && auth.replayGainEnabled) {
const prev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
const resolved = resolveReplayGainDb(track, prev, next, true, auth.replayGainMode);
const nowDb = resolved != null ? (resolved + auth.replayGainPreGainDb) : auth.replayGainFallbackDb;
return {
normalizationNowDb: nowDb,
normalizationTargetLufs: null,
normalizationEngineLive: 'replaygain',
};
}
return {
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
};
}
@@ -0,0 +1,124 @@
import type { Track } from '@/features/playback/store/playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
invokeMock: vi.fn(async () => undefined),
emitMock: vi.fn(),
setSeekMock: vi.fn(),
prefetchMock: vi.fn(),
promoteMock: vi.fn(async () => undefined),
engineLoadMock: vi.fn(),
bumpGenMock: vi.fn(() => 7),
getGenMock: vi.fn(() => 7),
playerState: {
isPlaying: false,
currentRadio: null as { streamUrl: string } | null,
volume: 0.8,
},
authState: {
hotCacheEnabled: true,
hotCacheDownloadDir: '/tmp/hot',
},
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('@/features/playback/store/playbackProgress', () => ({ emitPlaybackProgress: hoisted.emitMock }));
vi.mock('@/features/playback/store/seekFallbackState', () => ({ setSeekFallbackVisualTarget: hoisted.setSeekMock }));
vi.mock('@/hotCachePrefetch', () => ({ scheduleHotCachePrefetchForTrack: hoisted.prefetchMock }));
vi.mock('@/features/playback/store/promoteStreamCache', () => ({ promoteCompletedStreamToHotCache: hoisted.promoteMock }));
vi.mock('@/features/playback/store/engineLoadTrackAtPosition', () => ({ engineLoadTrackAtPosition: hoisted.engineLoadMock }));
vi.mock('@/features/playback/store/engineState', () => ({
bumpPlayGeneration: hoisted.bumpGenMock,
getPlayGeneration: hoisted.getGenMock,
}));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.authState } }));
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: {
getState: () => hoisted.playerState,
setState: vi.fn((partial: Record<string, unknown>) => {
Object.assign(hoisted.playerState, partial);
}),
},
}));
vi.mock('@/utils/library/queueTrackView', () => ({
getQueueTracksView: vi.fn((refs: { trackId: string }[]) =>
refs.map(r => ({
id: r.trackId,
title: r.trackId,
artist: 'A',
album: 'B',
albumId: 'B',
duration: 200,
})),
),
}));
vi.mock('@/features/playback/utils/playback/playbackServer', () => ({
getPlaybackCacheServerKey: () => 'srv-key',
}));
import {
applyRestoredPlaybackVisual,
preparePausedRestoreOnStartup,
} from '@/features/playback/store/pausedRestorePrepare';
function track(id: string, duration = 200): Track {
return { id, title: id, artist: 'A', album: 'B', albumId: 'B', duration };
}
beforeEach(() => {
hoisted.invokeMock.mockClear();
hoisted.emitMock.mockClear();
hoisted.setSeekMock.mockClear();
hoisted.prefetchMock.mockClear();
hoisted.promoteMock.mockClear();
hoisted.engineLoadMock.mockClear();
hoisted.bumpGenMock.mockClear();
hoisted.getGenMock.mockReturnValue(7);
hoisted.playerState.isPlaying = false;
hoisted.playerState.currentRadio = null;
});
describe('applyRestoredPlaybackVisual', () => {
it('updates store progress and emits playback progress', () => {
applyRestoredPlaybackVisual(track('t1'), 50);
expect(hoisted.emitMock).toHaveBeenCalledWith({
currentTime: 50,
progress: 0.25,
buffered: 0,
buffering: false,
});
expect(hoisted.setSeekMock).toHaveBeenCalledWith({
trackId: 't1',
seconds: 50,
setAtMs: expect.any(Number),
});
});
});
describe('preparePausedRestoreOnStartup', () => {
it('prefetches, promotes stream cache, and loads the engine paused', async () => {
preparePausedRestoreOnStartup(
track('t1'),
[{ serverId: 'srv', trackId: 't1' }],
0,
42,
);
expect(hoisted.prefetchMock).toHaveBeenCalled();
expect(hoisted.bumpGenMock).toHaveBeenCalled();
await vi.waitFor(() => {
expect(hoisted.promoteMock).toHaveBeenCalled();
expect(hoisted.engineLoadMock).toHaveBeenCalledWith(expect.objectContaining({
generation: 7,
atSeconds: 42,
wantPlaying: false,
}));
});
});
it('skips when transport is already playing', () => {
hoisted.playerState.isPlaying = true;
preparePausedRestoreOnStartup(track('t1'), [], 0, 10);
expect(hoisted.engineLoadMock).not.toHaveBeenCalled();
expect(hoisted.prefetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,75 @@
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
import { getQueueTracksView } from '@/utils/library/queueTrackView';
import { scheduleHotCachePrefetchForTrack } from '@/hotCachePrefetch';
import { getPlaybackCacheServerKey } from '@/features/playback/utils/playback/playbackServer';
import { useAuthStore } from '@/store/authStore';
import { bumpPlayGeneration, getPlayGeneration } from '@/features/playback/store/engineState';
import { engineLoadTrackAtPosition } from '@/features/playback/store/engineLoadTrackAtPosition';
import { emitPlaybackProgress } from '@/features/playback/store/playbackProgress';
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { setSeekFallbackVisualTarget } from '@/features/playback/store/seekFallbackState';
/** Push restored position into the store + progress channel so the seekbar paints immediately. */
export function applyRestoredPlaybackVisual(track: Track, atSeconds: number): void {
const dur = track.duration > 0 ? track.duration : 0;
const seconds = Math.max(0, atSeconds);
const progress = dur > 0 ? Math.min(1, seconds / dur) : 0;
usePlayerStore.setState({ currentTime: seconds, progress, buffered: 0 });
emitPlaybackProgress({
currentTime: seconds,
progress,
buffered: 0,
buffering: false,
});
if (seconds > 0.05) {
setSeekFallbackVisualTarget({
trackId: track.id,
seconds,
setAtMs: Date.now(),
});
}
}
/**
* After `getPlayQueue` restores a paused session: show the saved seek position,
* prefetch bytes for the current track, and load the engine paused at that spot
* so the next Play is a warm `audio_resume`.
*/
export function preparePausedRestoreOnStartup(
track: Track,
queueItems: QueueItemRef[],
queueIndex: number,
atSeconds: number,
): void {
const player = usePlayerStore.getState();
if (player.isPlaying || player.currentRadio) return;
applyRestoredPlaybackVisual(track, atSeconds);
scheduleHotCachePrefetchForTrack(track, getPlaybackCacheServerKey());
const generation = bumpPlayGeneration();
void (async () => {
const auth = useAuthStore.getState();
const promoteSid = getPlaybackCacheServerKey();
if (auth.hotCacheEnabled && promoteSid) {
await promoteCompletedStreamToHotCache(
track,
promoteSid,
auth.hotCacheDownloadDir || null,
);
}
if (getPlayGeneration() !== generation) return;
if (usePlayerStore.getState().isPlaying) return;
const queue = getQueueTracksView(queueItems, [track]);
engineLoadTrackAtPosition({
generation,
track,
queue,
queueIndex,
atSeconds,
wantPlaying: false,
});
})();
}
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const starMock = vi.fn();
const unstarMock = vi.fn();
const setRatingMock = vi.fn();
vi.mock('@/lib/api/subsonicStarRating', () => ({
star: (...a: unknown[]) => starMock(...a),
unstar: (...a: unknown[]) => unstarMock(...a),
setRating: (...a: unknown[]) => setRatingMock(...a),
}));
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { Track } from '@/features/playback/store/playerStoreTypes';
import { queueSongStar, queueSongRating, _resetPendingStarSyncForTest } from '@/features/playback/store/pendingStarSync';
import {
getCachedTrack,
seedQueueResolver,
_resetQueueResolverForTest,
} from '@/utils/library/queueTrackResolver';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
const track = (id: string): Track => ({
id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1,
});
describe('pendingStarSync', () => {
beforeEach(() => {
vi.useFakeTimers();
starMock.mockReset().mockResolvedValue(undefined);
unstarMock.mockReset().mockResolvedValue(undefined);
setRatingMock.mockReset().mockResolvedValue(undefined);
_resetPendingStarSyncForTest();
_resetQueueResolverForTest();
// Thin-state: the queue's track copy lives in the resolver cache. Seed it so
// a star/rating success has a cached entry to patch in place.
seedQueueResolver('', [track('t1')]);
usePlayerStore.setState({
currentTrack: track('t1'),
queueItems: toQueueItemRefs('', [track('t1')]),
queueServerId: null,
starredOverrides: {},
userRatingOverrides: {},
});
});
afterEach(() => {
_resetPendingStarSyncForTest();
vi.useRealTimers();
});
it('stars optimistically, then keeps the override + patches the track on success', async () => {
queueSongStar('t1', true);
expect(usePlayerStore.getState().starredOverrides.t1).toBe(true); // optimistic, instant
await vi.runAllTimersAsync();
expect(starMock).toHaveBeenCalledWith('t1', 'song', undefined);
const s = usePlayerStore.getState();
expect(s.starredOverrides.t1).toBe(true); // kept on success so list views stay in sync
expect(s.currentTrack?.starred).toBeTruthy(); // in-memory track patched
// Thin-state: the resolver cache entry is patched in place (not dropped) so
// the visible queue row keeps its title and reflects the synced star —
// dropping it would blank the row to a "…" placeholder.
const cached = getCachedTrack({ serverId: '', trackId: 't1' });
expect(cached?.title).toBe('t1');
expect(cached?.starred).toBeTruthy();
});
it('does NOT roll back on a network failure and keeps retrying', async () => {
starMock.mockRejectedValue(new Error('offline'));
queueSongStar('t1', true);
await vi.advanceTimersByTimeAsync(4000); // 0ms + 1s + 2s backoff cycles
expect(starMock.mock.calls.length).toBeGreaterThanOrEqual(2); // retried
expect(usePlayerStore.getState().starredOverrides.t1).toBe(true); // override survives (no rollback)
});
it('passes serverId through to star/unstar for cross-server favorites', async () => {
queueSongStar('t1', true, 'srv-b');
await vi.runAllTimersAsync();
expect(starMock).toHaveBeenCalledWith('t1', 'song', { serverId: 'srv-b' });
});
it('latest toggle wins when re-queued before sync', async () => {
queueSongStar('t1', true);
queueSongStar('t1', false); // user toggled back off
await vi.runAllTimersAsync();
expect(unstarMock).toHaveBeenCalledWith('t1', 'song', undefined);
expect(usePlayerStore.getState().starredOverrides.t1).toBe(false); // kept as durable false
expect(usePlayerStore.getState().currentTrack?.starred).toBeFalsy();
});
it('rates optimistically (track patched), clears override on success', async () => {
queueSongRating('t1', 4);
// setUserRatingOverride patches the track immediately:
expect(usePlayerStore.getState().currentTrack?.userRating).toBe(4);
expect(usePlayerStore.getState().userRatingOverrides.t1).toBe(4);
await vi.runAllTimersAsync();
expect(setRatingMock).toHaveBeenCalledWith('t1', 4);
const s = usePlayerStore.getState();
expect('t1' in s.userRatingOverrides).toBe(false); // cleared
expect(s.currentTrack?.userRating).toBe(4); // track stays patched
});
});
@@ -0,0 +1,141 @@
import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { patchCachedTrack } from '@/utils/library/queueTrackResolver';
/**
* F4 — pending-sync for **song** star + rating (spec §6.5 / R7-18).
*
* The player-store override maps (`starredOverrides` / `userRatingOverrides`)
* are *session-only* client truth that every list view merges over its
* one-shot-fetched state:
*
* 1. Set the override optimistically (instant UI).
* 2. Retry the Subsonic API (`star` / `unstar` / `setRating`) with exponential
* backoff; flush immediately on `online` / window focus.
* 3. On **star** success: KEEP the override — list views read it — and patch
* the in-memory `Track`. F3 index patch-on-use runs in the API layer.
* (Ratings clear on success; see `onRatingSuccess`.)
* 4. On app restart before success: the pending change is lost — acceptable,
* overrides are not persisted.
*
* **No rollback on the first network error** (this replaces the per-component
* star rollback). v1 routes **songs only**; album/artist stay on their existing
* paths.
*/
type Task =
| { kind: 'star'; id: string; starred: boolean; serverId?: string }
| { kind: 'rating'; id: string; rating: number };
const pending = new Map<string, Task>(); // key `${kind}:${id}` — latest wins
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const attempts = new Map<string, number>();
const MAX_BACKOFF_MS = 30_000;
let listenersArmed = false;
const keyOf = (t: Task) =>
t.kind === 'star' ? `star:${t.serverId ?? ''}:${t.id}` : `${t.kind}:${t.id}`;
function armListeners(): void {
if (listenersArmed || typeof window === 'undefined') return;
listenersArmed = true;
const flushAll = () => {
for (const k of pending.keys()) schedule(k, 0);
};
window.addEventListener('online', flushAll);
window.addEventListener('focus', flushAll);
}
function schedule(k: string, delayMs: number): void {
const existing = timers.get(k);
if (existing) clearTimeout(existing);
timers.set(
k,
setTimeout(() => {
void run(k);
}, delayMs),
);
}
async function run(k: string): Promise<void> {
timers.delete(k);
const task = pending.get(k);
if (!task) return;
try {
if (task.kind === 'star') {
const meta = task.serverId ? { serverId: task.serverId } : undefined;
if (task.starred) await star(task.id, 'song', meta);
else await unstar(task.id, 'song', meta);
onStarSuccess(task.id, task.starred);
} else {
await setRating(task.id, task.rating);
onRatingSuccess(task.id);
}
// Only retire the entry if a newer toggle hasn't superseded it mid-flight.
if (pending.get(k) === task) {
pending.delete(k);
attempts.delete(k);
}
} catch {
if (pending.get(k) !== task) return; // superseded — the newer task self-schedules
const n = (attempts.get(k) ?? 0) + 1;
attempts.set(k, n);
schedule(k, Math.min(MAX_BACKOFF_MS, 1000 * 2 ** (n - 1)));
}
}
function onStarSuccess(id: string, starred: boolean): void {
const starredVal = starred ? new Date().toISOString() : undefined;
// Keep the override — list views merge it (step 3 atop this file).
usePlayerStore.setState(s => ({
currentTrack:
s.currentTrack?.id === id ? { ...s.currentTrack, starred: starredVal } : s.currentTrack,
}));
// Thin-state: the queue's copy lives in the resolver cache. Patch it in place
// to the synced value rather than dropping it — a dropped entry would blank the
// visible queue row to a "…" placeholder until the next window re-resolve.
patchCachedTrack(id, { starred: starredVal });
}
function onRatingSuccess(id: string): void {
const rating = usePlayerStore.getState().userRatingOverrides[id];
usePlayerStore.setState(s => {
if (!(id in s.userRatingOverrides)) return {};
const next = { ...s.userRatingOverrides };
delete next[id];
return { userRatingOverrides: next };
});
// Patch the cached queue track in place (see onStarSuccess) so the row keeps
// its title and shows the synced rating without flashing a placeholder.
if (rating !== undefined) patchCachedTrack(id, { userRating: rating });
}
/** Optimistically (un)star a song and sync it to the server with retry. */
export function queueSongStar(id: string, starred: boolean, serverId?: string): void {
usePlayerStore.getState().setStarredOverride(id, starred);
const t: Task = { kind: 'star', id, starred, serverId };
const k = keyOf(t);
pending.set(k, t);
attempts.delete(k);
armListeners();
schedule(k, 0);
}
/** Optimistically rate a song and sync it to the server with retry. */
export function queueSongRating(id: string, rating: number): void {
usePlayerStore.getState().setUserRatingOverride(id, rating);
const t: Task = { kind: 'rating', id, rating };
const k = keyOf(t);
pending.set(k, t);
attempts.delete(k);
armListeners();
schedule(k, 0);
}
/** Test-only: clear all pending state + timers. */
export function _resetPendingStarSyncForTest(): void {
pending.clear();
attempts.clear();
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
@@ -0,0 +1,219 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
import { onInvoke } from '@/test/mocks/tauri';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { usePreviewStore } from '@/features/playback/store/previewStore';
import {
_resetPlayListenSessionForTest,
playListenSessionFinalize,
playListenSessionOnPause,
playListenSessionOnProgress,
playListenSessionOpen,
resolveDurationSecHint,
} from '@/features/playback/store/playListenSession';
import { onPlaySessionRecorded } from '@/features/playback/store/playSessionRecorded';
vi.mock('@/utils/library/libraryReady', () => ({
libraryIsReady: vi.fn(async () => true),
}));
vi.mock('@/features/playback/utils/playback/playbackServer', () => ({
getPlaybackServerId: vi.fn(() => 'server-1'),
}));
import type { Track } from '@/features/playback/store/playerStoreTypes';
const testTrack: Track = {
id: 't1',
title: 'A',
artist: 'B',
album: '',
albumId: '',
duration: 180,
};
describe('playListenSession', () => {
beforeEach(() => {
_resetPlayListenSessionForTest();
useLibraryIndexStore.setState({
masterEnabled: true,
});
usePlayerStore.setState({
currentRadio: null,
isPlaying: true,
currentTrack: testTrack,
});
usePreviewStore.setState({ previewingId: null });
onInvoke('library_record_play_session', () => undefined);
});
it('does not invoke when listenedSec <= 10', async () => {
await playListenSessionOpen(testTrack, 'server-1');
await playListenSessionOnProgress(5, false);
await playListenSessionFinalize('ended');
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('invokes once after >10s listened', async () => {
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 15_000);
await playListenSessionOnProgress(12, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).toHaveBeenCalledWith(
'library_record_play_session',
expect.objectContaining({
input: expect.objectContaining({
trackId: 't1',
serverId: 'server-1',
endReason: 'ended',
durationSecHint: 180,
}),
}),
);
});
it('picks up engine duration from progress ticks', async () => {
usePlayerStore.setState({
currentTrack: { ...testTrack, duration: 0 },
});
vi.useFakeTimers();
await playListenSessionOpen({ ...testTrack, duration: 0 }, 'server-1', 240);
vi.setSystemTime(Date.now() + 15_000);
await playListenSessionOnProgress(12, false, 240);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).toHaveBeenCalledWith(
'library_record_play_session',
expect.objectContaining({
input: expect.objectContaining({ durationSecHint: 240 }),
}),
);
});
it('skips when index disabled', async () => {
useLibraryIndexStore.setState({ masterEnabled: false });
await playListenSessionOpen(testTrack, 'server-1');
vi.useFakeTimers();
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('skips preview playback', async () => {
const { usePreviewStore } = await import('@/features/playback/store/previewStore');
usePreviewStore.setState({ previewingId: 'preview-1' });
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('skips radio playback', async () => {
usePlayerStore.setState({
currentRadio: { id: 'r1', name: 'Radio', streamUrl: 'http://x' },
});
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('does not accumulate listened time while paused or buffering', async () => {
const { usePlayerStore } = await import('@/features/playback/store/playerStore');
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 15_000);
usePlayerStore.setState({ isPlaying: false });
await playListenSessionOnProgress(12, false);
vi.setSystemTime(Date.now() + 30_000);
await playListenSessionOnProgress(12, true);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('does not bill the paused gap as listened time after resume', async () => {
vi.useFakeTimers();
const t0 = Date.now();
vi.setSystemTime(t0);
await playListenSessionOpen(testTrack, 'server-1');
// Play for 8 s.
vi.setSystemTime(t0 + 8_000);
await playListenSessionOnProgress(8, false);
// User pauses; in production no progress ticks reach the session while
// paused, and the engine sits idle for several minutes.
usePlayerStore.setState({ isPlaying: false });
playListenSessionOnPause();
vi.setSystemTime(t0 + 8_000 + 300_000);
// User resumes and listens for 5 more seconds.
usePlayerStore.setState({ isPlaying: true });
await playListenSessionOnProgress(9, false);
vi.setSystemTime(t0 + 8_000 + 300_000 + 5_000);
await playListenSessionOnProgress(14, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
const call = vi
.mocked(invoke)
.mock.calls.find(([cmd]) => cmd === 'library_record_play_session');
expect(call).toBeDefined();
const listenedSec = (call![1] as { input: { listenedSec: number } }).input.listenedSec;
// ~13 s of real listening (8 + 5), never the ~300 s paused gap.
expect(listenedSec).toBeGreaterThanOrEqual(12);
expect(listenedSec).toBeLessThan(20);
});
it('skips when library is not ready', async () => {
const { libraryIsReady } = await import('@/utils/library/libraryReady');
vi.mocked(libraryIsReady).mockResolvedValueOnce(false);
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('emits play-session-recorded after a persisted listen', async () => {
const listener = vi.fn();
const unsub = onPlaySessionRecorded(listener);
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 15_000);
await playListenSessionOnProgress(12, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
unsub();
expect(listener).toHaveBeenCalledWith({
serverId: 'server-1',
trackId: 't1',
startedAtMs: expect.any(Number),
});
});
});
describe('resolveDurationSecHint', () => {
it('returns zero when no positive durations are available', () => {
expect(resolveDurationSecHint(null)).toBe(0);
expect(resolveDurationSecHint({ duration: 0 }, 0, undefined)).toBe(0);
});
it('prefers the largest finite positive hint', () => {
expect(resolveDurationSecHint({ duration: 180 }, 240, 200)).toBe(240);
});
});
@@ -0,0 +1,191 @@
import type { Track } from '@/features/playback/store/playerStoreTypes';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { usePreviewStore } from '@/features/playback/store/previewStore';
import {
libraryRecordPlaySession,
type PlaySessionEndReason,
} from '@/lib/api/library';
import { libraryIsReady } from '@/utils/library/libraryReady';
import { getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
import { emitPlaySessionRecorded } from '@/features/playback/store/playSessionRecorded';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
const MIN_LISTENED_SEC = 10;
type OpenSession = {
serverId: string;
trackId: string;
startedAtMs: number;
listenedSec: number;
positionMaxSec: number;
durationSecHint: number;
lastTickMs: number;
recordingEnabled: boolean;
paused: boolean;
};
let open: OpenSession | null = null;
let finalizeInFlight: Promise<void> | null = null;
function clearOpen(): void {
open = null;
}
/** Best-known track length in seconds from player metadata and/or engine. */
export function resolveDurationSecHint(
track: Pick<Track, 'duration'> | null | undefined,
...extraSec: (number | undefined)[]
): number {
const values = [track?.duration, ...extraSec].filter(
(d): d is number => typeof d === 'number' && Number.isFinite(d) && d > 0,
);
if (values.length === 0) return 0;
return Math.round(Math.max(...values));
}
function noteDurationHint(durationSec?: number): void {
if (!open || !durationSec || durationSec <= 0) return;
const rounded = Math.round(durationSec);
if (rounded > open.durationSecHint) {
open.durationSecHint = rounded;
}
}
function playerGateBlocks(): boolean {
if (usePreviewStore.getState().previewingId) return true;
if (usePlayerStore.getState().currentRadio) return true;
return false;
}
async function recordingEnabledForServer(serverId: string): Promise<boolean> {
if (!serverId) return false;
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
if (playerGateBlocks()) return false;
return libraryIsReady(serverId);
}
export async function playListenSessionOpen(
track: Track,
serverId: string,
engineDurationSec?: number,
): Promise<void> {
if (open && open.trackId === track.id && open.serverId === serverId) {
noteDurationHint(resolveDurationSecHint(track, engineDurationSec));
return;
}
await playListenSessionFinalize('skip');
const enabled = await recordingEnabledForServer(serverId);
if (!enabled) return;
open = {
serverId,
trackId: track.id,
startedAtMs: Date.now(),
listenedSec: 0,
positionMaxSec: 0,
durationSecHint: resolveDurationSecHint(track, engineDurationSec),
lastTickMs: Date.now(),
recordingEnabled: true,
paused: false,
};
}
/**
* Freeze the listening counter when transport pauses. While paused the Rust
* engine stops feeding active progress ticks to the session, so without this
* the next tick after resume would compute its wall-clock delta against the
* pre-pause timestamp and bill the entire paused span as listened time. We
* settle the partial segment played since the last tick, then mark the session
* paused so the first resumed tick rebaselines instead of counting the gap.
*/
export function playListenSessionOnPause(): void {
if (!open?.recordingEnabled || open.paused) return;
const now = Date.now();
open.listenedSec += Math.max(0, (now - open.lastTickMs) / 1000);
open.lastTickMs = now;
open.paused = true;
}
export async function playListenSessionOnProgress(
currentTime: number,
buffering: boolean,
durationSecHint?: number,
): Promise<void> {
if (!open?.recordingEnabled) return;
noteDurationHint(durationSecHint);
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (track?.id === open.trackId) {
noteDurationHint(resolveDurationSecHint(track, durationSecHint));
}
if (!store.isPlaying || buffering) {
open.lastTickMs = Date.now();
return;
}
const now = Date.now();
if (open.paused) {
// First tick after resume — rebaseline so the paused gap is not billed.
open.paused = false;
open.lastTickMs = now;
}
const deltaSec = Math.max(0, (now - open.lastTickMs) / 1000);
open.lastTickMs = now;
open.listenedSec += deltaSec;
if (Number.isFinite(currentTime) && currentTime > open.positionMaxSec) {
open.positionMaxSec = currentTime;
}
}
export async function playListenSessionFinalize(reason: PlaySessionEndReason): Promise<void> {
if (finalizeInFlight) {
await finalizeInFlight;
}
if (!open) return;
const session = open;
clearOpen();
if (!session.recordingEnabled || session.listenedSec <= MIN_LISTENED_SEC) {
return;
}
const track = usePlayerStore.getState().currentTrack;
const durationSecHint = resolveDurationSecHint(
track?.id === session.trackId ? track : null,
session.durationSecHint,
);
finalizeInFlight = libraryRecordPlaySession({
serverId: session.serverId,
trackId: session.trackId,
startedAtMs: session.startedAtMs,
listenedSec: session.listenedSec,
positionMaxSec: session.positionMaxSec,
endReason: reason,
durationSecHint: durationSecHint > 0 ? durationSecHint : undefined,
})
.then(() => {
emitPlaySessionRecorded({
serverId: session.serverId,
trackId: session.trackId,
startedAtMs: session.startedAtMs,
});
})
.catch(() => undefined)
.finally(() => {
finalizeInFlight = null;
});
await finalizeInFlight;
}
export async function playListenSessionOnTrackSwitched(nextTrack: Track): Promise<void> {
const serverId = getPlaybackServerId();
await playListenSessionFinalize('switch');
await playListenSessionOpen(nextTrack, serverId, nextTrack.duration);
}
/** Test-only reset */
export function _resetPlayListenSessionForTest(): void {
open = null;
finalizeInFlight = null;
}
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from 'vitest';
import { emitPlaySessionRecorded, onPlaySessionRecorded } from '@/features/playback/store/playSessionRecorded';
describe('playSessionRecorded', () => {
it('notifies subscribers when a listen is persisted', () => {
const listener = vi.fn();
const unsub = onPlaySessionRecorded(listener);
const detail = { serverId: 's1', trackId: 't1', startedAtMs: 123 };
emitPlaySessionRecorded(detail);
expect(listener).toHaveBeenCalledWith(detail);
unsub();
listener.mockClear();
emitPlaySessionRecorded(detail);
expect(listener).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,25 @@
export type PlaySessionRecordedDetail = {
serverId: string;
trackId: string;
startedAtMs: number;
};
const EVENT_NAME = 'psysonic:play-session-recorded';
export function emitPlaySessionRecorded(detail: PlaySessionRecordedDetail): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<PlaySessionRecordedDetail>(EVENT_NAME, { detail }));
}
export function onPlaySessionRecorded(
listener: (detail: PlaySessionRecordedDetail) => void,
): () => void {
if (typeof window === 'undefined') return () => {};
const wrapped = (evt: Event) => {
const ce = evt as CustomEvent<PlaySessionRecordedDetail>;
if (!ce?.detail) return;
listener(ce.detail);
};
window.addEventListener(EVENT_NAME, wrapped as EventListener);
return () => window.removeEventListener(EVENT_NAME, wrapped as EventListener);
}
@@ -0,0 +1,656 @@
import { playbackReportStart, playbackReportStopped } from '@/features/playback/store/playbackReportSession';
import { invoke } from '@tauri-apps/api/core';
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
import { setDeferHotCachePrefetch } from '@/utils/cache/hotCacheGate';
import { orbitBulkGuard, orbitSnapshot } from '@/store/orbitRuntime';
import { sameQueueTrackId } from '@/features/playback/utils/playback/queueIdentity';
import {
computeAutodjManualBlendPlan,
shouldAutodjInterruptBlend,
} from '@/features/playback/utils/playback/autodjManualBlend';
import type { CrossfadeTransitionPlan } from '@/utils/waveform/waveformSilence';
import {
armInterruptHandoff,
clearInterruptHandoff,
runInterruptBlendPrep,
shouldDeferInterruptHandoffUi,
} from '@/features/playback/utils/playback/autodjInterruptPrep';
import { isCrossfadeNextReady } from '@/features/playback/store/crossfadePreload';
import { STANDARD_BLEND_SEC } from '@/utils/waveform/waveformSilence';
import { armAutodjMixing, clearAutodjTransitionUi } from '@/features/playback/store/autodjTransitionUi';
import {
bindQueueServerForTracks,
getPlaybackCacheServerKey,
getPlaybackIndexKey,
playbackCacheKeyForTrack,
playbackProfileIdForTrack,
shouldBindQueueServerForPlay,
} from '@/features/playback/utils/playback/playbackServer';
import { stampTrackServerId, stampTrackServerIds } from '@/features/playback/utils/playback/trackServerScope';
import {
findLocalPlaybackUrl,
hasLocalPersistentPlaybackBytes,
} from '@/store/localPlaybackResolve';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
import { audioPlayHiResBlendArgs } from '@/utils/audio/hiResCrossfadeResample';
import { useAuthStore } from '@/store/authStore';
import { consumeCrossfadeDynamicOverlap, getCrossfadeTransition, peekArmedCrossfadeDynamicOverlap } from '@/features/playback/store/crossfadeTrimCache';
import {
bumpPlayGeneration,
getPlayGeneration,
setIsAudioPaused,
} from '@/features/playback/store/engineState';
import {
clearPreloadingIds,
getLastGaplessSwitchTime,
} from '@/features/playback/store/gaplessPreloadState';
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
import {
isReplayGainActive,
loudnessGainDbForEngineBind,
} from '@/features/playback/store/loudnessGainCache';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { fetchWaveformBins, refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
import {
playbackSourceHintForResolvedUrl,
recordEnginePlayUrl,
} from '@/features/playback/store/playbackUrlRouting';
import type { PlayerState, Track } from '@/features/playback/store/playerStoreTypes';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { getQueueTracksView, resolveQueueTrack } from '@/utils/library/queueTrackView';
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
import { pushQueueOnPlaybackStart } from '@/features/playback/store/queueSync';
import { playListenSessionFinalize } from '@/features/playback/store/playListenSession';
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
import { appendTimelineLeaveTrack } from '@/features/playback/store/timelineSessionHistory';
import { stopRadio } from '@/features/playback/store/radioPlayer';
import { clearAllPlaybackScheduleTimers } from '@/features/playback/store/scheduleTimers';
import { clearSeekDebounce } from '@/features/playback/store/seekDebounce';
import {
clearSeekFallbackRetry,
getSeekFallbackVisualTarget,
setSeekFallbackRestartAt,
setSeekFallbackTrackId,
setSeekFallbackVisualTarget,
} from '@/features/playback/store/seekFallbackState';
import {
clearSeekTarget,
setSeekTarget,
} from '@/features/playback/store/seekTargetState';
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 = orbitSnapshot().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 = orbitSnapshot().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 stateBeforeLeave = get();
const prevTrackForHistory = stateBeforeLeave.currentTrack;
const scopedTrackEarly = stampTrackServerId(track);
if (
prevTrackForHistory
&& !sameQueueTrackId(prevTrackForHistory.id, scopedTrackEarly.id)
) {
appendTimelineLeaveTrack(
prevTrackForHistory,
stateBeforeLeave.queueItems,
stateBeforeLeave.queueIndex,
);
}
const scopedTrack = scopedTrackEarly;
const scopedQueue = queue ? stampTrackServerIds(queue) : queue;
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
const gen = bumpPlayGeneration();
clearInterruptHandoff();
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 wasPlayingBeforeSkip = state.isPlaying;
const skipFromTimeSec = state.currentTime;
const outgoingWaveformBins = state.waveformBins;
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];
const prevPlaybackSid = prevTrack && prevPlayingRef
? playbackProfileIdForTrack(prevTrack, prevPlayingRef) ?? ''
: '';
const nextPlaybackSid = playbackProfileIdForTrack(scopedTrack, playingRef) ?? '';
if (
prevTrack
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id)
&& prevPlaybackSid
&& nextPlaybackSid
&& prevPlaybackSid !== nextPlaybackSid
) {
void playbackReportStopped(skipFromTimeSec);
}
// ±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]);
}
const hasJsAutoHandoff = !manual && peekArmedCrossfadeDynamicOverlap(scopedTrack.id);
const wantInterruptBlend = Boolean(
shouldAutodjInterruptBlend(wasPlayingBeforeSkip, hasJsAutoHandoff)
&& prevTrack
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id),
);
const bReadyNow = isCrossfadeNextReady(scopedTrack.id, playbackSid, playbackCacheSid);
/** Cold interrupt: engine still on A — don't swap player-bar metadata until handoff. */
const deferInterruptUi = shouldDeferInterruptHandoffUi(wantInterruptBlend, bReadyNow);
const applyInterruptHandoffUi = () => {
set({
currentTrack: scopedTrack,
waveformBins: null,
...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx),
progress: initialProgress,
buffered: 0,
currentTime: initialTime,
scrobbled: false,
networkLoved: false,
isPlaying: playbackSourceHint !== 'stream',
isPlaybackBuffering: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
});
void refreshWaveformForTrack(scopedTrack.id);
void refreshLoudnessForTrack(scopedTrack.id, { syncPlayingEngine: false });
};
if (deferInterruptUi) {
set({
currentRadio: null,
...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}),
queueIndex: idx >= 0 ? idx : 0,
});
} else {
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.
// During interrupt prep A is still audible — keep the play affordance on.
isPlaying: (wantInterruptBlend && wasPlayingBeforeSkip) || playbackSourceHint !== 'stream',
isPlaybackBuffering: wantInterruptBlend && wasPlayingBeforeSkip
? false
: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
});
void refreshWaveformForTrack(scopedTrack.id);
void refreshLoudnessForTrack(
scopedTrack.id,
wantInterruptBlend ? { syncPlayingEngine: false } : undefined,
);
}
setDeferHotCachePrefetch(true);
if (
prevTrack
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id)
&& authStateNow.hotCacheEnabled
) {
const prevPromoteSid = playbackCacheKeyForTrack(prevTrack, prevPlayingRef);
if (prevPromoteSid) {
void promoteCompletedStreamToHotCache(
prevTrack,
prevPromoteSid,
authStateNow.hotCacheDownloadDir || null,
);
}
}
const replayGainDb = resolveReplayGainDb(
scopedTrack, prevTrack, nextNeighbour,
isReplayGainActive(), authStateNow.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
const invokeAudioPlay = (manualBlend: CrossfadeTransitionPlan | null) => {
// Silence-aware crossfade (B-head + dynamic overlap): on a fresh auto-advance
// under crossfade, start past this track's leading silence (always, from the
// plan) and — only when the JS A-tail advance positioned this transition —
// fade over the content-driven overlap it armed. AutoDJ smooth skip uses the
// same rules from the current playback position on manual next/previous.
const useTrimAuto =
!manual
&& authStateNow.crossfadeEnabled
&& authStateNow.crossfadeTrimSilence
&& !authStateNow.gaplessEnabled
&& initialTime <= 0.05;
const useManualBlend = manualBlend !== null;
const crossfadePlan = useTrimAuto ? getCrossfadeTransition(scopedTrack.id) : null;
const armedOverlap = useTrimAuto ? consumeCrossfadeDynamicOverlap(scopedTrack.id) : null;
const crossfadeStartSecs = useManualBlend
? manualBlend.bStartSec
: (crossfadePlan?.bStartSec ?? 0);
const crossfadeSecsOverride = useManualBlend
? manualBlend.overlapSec
: (armedOverlap ? armedOverlap.overlapSec : null);
const outgoingFadeSecsOverride = useManualBlend
? manualBlend.outgoingFadeSec
: (armedOverlap ? armedOverlap.outgoingFadeSec : null);
if (useManualBlend) {
armAutodjMixing(manualBlend.overlapSec);
} else if (crossfadeSecsOverride != null && crossfadeSecsOverride > 0) {
armAutodjMixing(crossfadeSecsOverride);
} else if (manual) {
clearAutodjTransitionUi();
}
invoke('audio_play', {
url,
volume: state.volume,
durationHint: scopedTrack.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(scopedTrack.id),
preGainDb: authStateNow.replayGainPreGainDb,
fallbackDb: authStateNow.replayGainFallbackDb,
manual,
...audioPlayHiResBlendArgs(authStateNow),
analysisTrackId: scopedTrack.id,
serverId: getPlaybackIndexKey() || null,
streamFormatSuffix: scopedTrack.suffix ?? null,
startPaused: false,
startSecs: crossfadeStartSecs > 0.05 ? crossfadeStartSecs : null,
crossfadeSecsOverride,
outgoingFadeSecsOverride,
manualAutodjBlend: useManualBlend ? true : null,
})
.then(() => {
if (getPlayGeneration() !== gen) return;
if (wantInterruptBlend) {
get().updateReplayGainForCurrentTrack();
}
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);
});
};
const finishPlaybackSideEffects = () => {
// Subsonic-server now-playing follows nowPlayingEnabled; Music Network
// now-playing follows scrobbling, as Last.fm now-playing did (runtime gates
// internally). playbackReportStart opens the live FSM on extension-capable
// servers and falls back to the legacy presence call otherwise.
playbackReportStart(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 },
}));
});
}
pushQueueOnPlaybackStart(get().queueItems, scopedTrack, initialTime);
touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid);
};
const startAudio = (manualBlend: CrossfadeTransitionPlan | null) => {
if (deferInterruptUi) applyInterruptHandoffUi();
clearInterruptHandoff();
invokeAudioPlay(manualBlend);
finishPlaybackSideEffects();
};
if (wantInterruptBlend && prevTrack) {
const aDur = prevTrack.duration || 0;
armAutodjMixing(STANDARD_BLEND_SEC);
armInterruptHandoff(gen);
void (async () => {
try {
const [prep, bBins] = await Promise.all([
bReadyNow
? Promise.resolve({ ready: true })
: runInterruptBlendPrep(
scopedTrack,
playbackSid,
playbackCacheSid,
() => getPlayGeneration() !== gen,
),
fetchWaveformBins(scopedTrack.id, playbackCacheSid || null),
]);
if (getPlayGeneration() !== gen) {
clearInterruptHandoff();
return;
}
const blend = prep.ready
? computeAutodjManualBlendPlan(
outgoingWaveformBins,
aDur,
skipFromTimeSec,
bBins,
scopedTrack.duration || 0,
)
: null;
startAudio(blend
? {
...blend,
// Prep fade already ducked A when we waited for a cold B.
outgoingFadeSec: bReadyNow ? blend.outgoingFadeSec : 0,
}
: null);
} catch {
if (getPlayGeneration() !== gen) {
clearInterruptHandoff();
return;
}
startAudio(null);
}
})();
return;
}
startAudio(null);
};
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();
}
}
@@ -0,0 +1,13 @@
// Engine-side registration for the playback-engine bridge. Side-effect module:
// importing it installs the engine's operations into @/store/playbackEngineBridge.
// MainApp side-effect-imports this at boot. Lives with the engine (moves into
// @/features/playback alongside playerStore); the bridge itself stays in core.
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { clearQueueServerForPlayback } from '@/features/playback/utils/playback/playbackServer';
import { registerPlaybackEngineBridge } from '@/store/playbackEngineBridge';
registerPlaybackEngineBridge({
getQueueServerId: () => usePlayerStore.getState().queueServerId,
clearQueueServerForPlayback,
updateReplayGainForCurrentTrack: () => usePlayerStore.getState().updateReplayGainForCurrentTrack(),
});
@@ -0,0 +1,92 @@
/**
* Direct unit coverage for the playback-progress pub/sub — focused on the
* delta-short-circuit behaviour that keeps idle CPU bounded. The end-to-end
* "Tauri event → emit → subscriber" path is covered by
* `playerStore.progress.test.ts`.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
_resetPlaybackProgressForTest,
emitPlaybackProgress,
getPlaybackProgressSnapshot,
subscribePlaybackProgress,
} from '@/features/playback/store/playbackProgress';
afterEach(() => {
_resetPlaybackProgressForTest();
});
describe('getPlaybackProgressSnapshot', () => {
it('starts at zero', () => {
const snap = getPlaybackProgressSnapshot();
expect(snap).toEqual({ currentTime: 0, progress: 0, buffered: 0, buffering: false });
});
it('returns the latest snapshot after an emit', () => {
emitPlaybackProgress({ currentTime: 42, progress: 0.5, buffered: 0.7 });
expect(getPlaybackProgressSnapshot()).toEqual({
currentTime: 42, progress: 0.5, buffered: 0.7, buffering: false,
});
});
});
describe('subscribePlaybackProgress', () => {
it('fires the listener with (next, prev) on a meaningful change', () => {
const cb = vi.fn();
subscribePlaybackProgress(cb);
emitPlaybackProgress({ currentTime: 1, progress: 0.1, buffered: 0.2 });
expect(cb).toHaveBeenCalledTimes(1);
expect(cb.mock.calls[0][0]).toEqual({
currentTime: 1, progress: 0.1, buffered: 0.2, buffering: false,
});
expect(cb.mock.calls[0][1]).toEqual({ currentTime: 0, progress: 0, buffered: 0, buffering: false });
});
it('returns an unsubscribe that detaches the listener', () => {
const cb = vi.fn();
const unsub = subscribePlaybackProgress(cb);
unsub();
emitPlaybackProgress({ currentTime: 1, progress: 0.1, buffered: 0.2 });
expect(cb).not.toHaveBeenCalled();
});
it('fans out to multiple listeners', () => {
const a = vi.fn();
const b = vi.fn();
subscribePlaybackProgress(a);
subscribePlaybackProgress(b);
emitPlaybackProgress({ currentTime: 1, progress: 0.1, buffered: 0.2 });
expect(a).toHaveBeenCalledTimes(1);
expect(b).toHaveBeenCalledTimes(1);
});
});
describe('emitPlaybackProgress delta short-circuit', () => {
it('skips emits whose deltas are below thresholds (currentTime <0.005, progress/buffered <0.0002)', () => {
const cb = vi.fn();
subscribePlaybackProgress(cb);
emitPlaybackProgress({ currentTime: 1.0, progress: 0.5, buffered: 0.6 });
expect(cb).toHaveBeenCalledTimes(1);
// All deltas below the cut-off — must be suppressed.
emitPlaybackProgress({ currentTime: 1.001, progress: 0.50005, buffered: 0.60005 });
expect(cb).toHaveBeenCalledTimes(1);
// currentTime delta crosses the 0.005 threshold — fires.
emitPlaybackProgress({ currentTime: 1.01, progress: 0.50005, buffered: 0.60005 });
expect(cb).toHaveBeenCalledTimes(2);
});
it('fires when only progress crosses its threshold', () => {
const cb = vi.fn();
subscribePlaybackProgress(cb);
emitPlaybackProgress({ currentTime: 1.0, progress: 0.5, buffered: 0.6 });
cb.mockClear();
emitPlaybackProgress({ currentTime: 1.0, progress: 0.5005, buffered: 0.6 });
expect(cb).toHaveBeenCalledTimes(1);
});
it('does not advance the snapshot when an emit is suppressed', () => {
emitPlaybackProgress({ currentTime: 1.0, progress: 0.5, buffered: 0.6 });
emitPlaybackProgress({ currentTime: 1.001, progress: 0.5, buffered: 0.6 });
expect(getPlaybackProgressSnapshot().currentTime).toBe(1.0);
});
});
@@ -0,0 +1,66 @@
/**
* High-frequency playback-progress channel. Decoupled from the main Zustand
* store so subscribers (waveform, time labels, lyrics scroller, mini player
* mirror) can re-render on every audio tick without invalidating selectors
* that watch unrelated player state.
*
* `emitPlaybackProgress` short-circuits when the next snapshot is within a
* sub-perceptible delta of the previous one — keeps idle CPU bounded when
* the player is paused or the engine reports identical frames in a row.
*/
export type PlaybackProgressSnapshot = {
currentTime: number;
progress: number;
buffered: number;
/** Legacy HTTP stream still filling — do not extrapolate the seekbar. */
buffering?: boolean;
};
let playbackProgressSnapshot: PlaybackProgressSnapshot = {
currentTime: 0,
progress: 0,
buffered: 0,
buffering: false,
};
const playbackProgressListeners = new Set<(
next: PlaybackProgressSnapshot,
prev: PlaybackProgressSnapshot,
) => void>();
export function emitPlaybackProgress(next: PlaybackProgressSnapshot): void {
const normalized: PlaybackProgressSnapshot = {
...next,
buffering: next.buffering ?? false,
};
const prev = playbackProgressSnapshot;
if (
Math.abs(prev.currentTime - normalized.currentTime) < 0.005 &&
Math.abs(prev.progress - normalized.progress) < 0.0002 &&
Math.abs(prev.buffered - normalized.buffered) < 0.0002 &&
(prev.buffering ?? false) === normalized.buffering
) {
return;
}
playbackProgressSnapshot = normalized;
playbackProgressListeners.forEach(cb => cb(normalized, prev));
}
export function getPlaybackProgressSnapshot(): PlaybackProgressSnapshot {
return playbackProgressSnapshot;
}
export function subscribePlaybackProgress(
cb: (next: PlaybackProgressSnapshot, prev: PlaybackProgressSnapshot) => void,
): () => void {
playbackProgressListeners.add(cb);
return () => {
playbackProgressListeners.delete(cb);
};
}
/** Test-only: reset module state between specs so suites stay isolated. */
export function _resetPlaybackProgressForTest(): void {
playbackProgressSnapshot = { currentTime: 0, progress: 0, buffered: 0, buffering: false };
playbackProgressListeners.clear();
}
@@ -0,0 +1,107 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import {
clampPlaybackPitch,
clampPlaybackSpeed,
DEFAULT_PLAYBACK_STRATEGY,
effectivePlaybackPitch,
engineStrategy,
type PlaybackStrategy,
} from '@/features/playback/utils/audio/playbackRateHelpers';
import {
restartPlaybackForRateChange,
shouldRestartPlaybackForRateChange,
type PlaybackRateSnapshot,
} from '@/features/playback/utils/audio/playbackRateRestart';
import { isOrbitPlaybackSyncActive } from '@/store/orbitRuntime';
interface PlaybackRateState extends PlaybackRateSnapshot {
/** UI-only: smaller slider steps (Advanced). Not sent to the engine. */
fineStep: boolean;
setEnabled: (v: boolean) => void;
setStrategy: (s: PlaybackStrategy) => void;
setSpeed: (speed: number) => void;
setPitchSemitones: (semitones: number) => void;
applyPresetSpeed: (speed: number) => void;
setFineStep: (v: boolean) => void;
syncToRust: () => void;
}
function syncPlaybackRate(state: PlaybackRateSnapshot, prev?: PlaybackRateSnapshot) {
// Orbit sync assumes 1.0× wall-clock playback; suppress DSP without mutating prefs.
const effectiveEnabled = state.enabled && !isOrbitPlaybackSyncActive();
invoke('audio_set_playback_rate', {
enabled: effectiveEnabled,
strategy: engineStrategy(state.strategy),
speed: state.speed,
pitchSemitones: effectivePlaybackPitch(state.strategy, state.pitchSemitones),
}).catch(() => {});
if (prev && shouldRestartPlaybackForRateChange(prev, state)) {
restartPlaybackForRateChange();
}
}
export const usePlaybackRateStore = create<PlaybackRateState>()(
persist(
(set, get) => ({
enabled: false,
strategy: DEFAULT_PLAYBACK_STRATEGY,
speed: 1.0,
pitchSemitones: 0,
fineStep: false,
setEnabled: (v) => {
const prev = get();
set({ enabled: v });
syncPlaybackRate(get(), prev);
},
setStrategy: (strategy) => {
const prev = get();
set({ strategy });
syncPlaybackRate(get(), prev);
},
setSpeed: (speed) => {
const prev = get();
const clamped = clampPlaybackSpeed(speed);
set({ speed: clamped });
syncPlaybackRate(get(), prev);
},
setPitchSemitones: (semitones) => {
const prev = get();
const clamped = clampPlaybackPitch(semitones);
set({ pitchSemitones: clamped });
syncPlaybackRate(get(), prev);
},
applyPresetSpeed: (speed) => {
const prev = get();
const clamped = clampPlaybackSpeed(speed);
set({ speed: clamped });
syncPlaybackRate(get(), prev);
},
setFineStep: (v) => set({ fineStep: v }),
syncToRust: () => {
syncPlaybackRate(get());
},
}),
{
name: 'psysonic-playback-rate',
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({
enabled: s.enabled,
strategy: s.strategy,
speed: s.speed,
pitchSemitones: s.pitchSemitones,
fineStep: s.fineStep,
}),
},
),
);
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/api/subsonicScrobble', () => ({
reportPlayback: vi.fn(() => Promise.resolve()),
reportNowPlaying: vi.fn(() => Promise.resolve()),
}));
vi.mock('@/serverCapabilities/storeView', () => ({
isFeatureActiveForServer: vi.fn(),
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: { getState: vi.fn(() => ({ nowPlayingEnabled: true })) },
}));
vi.mock('@/features/playback/store/playbackProgress', () => ({
getPlaybackProgressSnapshot: vi.fn(() => ({ currentTime: 12, progress: 0, buffered: 0, buffering: false })),
}));
vi.mock('@/features/playback/store/playbackRateStore', () => ({
usePlaybackRateStore: {
getState: () => ({ enabled: false, strategy: 'speed_corrected', speed: 1, pitchSemitones: 0 }),
},
}));
vi.mock('@/features/playback/utils/audio/playbackRateHelpers', () => ({ isPlaybackRateApplied: () => false }));
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
isOrbitPlaybackSyncActive: () => false,
}));
import { reportNowPlaying, reportPlayback } from '@/lib/api/subsonicScrobble';
import { isFeatureActiveForServer } from '@/serverCapabilities/storeView';
import { useAuthStore } from '@/store/authStore';
import {
_resetPlaybackReportSessionForTest,
playbackReportPaused,
playbackReportPlaying,
playbackReportSeek,
playbackReportStart,
playbackReportStopped,
} from '@/features/playback/store/playbackReportSession';
const reportPlaybackMock = vi.mocked(reportPlayback);
const reportNowPlayingMock = vi.mocked(reportNowPlaying);
const featureActiveMock = vi.mocked(isFeatureActiveForServer);
const authStateMock = vi.mocked(useAuthStore.getState);
const SID = 'srv-1';
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
function lastState(): string | undefined {
const calls = reportPlaybackMock.mock.calls;
const call = calls[calls.length - 1];
return call?.[1].state;
}
beforeEach(() => {
reportPlaybackMock.mockClear();
reportPlaybackMock.mockResolvedValue(undefined);
reportNowPlayingMock.mockClear();
featureActiveMock.mockReset();
featureActiveMock.mockReturnValue(true);
authStateMock.mockReturnValue({ nowPlayingEnabled: true } as never);
_resetPlaybackReportSessionForTest();
});
afterEach(() => {
_resetPlaybackReportSessionForTest();
});
describe('playbackReportStart', () => {
it('does nothing when now-playing is disabled', () => {
authStateMock.mockReturnValue({ nowPlayingEnabled: false } as never);
playbackReportStart('t1', SID);
expect(reportPlaybackMock).not.toHaveBeenCalled();
expect(reportNowPlayingMock).not.toHaveBeenCalled();
});
it('opens the FSM with starting then playing on a new track', async () => {
playbackReportStart('t1', SID);
expect(reportPlaybackMock).toHaveBeenCalledTimes(1);
expect(reportPlaybackMock.mock.calls[0][1]).toMatchObject({
mediaId: 't1',
state: 'starting',
positionMs: 12000,
playbackRate: 1,
ignoreScrobble: true,
});
await flush();
expect(reportPlaybackMock).toHaveBeenCalledTimes(2);
expect(reportPlaybackMock.mock.calls[1][1].state).toBe('playing');
});
it('falls back to the legacy presence call when the extension is absent', () => {
featureActiveMock.mockReturnValue(false);
playbackReportStart('t1', SID);
expect(reportPlaybackMock).not.toHaveBeenCalled();
expect(reportNowPlayingMock).toHaveBeenCalledWith('t1', SID);
});
it('stops the previous server before opening a cross-server session', async () => {
playbackReportStart('t1', SID);
await flush();
reportPlaybackMock.mockClear();
playbackReportStart('t2', 'srv-2');
expect(reportPlaybackMock).toHaveBeenCalledTimes(1);
expect(reportPlaybackMock.mock.calls[0][1]).toMatchObject({
mediaId: 't1',
state: 'stopped',
});
await flush();
expect(reportPlaybackMock.mock.calls[1][1]).toMatchObject({
mediaId: 't2',
state: 'starting',
});
expect(reportPlaybackMock.mock.calls[2][1].state).toBe('playing');
});
});
describe('FSM transitions on an open session', () => {
beforeEach(async () => {
playbackReportStart('t1', SID);
await flush();
reportPlaybackMock.mockClear();
});
it('reports playing on heartbeat with the supplied position', () => {
playbackReportPlaying(30);
expect(reportPlaybackMock).toHaveBeenCalledTimes(1);
expect(reportPlaybackMock.mock.calls[0][1]).toMatchObject({ state: 'playing', positionMs: 30000 });
});
it('reports paused', () => {
playbackReportPaused(45);
expect(lastState()).toBe('paused');
expect(reportPlaybackMock.mock.calls[0][1].positionMs).toBe(45000);
});
it('reports the transport state on seek', () => {
playbackReportSeek(60, true);
expect(lastState()).toBe('playing');
playbackReportSeek(60, false);
expect(lastState()).toBe('paused');
});
it('reports stopped and clears the session', async () => {
await playbackReportStopped(90);
expect(lastState()).toBe('stopped');
reportPlaybackMock.mockClear();
// No session left: further FSM calls are inert.
playbackReportPlaying(100);
playbackReportPaused(100);
expect(reportPlaybackMock).not.toHaveBeenCalled();
});
});
describe('extension toggled off mid-session', () => {
it('stops emitting FSM reports once the server no longer advertises the extension', async () => {
playbackReportStart('t1', SID);
await flush();
reportPlaybackMock.mockClear();
featureActiveMock.mockReturnValue(false);
playbackReportPlaying(10);
playbackReportPaused(10);
expect(reportPlaybackMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,150 @@
import { reportNowPlaying, reportPlayback } from '@/lib/api/subsonicScrobble';
import type { PlaybackReportState } from '@/lib/api/subsonicTypes';
import { FEATURE_PLAYBACK_REPORT } from '@/serverCapabilities/catalog';
import { isFeatureActiveForServer } from '@/serverCapabilities/storeView';
import { isPlaybackRateApplied } from '@/features/playback/utils/audio/playbackRateHelpers';
import { isOrbitPlaybackSyncActive } from '@/store/orbitRuntime';
import { useAuthStore } from '@/store/authStore';
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
import { usePlaybackRateStore } from '@/features/playback/store/playbackRateStore';
/**
* Live now-playing presence on the Subsonic server channel.
*
* When the server advertises the OpenSubsonic `playbackReport` extension
* (Navidrome ≥ 0.62) we drive a small playback state machine — starting →
* playing ↔ paused → stopped — that mirrors the lifecycle hooks already used by
* `playListenSession`. This gives `getNowPlaying` a real transport state and an
* extrapolated position. `ignoreScrobble=true` keeps the server from applying
* scrobble / play-count side effects, because psysonic still owns play counts on
* the dedicated `scrobble.view` channel (the 50% rule in `audioEventHandlers`).
*
* On servers without the extension every entry point degrades to the legacy
* `scrobble.view?submission=false` presence call (`reportNowPlaying`), so the
* behaviour is unchanged there. All presence reporting stays gated on the
* existing `nowPlayingEnabled` master toggle.
*/
type ReportSession = { serverId: string; trackId: string };
let session: ReportSession | null = null;
function nowPlayingEnabled(): boolean {
return useAuthStore.getState().nowPlayingEnabled;
}
function extensionActive(serverId: string): boolean {
return isFeatureActiveForServer(serverId, FEATURE_PLAYBACK_REPORT);
}
/** Effective playback speed sent to the server (1.0 when the speed DSP is off). */
function effectivePlaybackRate(): number {
const { enabled, strategy, speed, pitchSemitones } = usePlaybackRateStore.getState();
return isPlaybackRateApplied(enabled, strategy, speed, pitchSemitones, isOrbitPlaybackSyncActive())
? speed
: 1.0;
}
function positionMs(explicitSec?: number): number {
const sec = explicitSec ?? getPlaybackProgressSnapshot().currentTime;
return Math.max(0, Math.floor((Number.isFinite(sec) ? sec : 0) * 1000));
}
function send(
serverId: string,
trackId: string,
state: PlaybackReportState,
explicitSec?: number,
): Promise<void> {
return reportPlayback(serverId, {
mediaId: trackId,
positionMs: positionMs(explicitSec),
state,
playbackRate: effectivePlaybackRate(),
ignoreScrobble: true,
});
}
function stopExtensionSession(prev: ReportSession, explicitSec?: number): Promise<void> {
if (!extensionActive(prev.serverId)) return Promise.resolve();
return send(prev.serverId, prev.trackId, 'stopped', explicitSec);
}
function openExtensionSession(
serverId: string,
trackId: string,
isNewSession: boolean,
): void {
session = { serverId, trackId };
if (isNewSession) {
void send(serverId, trackId, 'starting').then(() => send(serverId, trackId, 'playing'));
} else {
void send(serverId, trackId, 'playing');
}
}
/**
* Track start / gapless switch / queue restore. Replaces the direct
* `reportNowPlaying` presence call at those sites: the extension path opens the
* FSM (starting → playing); otherwise the legacy presence call is used.
*/
export function playbackReportStart(trackId: string, serverId: string): void {
if (!serverId || !nowPlayingEnabled()) return;
const prev = session;
const isNewSession = !prev || prev.trackId !== trackId || prev.serverId !== serverId;
const serverChanged = prev != null && prev.serverId !== serverId;
const openNext = () => {
if (!extensionActive(serverId)) {
session = { serverId, trackId };
void reportNowPlaying(trackId, serverId);
return;
}
openExtensionSession(serverId, trackId, isNewSession);
};
if (serverChanged) {
session = null;
void stopExtensionSession(prev).then(openNext);
return;
}
openNext();
}
/** Engine-confirmed playback / resume / heartbeat (extension path only). */
export function playbackReportPlaying(explicitSec?: number): void {
if (!session || !nowPlayingEnabled() || !extensionActive(session.serverId)) return;
void send(session.serverId, session.trackId, 'playing', explicitSec);
}
/** Transport paused (extension path only). */
export function playbackReportPaused(explicitSec?: number): void {
if (!session || !nowPlayingEnabled() || !extensionActive(session.serverId)) return;
void send(session.serverId, session.trackId, 'paused', explicitSec);
}
/** Seek settled — report the new position with the current transport state. */
export function playbackReportSeek(explicitSec: number, isPlaying: boolean): void {
if (!session || !nowPlayingEnabled() || !extensionActive(session.serverId)) return;
void send(session.serverId, session.trackId, isPlaying ? 'playing' : 'paused', explicitSec);
}
/**
* Playback stopped (manual stop / ended / error / app quit). Clears the session
* and tells the server to drop the now-playing entry. Returns the in-flight
* request so the exit flow can race it against a timeout.
*/
export function playbackReportStopped(explicitSec?: number): Promise<void> {
if (!session) return Promise.resolve();
const { serverId, trackId } = session;
session = null;
if (!extensionActive(serverId)) return Promise.resolve();
return send(serverId, trackId, 'stopped', explicitSec);
}
/** Test-only reset. */
export function _resetPlaybackReportSessionForTest(): void {
session = null;
}
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
LIVE_PROGRESS_EMIT_MIN_DELTA_SEC,
LIVE_PROGRESS_EMIT_MIN_MS,
NORMALIZATION_UI_THROTTLE_MS,
STORE_PROGRESS_COMMIT_MIN_DELTA_SEC,
STORE_PROGRESS_COMMIT_MIN_MS,
_resetPlaybackThrottlesForTest,
getLastLiveProgressEmitAt,
getLastNormalizationUiUpdateAtMs,
getLastStoreProgressCommitAt,
markLiveProgressEmit,
markNormalizationUiUpdate,
markStoreProgressCommit,
resetProgressEmitThrottles,
} from '@/features/playback/store/playbackThrottles';
afterEach(() => {
_resetPlaybackThrottlesForTest();
});
describe('constants', () => {
it('match the values the runtime expects', () => {
expect(LIVE_PROGRESS_EMIT_MIN_MS).toBe(1500);
expect(LIVE_PROGRESS_EMIT_MIN_DELTA_SEC).toBe(0.9);
expect(STORE_PROGRESS_COMMIT_MIN_MS).toBe(20_000);
expect(STORE_PROGRESS_COMMIT_MIN_DELTA_SEC).toBe(5.0);
expect(NORMALIZATION_UI_THROTTLE_MS).toBe(120);
});
});
describe('throttle accessors', () => {
it('all start at 0', () => {
expect(getLastLiveProgressEmitAt()).toBe(0);
expect(getLastStoreProgressCommitAt()).toBe(0);
expect(getLastNormalizationUiUpdateAtMs()).toBe(0);
});
it('mark + get round-trip independently for each throttle', () => {
markLiveProgressEmit(1000);
markStoreProgressCommit(2000);
markNormalizationUiUpdate(3000);
expect(getLastLiveProgressEmitAt()).toBe(1000);
expect(getLastStoreProgressCommitAt()).toBe(2000);
expect(getLastNormalizationUiUpdateAtMs()).toBe(3000);
});
it('mark overwrites a previous value', () => {
markLiveProgressEmit(1000);
markLiveProgressEmit(2000);
expect(getLastLiveProgressEmitAt()).toBe(2000);
});
});
describe('resetProgressEmitThrottles', () => {
it('zeros both progress throttles but leaves normalization UI alone', () => {
markLiveProgressEmit(1000);
markStoreProgressCommit(2000);
markNormalizationUiUpdate(3000);
resetProgressEmitThrottles();
expect(getLastLiveProgressEmitAt()).toBe(0);
expect(getLastStoreProgressCommitAt()).toBe(0);
expect(getLastNormalizationUiUpdateAtMs()).toBe(3000);
});
});
describe('_resetPlaybackThrottlesForTest', () => {
it('zeros all three timestamps', () => {
markLiveProgressEmit(1);
markStoreProgressCommit(2);
markNormalizationUiUpdate(3);
_resetPlaybackThrottlesForTest();
expect(getLastLiveProgressEmitAt()).toBe(0);
expect(getLastStoreProgressCommitAt()).toBe(0);
expect(getLastNormalizationUiUpdateAtMs()).toBe(0);
});
});
@@ -0,0 +1,64 @@
/**
* Three time-based throttles used by the audio-progress handler so it
* doesn't saturate the WebView2 renderer with redundant emits / setState
* commits on a noisy `audio:progress` stream:
*
* - **Live progress emit** — feeds the high-frequency `playbackProgress`
* pub/sub. Gate: 1.5 s elapsed OR ≥0.9 s position delta.
* - **Store progress commit** — writes the Zustand player store. Gate:
* 20 s elapsed OR ≥5 s position delta. Heavier than the live emit
* because subscribers to the store re-render.
* - **Normalization UI update** — 120 ms throttle on the live dB readout
* written to the store during a track.
*
* The progress emit + commit pair share `resetProgressEmitThrottles` so
* the runtime can force a fresh emit/commit immediately after a track
* boundary (`handleAudioPlaying`).
*/
export const LIVE_PROGRESS_EMIT_MIN_MS = 1500;
export const LIVE_PROGRESS_EMIT_MIN_DELTA_SEC = 0.9;
export const STORE_PROGRESS_COMMIT_MIN_MS = 20_000;
export const STORE_PROGRESS_COMMIT_MIN_DELTA_SEC = 5.0;
export const NORMALIZATION_UI_THROTTLE_MS = 120;
let lastLiveProgressEmitAt = 0;
let lastStoreProgressCommitAt = 0;
let lastNormalizationUiUpdateAtMs = 0;
export function getLastLiveProgressEmitAt(): number {
return lastLiveProgressEmitAt;
}
export function markLiveProgressEmit(nowMs: number): void {
lastLiveProgressEmitAt = nowMs;
}
export function getLastStoreProgressCommitAt(): number {
return lastStoreProgressCommitAt;
}
export function markStoreProgressCommit(nowMs: number): void {
lastStoreProgressCommitAt = nowMs;
}
export function getLastNormalizationUiUpdateAtMs(): number {
return lastNormalizationUiUpdateAtMs;
}
export function markNormalizationUiUpdate(nowMs: number): void {
lastNormalizationUiUpdateAtMs = nowMs;
}
/** Reset the two progress throttles — used by `handleAudioPlaying` so the first emit + commit after a track boundary go through immediately. */
export function resetProgressEmitThrottles(): void {
lastLiveProgressEmitAt = 0;
lastStoreProgressCommitAt = 0;
}
/** Test-only: wipe all three timestamps so each spec starts fresh. */
export function _resetPlaybackThrottlesForTest(): void {
lastLiveProgressEmitAt = 0;
lastStoreProgressCommitAt = 0;
lastNormalizationUiUpdateAtMs = 0;
}
@@ -0,0 +1,109 @@
/**
* Playback-URL routing characterization. The non-trivial behaviour is
* (a) the module-scoped `lastOpenedWithHttpTrackId` only persists for HTTP
* URLs (offline / hot-cache plays clear it), (b) the rebind check matches
* across the `stream:` / bare id forms, and (c) the source-kind classifier
* picks 'offline' vs 'hot' only when the library index has a local file.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { offlineStoreState } = vi.hoisted(() => ({
offlineStoreState: { localUrlByKey: new Map<string, string>() },
}));
vi.mock('@/store/localPlaybackResolve', () => ({
findLocalPlaybackUrl: (trackId: string, serverId: string, tier: string) => {
if (tier !== 'library') return null;
return offlineStoreState.localUrlByKey.has(`${serverId}:${trackId}`)
? `psysonic-local://${serverId}/${trackId}`
: null;
},
}));
vi.mock('@/features/playback/utils/playback/resolvePlaybackUrl', () => ({
resolvePlaybackUrl: vi.fn((trackId: string, serverId: string) => {
if (offlineStoreState.localUrlByKey.has(`${serverId}:${trackId}`)) {
return `psysonic-local://${serverId}/${trackId}`;
}
return `https://mock/${serverId}/${trackId}`;
}),
}));
import {
_resetPlaybackUrlRoutingForTest,
playbackSourceHintForResolvedUrl,
recordEnginePlayUrl,
shouldRebindPlaybackToHotCache,
} from '@/features/playback/store/playbackUrlRouting';
beforeEach(() => {
offlineStoreState.localUrlByKey.clear();
});
afterEach(() => {
_resetPlaybackUrlRoutingForTest();
});
describe('playbackSourceHintForResolvedUrl', () => {
it("classifies non-local URLs as 'stream'", () => {
expect(playbackSourceHintForResolvedUrl('t1', 'srv', 'https://mock/srv/t1')).toBe('stream');
});
it("classifies psysonic-local:// as 'offline' when the library index has the file", () => {
offlineStoreState.localUrlByKey.set('srv:t1', 'file:///cache/t1.mp3');
expect(playbackSourceHintForResolvedUrl('t1', 'srv', 'psysonic-local://srv/t1')).toBe('offline');
});
it("classifies psysonic-local:// as 'hot' when no offline copy exists", () => {
expect(playbackSourceHintForResolvedUrl('t1', 'srv', 'psysonic-local://srv/t1')).toBe('hot');
});
});
describe('recordEnginePlayUrl + shouldRebindPlaybackToHotCache', () => {
it('records HTTP URLs and rebinds when the resolved URL later goes local', () => {
recordEnginePlayUrl('t1', 'https://mock/srv/t1');
offlineStoreState.localUrlByKey.set('srv:t1', 'file:///cache/t1.mp3');
expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(true);
});
it('does not rebind when the recorded URL was already local', () => {
recordEnginePlayUrl('t1', 'psysonic-local://srv/t1');
offlineStoreState.localUrlByKey.set('srv:t1', 'file:///cache/t1.mp3');
expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(false);
});
it('matches the recorded id across the stream: prefix', () => {
recordEnginePlayUrl('stream:t1', 'https://mock/srv/stream:t1');
offlineStoreState.localUrlByKey.set('srv:t1', 'file:///cache/t1.mp3');
expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(true);
});
it('returns false for a different track id', () => {
recordEnginePlayUrl('t1', 'https://mock/srv/t1');
offlineStoreState.localUrlByKey.set('srv:t2', 'file:///cache/t2.mp3');
expect(shouldRebindPlaybackToHotCache('t2', 'srv')).toBe(false);
});
it('returns false when serverId is empty', () => {
recordEnginePlayUrl('t1', 'https://mock/srv/t1');
expect(shouldRebindPlaybackToHotCache('t1', '')).toBe(false);
});
it('returns false when nothing has been recorded yet', () => {
expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(false);
});
it('returns false when the resolved URL is still HTTP (no hot-cache entry)', () => {
recordEnginePlayUrl('t1', 'https://mock/srv/t1');
expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(false);
});
});
describe('_resetPlaybackUrlRoutingForTest', () => {
it('clears the recorded id', () => {
recordEnginePlayUrl('t1', 'https://mock/srv/t1');
_resetPlaybackUrlRoutingForTest();
offlineStoreState.localUrlByKey.set('srv:t1', 'file:///cache/t1.mp3');
expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
import { resolvePlaybackUrl, type PlaybackSourceKind } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
import { sameQueueTrackId } from '@/features/playback/utils/playback/queueIdentity';
/**
* Helpers that classify the URL `audio_play` was last invoked with, so the
* runtime can rebind playback onto a freshly-promoted hot-cache entry the
* next time the same track plays.
*
* `lastOpenedWithHttpTrackId` remembers the most recent track id we asked
* the engine to fetch over HTTP (anything that isn't the `psysonic-local://`
* scheme). When the hot-cache later promotes that track to a local URL we
* compare against this id to decide whether a transparent rebind is worth
* triggering.
*/
let lastOpenedWithHttpTrackId: string | null = null;
export function recordEnginePlayUrl(trackId: string, url: string): void {
lastOpenedWithHttpTrackId = url.startsWith('psysonic-local://') ? null : trackId;
}
/** Matches `playTrack` / PlayerBar: stream vs hot-cache vs offline file from resolved `audio_play` URL. */
export function playbackSourceHintForResolvedUrl(trackId: string, serverId: string, url: string): PlaybackSourceKind {
if (!url.startsWith('psysonic-local://')) return 'stream';
const profileId = resolveServerIdForIndexKey(serverId) || serverId;
return findLocalPlaybackUrl(trackId, profileId, 'library') ? 'offline' : 'hot';
}
export function shouldRebindPlaybackToHotCache(trackId: string, serverId: string): boolean {
if (!serverId) return false;
if (!lastOpenedWithHttpTrackId || !sameQueueTrackId(lastOpenedWithHttpTrackId, trackId)) {
return false;
}
return resolvePlaybackUrl(trackId, serverId).startsWith('psysonic-local://');
}
/** Test-only: clear the module-scoped track id so each test starts clean. */
export function _resetPlaybackUrlRoutingForTest(): void {
lastOpenedWithHttpTrackId = null;
}
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
persistPlayerPrefs,
readInitialPlayerPrefs,
} from '@/features/playback/store/playerPrefsStorage';
const PREFS_KEY = 'psysonic_player_prefs';
const LEGACY_KEY = 'psysonic-player';
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
describe('readInitialPlayerPrefs', () => {
it('defaults when nothing is stored', () => {
expect(readInitialPlayerPrefs()).toEqual({ volume: 0.8, repeatMode: 'off' });
});
it('reads the dedicated prefs key', () => {
window.localStorage.setItem(PREFS_KEY, JSON.stringify({ volume: 0.42, repeatMode: 'all' }));
expect(readInitialPlayerPrefs()).toEqual({ volume: 0.42, repeatMode: 'all' });
});
it('clamps an out-of-range volume', () => {
window.localStorage.setItem(PREFS_KEY, JSON.stringify({ volume: 1.5, repeatMode: 'one' }));
expect(readInitialPlayerPrefs()).toEqual({ volume: 1, repeatMode: 'one' });
});
it('falls back to the legacy psysonic-player blob when the dedicated key is absent', () => {
window.localStorage.setItem(
LEGACY_KEY,
JSON.stringify({ state: { volume: 0.25, repeatMode: 'one', queueItems: [] } }),
);
expect(readInitialPlayerPrefs()).toEqual({ volume: 0.25, repeatMode: 'one' });
});
it('prefers the dedicated key over the legacy blob', () => {
window.localStorage.setItem(PREFS_KEY, JSON.stringify({ volume: 0.6, repeatMode: 'off' }));
window.localStorage.setItem(
LEGACY_KEY,
JSON.stringify({ state: { volume: 0.1, repeatMode: 'all' } }),
);
expect(readInitialPlayerPrefs()).toEqual({ volume: 0.6, repeatMode: 'off' });
});
});
describe('persistPlayerPrefs', () => {
it('round-trips through readInitialPlayerPrefs', () => {
persistPlayerPrefs({ volume: 0.33, repeatMode: 'all' });
expect(readInitialPlayerPrefs()).toEqual({ volume: 0.33, repeatMode: 'all' });
});
});
@@ -0,0 +1,88 @@
/**
* Small player preferences that must survive even when the main
* `psysonic-player` Zustand blob exceeds the localStorage quota (full
* queueItems persist since thin-state #872). Same pattern as
* `queueVisibilityStorage.ts`.
*/
export type PlayerRepeatMode = 'off' | 'all' | 'one';
export type PlayerPrefs = {
volume: number;
repeatMode: PlayerRepeatMode;
};
const PREFS_STORAGE_KEY = 'psysonic_player_prefs';
const LEGACY_PLAYER_STORAGE_KEY = 'psysonic-player';
const DEFAULT_VOLUME = 0.8;
const DEFAULT_REPEAT_MODE: PlayerRepeatMode = 'off';
const REPEAT_MODES: readonly PlayerRepeatMode[] = ['off', 'all', 'one'];
function clampVolume(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_VOLUME;
return Math.max(0, Math.min(1, value));
}
function parseRepeatMode(value: unknown): PlayerRepeatMode {
if (typeof value === 'string' && (REPEAT_MODES as readonly string[]).includes(value)) {
return value as PlayerRepeatMode;
}
return DEFAULT_REPEAT_MODE;
}
function readLegacyPrefsFromPlayerBlob(): Partial<PlayerPrefs> | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(LEGACY_PLAYER_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: Partial<PlayerPrefs> };
return parsed.state ?? null;
} catch {
return null;
}
}
export function readInitialPlayerPrefs(): PlayerPrefs {
if (typeof window === 'undefined') {
return { volume: DEFAULT_VOLUME, repeatMode: DEFAULT_REPEAT_MODE };
}
try {
const raw = window.localStorage.getItem(PREFS_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<PlayerPrefs>;
return {
volume: clampVolume(typeof parsed.volume === 'number' ? parsed.volume : DEFAULT_VOLUME),
repeatMode: parseRepeatMode(parsed.repeatMode),
};
}
} catch {
// fall through to legacy blob / defaults
}
const legacy = readLegacyPrefsFromPlayerBlob();
if (legacy) {
return {
volume: clampVolume(typeof legacy.volume === 'number' ? legacy.volume : DEFAULT_VOLUME),
repeatMode: parseRepeatMode(legacy.repeatMode),
};
}
return { volume: DEFAULT_VOLUME, repeatMode: DEFAULT_REPEAT_MODE };
}
export function persistPlayerPrefs(prefs: PlayerPrefs): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(
PREFS_STORAGE_KEY,
JSON.stringify({
volume: clampVolume(prefs.volume),
repeatMode: parseRepeatMode(prefs.repeatMode),
}),
);
} catch {
// best-effort — in-memory state still works this session
}
}
@@ -0,0 +1,279 @@
/**
* Audio-event handler characterization for `playerStore` (Phase F1 / PR 2b).
*
* Drives the Rust engine's `audio:*` channels through `emitTauriEvent` and
* asserts on observable store state. Also covers the listener-lifecycle
* regression test from §4.2 of the pre-refactor testing plan v2 — the
* cleanup function returned by `initAudioListeners` must actually unsub.
*/
import { initAudioListeners } from '@/features/playback/store/initAudioListeners';
import { armInterruptHandoff, clearInterruptHandoff } from '@/features/playback/utils/playback/autodjInterruptPrep';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/api/subsonic', async () => {
const actual = await vi.importActual<typeof import('@/lib/api/subsonic')>('@/lib/api/subsonic');
return {
...actual,
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
setRating: vi.fn(async () => undefined),
};
});
vi.mock('@/music-network', () => {
const runtime = {
getEnrichmentPrimaryId: vi.fn(() => null),
dispatchScrobble: vi.fn(async () => undefined),
dispatchNowPlaying: vi.fn(async () => undefined),
isTrackLoved: vi.fn(async () => false),
setTrackLoved: vi.fn(async () => undefined),
syncLovedTracks: vi.fn(async () => ({})),
};
return {
getMusicNetworkRuntime: () => runtime,
getMusicNetworkRuntimeOrNull: () => runtime,
};
});
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
orbitBulkGuard: vi.fn(async () => true),
}));
import { usePlayerStore } from '@/features/playback/store/playerStore';
import {
emitTauriEvent,
onInvoke,
tauriMockListenerCount,
} from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
function stubPlaybackInvokes(): void {
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_resume', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('audio_set_normalization', () => undefined);
onInvoke('discord_update_presence', () => undefined);
onInvoke('frontend_debug_log', () => undefined);
}
let cleanupListeners: (() => void) | null = null;
beforeEach(() => {
vi.useFakeTimers();
resetPlayerStore();
resetAuthStore();
stubPlaybackInvokes();
cleanupListeners = initAudioListeners();
});
afterEach(() => {
cleanupListeners?.();
cleanupListeners = null;
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
describe('audio:progress', () => {
it('commits currentTime to the store when transport is active', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
// Above the 5 s store-commit threshold so the first event causes a write.
emitTauriEvent('audio:progress', { current_time: 25, duration: 100 });
expect(usePlayerStore.getState().currentTime).toBeCloseTo(25, 5);
expect(usePlayerStore.getState().progress).toBeCloseTo(0.25, 5);
});
it('is ignored when there is no current track', () => {
usePlayerStore.setState({ currentTrack: null, currentTime: 0 });
emitTauriEvent('audio:progress', { current_time: 25, duration: 100 });
expect(usePlayerStore.getState().currentTime).toBe(0);
});
it('is ignored when transport is inactive (paused, no radio)', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track, isPlaying: false, currentTime: 0 });
emitTauriEvent('audio:progress', { current_time: 25, duration: 100 });
expect(usePlayerStore.getState().currentTime).toBe(0);
});
it('uses the track duration when the event reports duration ≤ 0', () => {
const track = makeTrack({ duration: 200 });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
emitTauriEvent('audio:progress', { current_time: 50, duration: 0 });
// Falls back to track.duration = 200, so progress = 50/200 = 0.25.
expect(usePlayerStore.getState().progress).toBeCloseTo(0.25, 5);
});
});
describe('audio:track_switched', () => {
it('advances to queue[queueIndex + 1] under repeatMode=off', () => {
const queue = makeTracks(3);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({ repeatMode: 'off' });
emitTauriEvent('audio:track_switched', queue[1].duration);
const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe(queue[1].id);
expect(s.queueIndex).toBe(1);
expect(s.isPlaying).toBe(true);
expect(s.scrobbled).toBe(false);
expect(s.progress).toBe(0);
expect(s.currentTime).toBe(0);
});
it('replays the same track under repeatMode=one (queueIndex stays put)', () => {
const queue = makeTracks(3);
seedQueue(queue, { index: 1, currentTrack: queue[1] });
usePlayerStore.setState({ repeatMode: 'one' });
emitTauriEvent('audio:track_switched', queue[1].duration);
const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe(queue[1].id);
expect(s.queueIndex).toBe(1);
});
it('wraps to queue[0] when at the end with repeatMode=all', () => {
const queue = makeTracks(3);
seedQueue(queue, { index: 2, currentTrack: queue[2] });
usePlayerStore.setState({ repeatMode: 'all' });
emitTauriEvent('audio:track_switched', queue[0].duration);
const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe(queue[0].id);
expect(s.queueIndex).toBe(0);
});
it('is a no-op when at the end with repeatMode=off', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 1, currentTrack: queue[1] });
usePlayerStore.setState({ repeatMode: 'off' });
emitTauriEvent('audio:track_switched', queue[1].duration);
// No next candidate → handler returns early before state changes.
const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe(queue[1].id);
expect(s.queueIndex).toBe(1);
});
it('resets scrobbled + networkLoved flags so the new track can be rescored', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({ scrobbled: true, networkLoved: true });
emitTauriEvent('audio:track_switched', queue[1].duration);
expect(usePlayerStore.getState().scrobbled).toBe(false);
expect(usePlayerStore.getState().networkLoved).toBe(false);
});
});
describe('audio:ended', () => {
// Module-scope `lastGaplessSwitchTime` is updated by handleAudioTrackSwitched
// in adjacent tests; the ghost-guard skips audio:ended events fired within
// 600 ms of a track switch. Advance the fake clock to bypass the guard.
beforeEach(() => {
vi.advanceTimersByTime(1000);
});
it('immediately resets playback bookkeeping (before the 150 ms next() timer)', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({
isPlaying: true,
progress: 0.99,
currentTime: 178,
buffered: 1,
});
emitTauriEvent('audio:ended', undefined);
const s = usePlayerStore.getState();
expect(s.isPlaying).toBe(false);
expect(s.progress).toBe(0);
expect(s.currentTime).toBe(0);
expect(s.buffered).toBe(0);
// currentTrack stays — `next()` (deferred 150 ms) will replace it.
expect(s.currentTrack?.id).toBe(queue[0].id);
});
it('ignores ended while an interrupt handoff is pending', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[1] });
usePlayerStore.setState({ isPlaying: true, progress: 0.5, currentTime: 90 });
armInterruptHandoff(1);
emitTauriEvent('audio:ended', undefined);
expect(usePlayerStore.getState().isPlaying).toBe(true);
clearInterruptHandoff();
});
it('clears state and currentRadio for a radio stream without advancing the queue', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({
currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' },
isPlaying: true,
});
emitTauriEvent('audio:ended', undefined);
const s = usePlayerStore.getState();
expect(s.isPlaying).toBe(false);
expect(s.currentRadio).toBeNull();
expect(s.progress).toBe(0);
expect(s.currentTime).toBe(0);
// Queue not advanced.
expect(s.queueIndex).toBe(0);
expect(s.currentTrack?.id).toBe(queue[0].id);
});
});
describe('initAudioListeners — listener lifecycle (regression §4.2)', () => {
it('registers exactly one listener per audio:* channel', () => {
// beforeEach already called initAudioListeners once.
expect(tauriMockListenerCount('audio:progress')).toBe(1);
expect(tauriMockListenerCount('audio:ended')).toBe(1);
expect(tauriMockListenerCount('audio:track_switched')).toBe(1);
expect(tauriMockListenerCount('audio:playing')).toBe(1);
});
it('cleanup() removes all audio:* listeners it registered', async () => {
// Tear down the listeners attached by beforeEach.
cleanupListeners?.();
cleanupListeners = null;
// `pending.forEach(p => p.then(unlisten => unlisten()))` runs in microtasks
// — flush twice to ride through the then-chain.
await Promise.resolve();
await Promise.resolve();
expect(tauriMockListenerCount('audio:progress')).toBe(0);
expect(tauriMockListenerCount('audio:ended')).toBe(0);
expect(tauriMockListenerCount('audio:track_switched')).toBe(0);
expect(tauriMockListenerCount('audio:playing')).toBe(0);
});
it('re-init after cleanup keeps the count at 1 (no leak)', async () => {
cleanupListeners?.();
cleanupListeners = null;
await Promise.resolve();
await Promise.resolve();
cleanupListeners = initAudioListeners();
expect(tauriMockListenerCount('audio:progress')).toBe(1);
expect(tauriMockListenerCount('audio:track_switched')).toBe(1);
});
it('init without cleanup stacks listeners — guards against missing useEffect cleanup', () => {
// Demonstrates the pre-ShortcutMap bug shape: calling init twice without
// tearing down accumulates handlers. The Real Fix lives in the consumer
// (React useEffect cleanup); this test pins the contract so a refactor
// that silently swallows the cleanup return value fails loudly.
const second = initAudioListeners();
expect(tauriMockListenerCount('audio:progress')).toBe(2);
second();
});
});
@@ -0,0 +1,333 @@
/**
* Miscellaneous-action characterization for `playerStore` — pushes Phase F1
* past the 50 % line-coverage floor without touching `playTrack` (which is
* its own async beast).
*
* Covers the smaller surfaces 2a / 2b / 2c skipped: shuffleQueue,
* shuffleUpcomingQueue, stop, setStarredOverride / setUserRatingOverride,
* toggleQueue / setQueueVisible, toggleFullscreen, openContextMenu /
* closeContextMenu, openSongInfo / closeSongInfo, setNetworkLoved /
* setNetworkLovedForSong, pruneUpcomingToCurrent, setProgress.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const runtimeMock = {
getEnrichmentPrimaryId: vi.fn<() => string | null>(() => null),
setTrackLoved: vi.fn(async () => undefined),
isTrackLoved: vi.fn(async () => false),
syncLovedTracks: vi.fn(async () => ({})),
};
vi.mock('@/lib/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
setRating: vi.fn(async () => undefined),
star: vi.fn(async () => undefined),
unstar: vi.fn(async () => undefined),
}));
vi.mock('@/music-network', () => ({
getMusicNetworkRuntime: () => runtimeMock,
getMusicNetworkRuntimeOrNull: () => runtimeMock,
}));
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
orbitBulkGuard: vi.fn(async () => true),
}));
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
beforeEach(() => {
resetPlayerStore();
resetAuthStore();
runtimeMock.getEnrichmentPrimaryId.mockReturnValue(null);
runtimeMock.setTrackLoved.mockClear();
runtimeMock.isTrackLoved.mockResolvedValue(false);
runtimeMock.syncLovedTracks.mockResolvedValue({});
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('audio_set_normalization', () => undefined);
});
afterEach(() => {
vi.useRealTimers();
});
describe('setStarredOverride', () => {
it('stores per-track starred booleans', () => {
usePlayerStore.getState().setStarredOverride('t-1', true);
usePlayerStore.getState().setStarredOverride('t-2', false);
expect(usePlayerStore.getState().starredOverrides).toEqual({
't-1': true,
't-2': false,
});
});
});
describe('setUserRatingOverride', () => {
it('stores per-track rating overrides', () => {
usePlayerStore.getState().setUserRatingOverride('t-1', 4);
usePlayerStore.getState().setUserRatingOverride('t-2', 5);
expect(usePlayerStore.getState().userRatingOverrides).toEqual({
't-1': 4,
't-2': 5,
});
});
});
describe('openContextMenu / closeContextMenu', () => {
it('opens with position + item + type + queueIndex', () => {
const track = makeTrack();
usePlayerStore.getState().openContextMenu(100, 200, track, 'song', 5);
const cm = usePlayerStore.getState().contextMenu;
expect(cm.isOpen).toBe(true);
expect(cm.x).toBe(100);
expect(cm.y).toBe(200);
expect(cm.type).toBe('song');
expect(cm.queueIndex).toBe(5);
});
it('closeContextMenu flips isOpen but preserves the rest of the menu state', () => {
const track = makeTrack();
usePlayerStore.getState().openContextMenu(50, 50, track, 'song');
usePlayerStore.getState().closeContextMenu();
const cm = usePlayerStore.getState().contextMenu;
expect(cm.isOpen).toBe(false);
expect(cm.x).toBe(50);
expect(cm.type).toBe('song');
});
});
describe('openSongInfo / closeSongInfo', () => {
it('opens with the song id and clears on close', () => {
usePlayerStore.getState().openSongInfo('song-1');
expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: true, songId: 'song-1' });
usePlayerStore.getState().closeSongInfo();
expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: false, songId: null });
});
});
describe('toggleQueue / setQueueVisible', () => {
it('toggleQueue flips isQueueVisible', () => {
const before = usePlayerStore.getState().isQueueVisible;
usePlayerStore.getState().toggleQueue();
expect(usePlayerStore.getState().isQueueVisible).toBe(!before);
usePlayerStore.getState().toggleQueue();
expect(usePlayerStore.getState().isQueueVisible).toBe(before);
});
it('setQueueVisible writes through verbatim', () => {
usePlayerStore.getState().setQueueVisible(true);
expect(usePlayerStore.getState().isQueueVisible).toBe(true);
usePlayerStore.getState().setQueueVisible(false);
expect(usePlayerStore.getState().isQueueVisible).toBe(false);
});
});
describe('toggleFullscreen', () => {
it('flips isFullscreenOpen', () => {
expect(usePlayerStore.getState().isFullscreenOpen).toBe(false);
usePlayerStore.getState().toggleFullscreen();
expect(usePlayerStore.getState().isFullscreenOpen).toBe(true);
usePlayerStore.getState().toggleFullscreen();
expect(usePlayerStore.getState().isFullscreenOpen).toBe(false);
});
});
describe('setNetworkLoved / toggleNetworkLove', () => {
it('setNetworkLoved writes the flag verbatim (no primary gate inside the setter)', () => {
usePlayerStore.setState({ currentTrack: makeTrack(), networkLoved: false });
usePlayerStore.getState().setNetworkLoved(true);
expect(usePlayerStore.getState().networkLoved).toBe(true);
});
it('setNetworkLoved also caches the value under "title::artist" when there is a current track', () => {
usePlayerStore.setState({
currentTrack: makeTrack({ title: 'Hello', artist: 'Adele' }),
networkLoved: false,
});
usePlayerStore.getState().setNetworkLoved(true);
expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(true);
});
it('setNetworkLoved without a current track only updates the flag, not the cache', () => {
usePlayerStore.setState({ currentTrack: null, networkLoved: false, networkLovedCache: {} });
usePlayerStore.getState().setNetworkLoved(true);
expect(usePlayerStore.getState().networkLoved).toBe(true);
expect(usePlayerStore.getState().networkLovedCache).toEqual({});
});
it('toggleNetworkLove is a no-op without a current track', () => {
runtimeMock.getEnrichmentPrimaryId.mockReturnValue('primary');
usePlayerStore.setState({ currentTrack: null, networkLoved: false });
usePlayerStore.getState().toggleNetworkLove();
expect(usePlayerStore.getState().networkLoved).toBe(false);
expect(runtimeMock.setTrackLoved).not.toHaveBeenCalled();
});
it('toggleNetworkLove flips state and writes through the runtime when a track + primary are present', () => {
runtimeMock.getEnrichmentPrimaryId.mockReturnValue('primary');
usePlayerStore.setState({ currentTrack: makeTrack({ title: 'T', artist: 'A' }), networkLoved: false });
usePlayerStore.getState().toggleNetworkLove();
expect(usePlayerStore.getState().networkLoved).toBe(true);
expect(usePlayerStore.getState().networkLovedCache['T::A']).toBe(true);
expect(runtimeMock.setTrackLoved).toHaveBeenCalledWith({ title: 'T', artist: 'A' }, true);
});
});
describe('setNetworkLovedForSong', () => {
it('caches loved state under the "title::artist" key', () => {
usePlayerStore.getState().setNetworkLovedForSong('Hello', 'Adele', true);
expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(true);
usePlayerStore.getState().setNetworkLovedForSong('Hello', 'Adele', false);
expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(false);
});
});
describe('setProgress', () => {
it('writes currentTime / progress / duration', () => {
usePlayerStore.setState({ currentTrack: makeTrack({ duration: 200 }) });
usePlayerStore.getState().setProgress(50, 200);
const s = usePlayerStore.getState();
expect(s.currentTime).toBe(50);
expect(s.progress).toBeCloseTo(0.25, 4);
});
});
describe('stop', () => {
it('invokes audio_stop and clears playback state', () => {
seedQueue(makeTracks(2), { index: 0, currentTrack: makeTrack() });
usePlayerStore.setState({
isPlaying: true,
progress: 0.5,
currentTime: 60,
});
usePlayerStore.getState().stop();
expect(invokeMock).toHaveBeenCalledWith('audio_stop');
const s = usePlayerStore.getState();
expect(s.isPlaying).toBe(false);
expect(s.progress).toBe(0);
expect(s.currentTime).toBe(0);
});
it('keeps the waveform of the still-shown track and re-hydrates it from the DB', () => {
const track = makeTrack({ id: 'wf-keep' });
seedQueue([track], { index: 0, currentTrack: track });
usePlayerStore.setState({ isPlaying: true, waveformBins: [10, 20, 30] });
onInvoke('analysis_get_waveform_for_track', () => null);
usePlayerStore.getState().stop();
// currentTrack survives a stop, so its waveform bins must not be wiped.
expect(usePlayerStore.getState().waveformBins).toEqual([10, 20, 30]);
expect(invokeMock).toHaveBeenCalledWith(
'analysis_get_waveform_for_track',
expect.objectContaining({ trackId: 'wf-keep' }),
);
});
});
describe('shuffleQueue', () => {
it('is a no-op when the queue has fewer than 2 tracks', () => {
const t = makeTrack({ id: 'only' });
seedQueue([t], { index: 0, currentTrack: t });
usePlayerStore.getState().shuffleQueue();
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['only']);
});
it('keeps the current track at queueIndex 0 with the rest shuffled around it', () => {
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
const current = tracks[2];
seedQueue(tracks, { index: 2, currentTrack: current });
// Pin the RNG so the shuffle is deterministic.
vi.spyOn(Math, 'random').mockReturnValue(0);
usePlayerStore.getState().shuffleQueue();
vi.restoreAllMocks();
const s = usePlayerStore.getState();
expect(s.queueItems[0].trackId).toBe(current.id);
expect(s.queueIndex).toBe(0);
// The set of ids is preserved.
expect([...s.queueItems.map(r => r.trackId)].sort()).toEqual(['t-0', 't-1', 't-2', 't-3', 't-4'].sort());
});
});
describe('shuffleUpcomingQueue', () => {
it('is a no-op when fewer than 2 upcoming tracks remain', () => {
const tracks = makeTracks(3, i => ({ id: `t-${i}` }));
seedQueue(tracks, { index: 2, currentTrack: tracks[2] });
const beforeIds = tracks.map(t => t.id);
usePlayerStore.getState().shuffleUpcomingQueue();
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(beforeIds);
});
it('keeps the head + current in place and shuffles only the upcoming tail', () => {
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
vi.spyOn(Math, 'random').mockReturnValue(0);
usePlayerStore.getState().shuffleUpcomingQueue();
vi.restoreAllMocks();
const s = usePlayerStore.getState();
// First two entries unchanged (head + current).
expect(s.queueItems[0].trackId).toBe('t-0');
expect(s.queueItems[1].trackId).toBe('t-1');
// The tail still contains the same ids in some order.
expect([...s.queueItems.slice(2).map(r => r.trackId)].sort()).toEqual(['t-2', 't-3', 't-4'].sort());
});
});
describe('pruneUpcomingToCurrent', () => {
it('drops everything after queueIndex', () => {
const tracks = makeTracks(5);
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
usePlayerStore.getState().pruneUpcomingToCurrent();
const s = usePlayerStore.getState();
expect(s.queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[1].id]);
expect(s.queueIndex).toBe(1);
});
it('clears the queue entirely when there is no current track (orphaned queue → empty)', () => {
seedQueue(makeTracks(3), { index: 0, currentTrack: null });
usePlayerStore.getState().pruneUpcomingToCurrent();
const s = usePlayerStore.getState();
expect(s.queueItems).toEqual([]);
expect(s.queueIndex).toBe(0);
});
it('returns early without clearing when no current track AND queue is already empty', () => {
usePlayerStore.setState({ queueItems: [], queueIndex: 0, currentTrack: null });
usePlayerStore.getState().pruneUpcomingToCurrent();
expect(usePlayerStore.getState().queueItems).toEqual([]);
});
});
describe('setRadioArtistId', () => {
it('accepts an artist id without throwing (module-level state, observable via radio playback)', () => {
// No public getter for radioArtistId — assertion via does-not-throw.
expect(() => usePlayerStore.getState().setRadioArtistId('ar-1')).not.toThrow();
expect(() => usePlayerStore.getState().setRadioArtistId('ar-2')).not.toThrow();
});
});
@@ -0,0 +1,308 @@
/**
* Player store persistence: server play-queue flush (Phase F1 / PR 2c) and
* localStorage partialize windowing (PR #756).
*
* `flushPlayQueuePosition` is the synchronous-from-the-caller's-view path
* that the playback heartbeat / close handler / `pause()` use to push the
* current position to the Subsonic server so cross-device resume works.
*
* `partialize` caps the localStorage queue to a ±250-track window around the
* current index, remapping `queueIndex` into the slice so the persisted
* snapshot stays self-consistent and within the browser storage quota.
*
* Mocks `savePlayQueue` at the module boundary so we can assert the exact
* args passed to the Subsonic API call.
*/
import { savePlayQueue } from '@/lib/api/subsonicPlayQueue';
import { initAudioListeners } from '@/features/playback/store/initAudioListeners';
import { flushPlayQueuePosition } from '@/features/playback/store/queueSync';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Explicit (non-spread) mock map — the `...actual` spread pattern lets the
// real `savePlayQueue` leak through to `playerStore.ts`'s relative import.
// Listing every export the store uses keeps the override stable.
vi.mock('@/lib/api/subsonic', () => ({
pingWithCredentials: vi.fn(async () => ({ ok: true })),
pingWithCredentialsForProfile: vi.fn(async () => ({ ok: true })),
scheduleInstantMixProbeForServer: vi.fn(),
}));
vi.mock('@/lib/api/subsonicPlayQueue', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
}));
vi.mock('@/utils/network/activeServerReachability', () => ({
isActiveServerReachable: () => true,
}));
vi.mock('@/lib/api/subsonicStreamUrl', () => ({
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
}));
vi.mock('@/lib/api/subsonicLibrary', () => ({
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
}));
vi.mock('@/lib/api/subsonicArtists', () => ({
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
}));
vi.mock('@/lib/api/subsonicAlbumInfo', () => ({
getAlbumInfo2: vi.fn(async () => null),
}));
vi.mock('@/lib/api/subsonicScrobble', () => ({
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
}));
vi.mock('@/features/playback/utils/playback/playbackServer', () => ({
getPlaybackServerId: () => 'srv-test',
bindQueueServerForPlayback: vi.fn(),
clearQueueServerForPlayback: vi.fn(),
playbackServerDiffersFromActive: () => false,
filterQueueRefsForPlaybackServer: (refs: { serverId: string; trackId: string }[]) => refs,
playbackProfileIdForTrack: (track: { serverId?: string } | null) => track?.serverId ?? 'srv-test',
playbackCoverArtForId: (id: string, size: number) => ({
src: `https://mock/cover/${id}?size=${size}`,
cacheKey: `mock:cover:${id}:${size}`,
}),
}));
vi.mock('@/lib/api/subsonicStarRating', () => ({
setRating: vi.fn(async () => undefined),
probeEntityRatingSupport: vi.fn(async () => 'track_only'),
}));
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
function stubInvokes(): void {
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_resume', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('audio_set_normalization', () => undefined);
onInvoke('discord_update_presence', () => undefined);
onInvoke('frontend_debug_log', () => undefined);
}
let cleanupListeners: (() => void) | null = null;
beforeEach(() => {
vi.useFakeTimers();
resetPlayerStore();
resetAuthStore();
stubInvokes();
vi.mocked(savePlayQueue).mockClear();
cleanupListeners = initAudioListeners();
});
afterEach(() => {
cleanupListeners?.();
cleanupListeners = null;
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
describe('flushPlayQueuePosition', () => {
it('forwards the queue, current track, and millisecond position to savePlayQueue', async () => {
const [t1, t2, t3] = makeTracks(3);
seedQueue([t1, t2, t3], { index: 1, currentTrack: t2 });
usePlayerStore.setState({ isPlaying: true });
// Drive a live-progress snapshot so flushPlayQueuePosition has a non-zero
// position to flush — readonly snapshot is what the API call samples.
emitTauriEvent('audio:progress', { current_time: 12.345, duration: t2.duration });
// The audio:progress handler itself fires the 15 s heartbeat flush on the
// first event (lastQueueHeartbeatAt starts at 0). Discard that call so the
// assertion below targets only our explicit flushPlayQueuePosition().
vi.mocked(savePlayQueue).mockClear();
await flushPlayQueuePosition();
expect(savePlayQueue).toHaveBeenCalledTimes(1);
expect(savePlayQueue).toHaveBeenCalledWith(
[t1.id, t2.id, t3.id],
t2.id,
12345, // Math.floor(12.345 * 1000)
'srv-test',
);
});
it('caps the song-id list at 1000 entries', async () => {
const tracks = makeTracks(1100);
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
emitTauriEvent('audio:progress', { current_time: 1, duration: tracks[0].duration });
vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit
await flushPlayQueuePosition();
expect(savePlayQueue).toHaveBeenCalledTimes(1);
const idsArg = vi.mocked(savePlayQueue).mock.calls[0]?.[0];
expect(idsArg).toHaveLength(1000);
expect(idsArg?.[999]).toBe(tracks[999].id);
});
it('is a no-op when a radio stream is active', async () => {
const track = makeTrack();
seedQueue([track], { index: 0, currentTrack: track });
usePlayerStore.setState({
currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' },
});
await flushPlayQueuePosition();
expect(savePlayQueue).not.toHaveBeenCalled();
});
it('is a no-op when there is no current track', async () => {
seedQueue(makeTracks(2), { index: 0, currentTrack: null });
await flushPlayQueuePosition();
expect(savePlayQueue).not.toHaveBeenCalled();
});
it('is a no-op when the queue is empty', async () => {
usePlayerStore.setState({
queueItems: [],
queueIndex: 0,
currentTrack: null,
});
await flushPlayQueuePosition();
expect(savePlayQueue).not.toHaveBeenCalled();
});
it('swallows backend errors without propagating to the caller', async () => {
const track = makeTrack();
seedQueue([track], { index: 0, currentTrack: track });
vi.mocked(savePlayQueue).mockRejectedValueOnce(new Error('offline'));
await expect(flushPlayQueuePosition()).resolves.toBeUndefined();
});
it('floors the position to whole milliseconds', async () => {
const track = makeTrack({ duration: 200 });
seedQueue([track], { index: 0, currentTrack: track });
usePlayerStore.setState({ isPlaying: true });
emitTauriEvent('audio:progress', { current_time: 12.9999, duration: 200 });
vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit
await flushPlayQueuePosition();
const posArg = vi.mocked(savePlayQueue).mock.calls[0]?.[2];
expect(posArg).toBe(12999); // Math.floor(12.9999 * 1000)
});
});
// ---------------------------------------------------------------------------
// partialize + merge: thin-state refs-only persistence
// ---------------------------------------------------------------------------
function getPartialize() {
// zustand persist middleware exposes config (incl. partialize) via .persist
type PartializeFn = (state: ReturnType<typeof usePlayerStore.getState>) => Record<string, unknown>;
return (usePlayerStore as unknown as { persist: { getOptions(): { partialize: PartializeFn } } })
.persist.getOptions().partialize;
}
function getMerge() {
type MergeFn = (persisted: unknown, current: ReturnType<typeof usePlayerStore.getState>) => ReturnType<typeof usePlayerStore.getState>;
return (usePlayerStore as unknown as { persist: { getOptions(): { merge: MergeFn } } })
.persist.getOptions().merge;
}
describe('partialize: thin queueItems (refs only)', () => {
it('persists the WHOLE queue as thin refs (no windowed fat `queue`)', () => {
const tracks = makeTracks(600);
tracks[3].radioAdded = true;
tracks[4].autoAdded = true;
seedQueue(tracks, { index: 300, serverId: 's1', currentTrack: tracks[300] });
const partial = getPartialize()(usePlayerStore.getState());
const items = partial.queueItems as {
serverId: string; trackId: string; radioAdded?: boolean; autoAdded?: boolean;
}[];
// No fat `queue` key anymore.
expect(partial.queue).toBeUndefined();
// queueItems carries the WHOLE queue.
expect(items.length).toBe(600);
// queueItemsIndex is the restore-pending sentinel (= the live queueIndex).
expect(partial.queueItemsIndex).toBe(300);
expect(items[0].serverId).toBe('s1');
expect(items[3].radioAdded).toBe(true);
expect(items[4].autoAdded).toBe(true);
expect(items[0].radioAdded).toBeUndefined();
});
it('handles an empty queue without throwing', () => {
usePlayerStore.setState({ queueItems: [], queueIndex: 0 });
const partial = getPartialize()(usePlayerStore.getState());
expect((partial.queueItems as unknown[]).length).toBe(0);
expect(partial.queue).toBeUndefined();
});
});
describe('merge: restores the queue from any old persisted blob', () => {
const current = () => usePlayerStore.getState();
it('prefers an existing queueItems ref list + sets the sentinel', () => {
const merged = getMerge()(
{
queueServerId: 's1',
queueIndex: 2,
queueItems: [
{ serverId: 's1', trackId: 'a' },
{ serverId: 's1', trackId: 'b' },
],
queueItemsIndex: 1,
},
current(),
);
expect(merged.queueItems.map(r => r.trackId)).toEqual(['a', 'b']);
expect(merged.queueItemsIndex).toBe(1);
});
it('rebuilds queueItems from a legacy queueRefs string list', () => {
const merged = getMerge()(
{ queueServerId: 's2', queueRefs: ['x', 'y'], queueRefsIndex: 1 },
current(),
);
expect(merged.queueItems).toEqual([
{ serverId: 's2', trackId: 'x' },
{ serverId: 's2', trackId: 'y' },
]);
expect(merged.queueItemsIndex).toBe(1);
});
it('rebuilds queueItems from an old windowed fat `queue: Track[]` blob and drops the `queue` key', () => {
const blob: Record<string, unknown> = {
queueServerId: 's3',
queueIndex: 1,
queue: [makeTrack({ id: 'q0' }), makeTrack({ id: 'q1', radioAdded: true })],
};
const merged = getMerge()(blob, current());
expect(merged.queueItems).toEqual([
{ serverId: 's3', trackId: 'q0' },
{ serverId: 's3', trackId: 'q1', radioAdded: true },
]);
// The windowed fat-array key is deleted from the persisted blob.
expect('queue' in blob).toBe(false);
// Sentinel falls back to the persisted queueIndex when no explicit index.
expect(merged.queueItemsIndex).toBe(1);
});
it('leaves an empty queue alone (no sentinel) when the blob has nothing to restore', () => {
const merged = getMerge()({ queueServerId: null }, current());
expect(merged.queueItems).toEqual([]);
expect(merged.queueItemsIndex).toBeUndefined();
});
});
@@ -0,0 +1,281 @@
/**
* Playback-action characterization for `playerStore` (Phase F1 / PR 2b).
*
* Covers transport actions — pause / resume / togglePlay / seek / next /
* previous / toggleRepeat — and asserts that a failing Tauri invoke
* produces controlled error state, not partial mutation. Audio event
* handlers live in `playerStore.events.test.ts`.
*
* Heavy module-level mocking: `subsonic.ts` (server APIs) and the music-network
* runtime (scrobble + loved lookups) are mocked to no-ops so navigation-style
* `playTrack` calls (from `next` / `previous`) don't try to hit a real
* server. The store's own action bodies still run for real.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/api/subsonic', async () => {
const actual = await vi.importActual<typeof import('@/lib/api/subsonic')>('@/lib/api/subsonic');
return {
...actual,
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
setRating: vi.fn(async () => undefined),
};
});
vi.mock('@/music-network', () => {
const runtime = {
getEnrichmentPrimaryId: vi.fn(() => null),
dispatchScrobble: vi.fn(async () => undefined),
dispatchNowPlaying: vi.fn(async () => undefined),
isTrackLoved: vi.fn(async () => false),
setTrackLoved: vi.fn(async () => undefined),
syncLovedTracks: vi.fn(async () => ({})),
};
return {
getMusicNetworkRuntime: () => runtime,
getMusicNetworkRuntimeOrNull: () => runtime,
};
});
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
orbitBulkGuard: vi.fn(async () => true),
}));
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
function stubPlaybackInvokes(): void {
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_resume', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('audio_set_normalization', () => undefined);
onInvoke('discord_update_presence', () => undefined);
onInvoke('frontend_debug_log', () => undefined);
}
beforeEach(() => {
// Fake timers across the file so module-scoped locks (`togglePlayLock`,
// `seekDebounce`) don't bleed between tests. afterEach drains pending
// timer callbacks so each next test sees a clean slate.
vi.useFakeTimers();
resetPlayerStore();
resetAuthStore();
stubPlaybackInvokes();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
describe('pause', () => {
it('invokes audio_pause and clears isPlaying', () => {
usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
usePlayerStore.getState().pause();
expect(invokeMock).toHaveBeenCalledWith('audio_pause');
expect(usePlayerStore.getState().isPlaying).toBe(false);
});
it('still clears isPlaying when the engine invoke rejects (controlled error)', () => {
onInvoke('audio_pause', () => { throw new Error('engine gone'); });
usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
usePlayerStore.getState().pause();
// `invoke('audio_pause').catch(...)` is fire-and-forget — state mutation
// happens regardless of whether the engine call succeeds.
expect(usePlayerStore.getState().isPlaying).toBe(false);
});
it('clears any pending scheduled pause / resume timers', () => {
usePlayerStore.setState({
isPlaying: true,
currentTrack: makeTrack(),
scheduledPauseAtMs: Date.now() + 60_000,
scheduledPauseStartMs: Date.now(),
scheduledResumeAtMs: Date.now() + 120_000,
scheduledResumeStartMs: Date.now(),
});
usePlayerStore.getState().pause();
const s = usePlayerStore.getState();
expect(s.scheduledPauseAtMs).toBeNull();
expect(s.scheduledPauseStartMs).toBeNull();
expect(s.scheduledResumeAtMs).toBeNull();
expect(s.scheduledResumeStartMs).toBeNull();
});
});
describe('resume — warm path (engine has the track loaded, just paused)', () => {
it('invokes audio_resume and sets isPlaying', () => {
// Set up a "warm" state: pause was called previously so isAudioPaused=true.
usePlayerStore.setState({ currentTrack: makeTrack(), isPlaying: true });
usePlayerStore.getState().pause();
invokeMock.mockClear();
usePlayerStore.getState().resume();
expect(invokeMock).toHaveBeenCalledWith('audio_resume');
expect(usePlayerStore.getState().isPlaying).toBe(true);
});
it('returns without invoking when there is no current track', () => {
usePlayerStore.setState({ currentTrack: null });
usePlayerStore.getState().resume();
expect(invokeMock).not.toHaveBeenCalledWith('audio_resume');
});
});
describe('togglePlay', () => {
it('calls pause when isPlaying is true', () => {
usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
usePlayerStore.getState().togglePlay();
expect(invokeMock).toHaveBeenCalledWith('audio_pause');
expect(usePlayerStore.getState().isPlaying).toBe(false);
});
it('calls resume (warm path) when isPlaying is false', () => {
// Bring the engine into the "paused-but-loaded" state first.
usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
usePlayerStore.getState().pause();
invokeMock.mockClear();
usePlayerStore.getState().togglePlay();
expect(invokeMock).toHaveBeenCalledWith('audio_resume');
expect(usePlayerStore.getState().isPlaying).toBe(true);
});
});
describe('seek', () => {
it('clamps to duration - 0.25 and updates optimistic progress immediately', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track });
usePlayerStore.getState().seek(1.0); // 100% — should clamp to 99.75
const s = usePlayerStore.getState();
expect(s.currentTime).toBeCloseTo(99.75, 5);
expect(s.progress).toBeCloseTo(99.75 / 100, 5);
});
it('debounces 100 ms before invoking audio_seek', () => {
const track = makeTrack({ duration: 120 });
usePlayerStore.setState({ currentTrack: track });
usePlayerStore.getState().seek(0.5);
expect(invokeMock).not.toHaveBeenCalledWith('audio_seek', expect.anything());
vi.advanceTimersByTime(100);
expect(invokeMock).toHaveBeenCalledWith('audio_seek', expect.objectContaining({ seconds: 60 }));
});
it('coalesces rapid drags into a single backend seek', () => {
const track = makeTrack({ duration: 120 });
usePlayerStore.setState({ currentTrack: track });
const s = usePlayerStore.getState();
s.seek(0.25);
s.seek(0.5);
s.seek(0.75);
vi.advanceTimersByTime(100);
const seekCalls = invokeMock.mock.calls.filter(c => c[0] === 'audio_seek');
expect(seekCalls).toHaveLength(1);
expect(seekCalls[0]?.[1]).toEqual(expect.objectContaining({ seconds: 90 }));
});
it('is a no-op when there is no current track', () => {
usePlayerStore.setState({ currentTrack: null });
usePlayerStore.getState().seek(0.5);
expect(usePlayerStore.getState().currentTime).toBe(0);
});
it('is a no-op when the current track has zero duration', () => {
usePlayerStore.setState({ currentTrack: makeTrack({ duration: 0 }) });
usePlayerStore.getState().seek(0.5);
expect(usePlayerStore.getState().currentTime).toBe(0);
});
});
describe('next', () => {
it('advances to queue[queueIndex + 1] when one is available', () => {
const queue = makeTracks(3);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.getState().next();
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
expect(usePlayerStore.getState().queueIndex).toBe(1);
});
it('wraps to queue[0] when at the end with repeatMode=all', () => {
const queue = makeTracks(3);
seedQueue(queue, { index: 2, currentTrack: queue[2] });
usePlayerStore.setState({ repeatMode: 'all' });
usePlayerStore.getState().next();
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
expect(usePlayerStore.getState().queueIndex).toBe(0);
});
it('stops the engine and clears playback when at the end with repeatMode=off', () => {
// infiniteQueueEnabled and the radio fetch path are both off by default,
// so the no-next branch falls through to `audio_stop`.
const queue = makeTracks(2);
seedQueue(queue, { index: 1, currentTrack: queue[1] });
usePlayerStore.setState({ repeatMode: 'off', isPlaying: true });
usePlayerStore.getState().next();
expect(invokeMock).toHaveBeenCalledWith('audio_stop');
const s = usePlayerStore.getState();
expect(s.isPlaying).toBe(false);
expect(s.currentTime).toBe(0);
expect(s.progress).toBe(0);
});
});
describe('previous', () => {
it('restarts the current track when currentTime > 3 s', () => {
const queue = makeTracks(3);
seedQueue(queue, { index: 1, currentTrack: queue[1] });
// The store's `currentTime` is the source for the "restart vs jump back"
// branch. `getPlaybackProgressSnapshot` reads from the same field.
usePlayerStore.setState({ currentTime: 10, progress: 10 / queue[1].duration });
usePlayerStore.getState().previous();
expect(invokeMock).toHaveBeenCalledWith('audio_seek', expect.objectContaining({ seconds: 0 }));
expect(usePlayerStore.getState().queueIndex).toBe(1); // stayed on the same track
});
it('jumps to the previous track when currentTime ≤ 3 s and queueIndex > 0', () => {
const queue = makeTracks(3);
seedQueue(queue, { index: 2, currentTrack: queue[2] });
usePlayerStore.setState({ currentTime: 1.0 });
usePlayerStore.getState().previous();
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
expect(usePlayerStore.getState().queueIndex).toBe(1);
});
it('is a no-op when queueIndex is 0 and currentTime ≤ 3 s', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.setState({ currentTime: 0.5 });
usePlayerStore.getState().previous();
expect(usePlayerStore.getState().queueIndex).toBe(0);
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
});
});
describe('toggleRepeat', () => {
it('cycles off → all → one → off', () => {
expect(usePlayerStore.getState().repeatMode).toBe('off');
usePlayerStore.getState().toggleRepeat();
expect(usePlayerStore.getState().repeatMode).toBe('all');
usePlayerStore.getState().toggleRepeat();
expect(usePlayerStore.getState().repeatMode).toBe('one');
usePlayerStore.getState().toggleRepeat();
expect(usePlayerStore.getState().repeatMode).toBe('off');
});
});
@@ -0,0 +1,284 @@
/**
* Playback-progress snapshot + subscriber characterization (Phase F1 / PR 2c).
*
* `getPlaybackProgressSnapshot` + `subscribePlaybackProgress` are the
* low-overhead channel UI components (waveform, seekbar) read instead of
* subscribing to the full Zustand store. Heavy state writes still go
* through Zustand at the coarser store-commit interval — see
* `playerStore.events.test.ts` for that side.
*
* Drive emits via the `audio:progress` Tauri event (the only public path
* to `emitPlaybackProgress`).
*/
import { initAudioListeners } from '@/features/playback/store/initAudioListeners';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress, type PlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/api/subsonic', async () => {
const actual = await vi.importActual<typeof import('@/lib/api/subsonic')>('@/lib/api/subsonic');
return {
...actual,
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
getSong: vi.fn(async () => null),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
};
});
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack } from '@/test/helpers/factories';
function stubInvokes(): void {
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_resume', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('audio_set_normalization', () => undefined);
onInvoke('discord_update_presence', () => undefined);
onInvoke('frontend_debug_log', () => undefined);
}
let cleanupListeners: (() => void) | null = null;
beforeEach(() => {
vi.useFakeTimers();
resetPlayerStore();
resetAuthStore();
stubInvokes();
cleanupListeners = initAudioListeners();
});
afterEach(() => {
cleanupListeners?.();
cleanupListeners = null;
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
describe('getPlaybackProgressSnapshot', () => {
it('returns the same shape (currentTime / progress / buffered)', () => {
const snap = getPlaybackProgressSnapshot();
expect(snap).toHaveProperty('currentTime');
expect(snap).toHaveProperty('progress');
expect(snap).toHaveProperty('buffered');
expect(typeof snap.currentTime).toBe('number');
expect(typeof snap.progress).toBe('number');
expect(typeof snap.buffered).toBe('number');
});
it('reflects a fresh audio:progress event when transport is active', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
emitTauriEvent('audio:progress', { current_time: 42, duration: 100 });
const snap = getPlaybackProgressSnapshot();
expect(snap.currentTime).toBeCloseTo(42, 3);
expect(snap.progress).toBeCloseTo(0.42, 3);
});
});
describe('subscribePlaybackProgress', () => {
it('notifies the subscriber on each observable update', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
const calls: PlaybackProgressSnapshot[] = [];
const unsub = subscribePlaybackProgress(next => calls.push(next));
emitTauriEvent('audio:progress', { current_time: 10, duration: 100 });
vi.advanceTimersByTime(2000); // beyond LIVE_PROGRESS_EMIT_MIN_MS = 1500
emitTauriEvent('audio:progress', { current_time: 20, duration: 100 });
expect(calls).toHaveLength(2);
expect(calls[0]?.currentTime).toBeCloseTo(10, 3);
expect(calls[1]?.currentTime).toBeCloseTo(20, 3);
unsub();
});
it('passes (next, prev) snapshots to the subscriber', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
const pairs: Array<[PlaybackProgressSnapshot, PlaybackProgressSnapshot]> = [];
const unsub = subscribePlaybackProgress((next, prev) => pairs.push([next, prev]));
emitTauriEvent('audio:progress', { current_time: 5, duration: 100 });
vi.advanceTimersByTime(2000);
emitTauriEvent('audio:progress', { current_time: 15, duration: 100 });
expect(pairs).toHaveLength(2);
const [[next2, prev2]] = pairs.slice(-1);
expect(next2.currentTime).toBeCloseTo(15, 3);
expect(prev2.currentTime).toBeCloseTo(5, 3);
unsub();
});
it('coalesces near-duplicate snapshots (epsilon guard inside emitPlaybackProgress)', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
const calls: PlaybackProgressSnapshot[] = [];
const unsub = subscribePlaybackProgress(next => calls.push(next));
emitTauriEvent('audio:progress', { current_time: 10, duration: 100 });
vi.advanceTimersByTime(2000);
// Re-emit with a delta smaller than the snapshot's 0.005 s epsilon.
emitTauriEvent('audio:progress', { current_time: 10.001, duration: 100 });
expect(calls).toHaveLength(1);
unsub();
});
it('stops notifying after the returned unsubscribe runs', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
const calls: PlaybackProgressSnapshot[] = [];
const unsub = subscribePlaybackProgress(next => calls.push(next));
emitTauriEvent('audio:progress', { current_time: 7, duration: 100 });
expect(calls).toHaveLength(1);
unsub();
vi.advanceTimersByTime(2000);
emitTauriEvent('audio:progress', { current_time: 50, duration: 100 });
expect(calls).toHaveLength(1);
});
it('supports multiple subscribers independently', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
const a: number[] = [];
const b: number[] = [];
const unsubA = subscribePlaybackProgress(n => a.push(n.currentTime));
const unsubB = subscribePlaybackProgress(n => b.push(n.currentTime));
emitTauriEvent('audio:progress', { current_time: 11, duration: 100 });
expect(a).toHaveLength(1);
expect(b).toHaveLength(1);
unsubA();
vi.advanceTimersByTime(2000);
emitTauriEvent('audio:progress', { current_time: 21, duration: 100 });
expect(a).toHaveLength(1);
expect(b).toHaveLength(2);
unsubB();
});
});
describe('audio:progress throttling (live-emit guard)', () => {
it('drops a second event that fires within the time threshold and below the delta threshold', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
const calls: PlaybackProgressSnapshot[] = [];
const unsub = subscribePlaybackProgress(next => calls.push(next));
emitTauriEvent('audio:progress', { current_time: 5, duration: 100 });
// Δt only 50 ms (< 1500 ms), Δs only 0.5 (< 0.9 s) — second event dropped.
vi.advanceTimersByTime(50);
emitTauriEvent('audio:progress', { current_time: 5.5, duration: 100 });
expect(calls).toHaveLength(1);
expect(calls[0]?.currentTime).toBeCloseTo(5, 3);
unsub();
});
it('lets through a second event when the position delta is large enough (≥ 0.9 s)', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
const calls: PlaybackProgressSnapshot[] = [];
const unsub = subscribePlaybackProgress(next => calls.push(next));
emitTauriEvent('audio:progress', { current_time: 5, duration: 100 });
vi.advanceTimersByTime(50);
// Δs = 1.0 ≥ 0.9 → emit passes the live-emit gate even though Δt is small.
emitTauriEvent('audio:progress', { current_time: 6.0, duration: 100 });
expect(calls).toHaveLength(2);
unsub();
});
it('lets through a second event when enough time has passed (≥ 1500 ms)', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
const calls: PlaybackProgressSnapshot[] = [];
const unsub = subscribePlaybackProgress(next => calls.push(next));
emitTauriEvent('audio:progress', { current_time: 5, duration: 100 });
vi.advanceTimersByTime(1600); // ≥ LIVE_PROGRESS_EMIT_MIN_MS
emitTauriEvent('audio:progress', { current_time: 5.05, duration: 100 });
expect(calls).toHaveLength(2);
unsub();
});
});
describe('audio:progress buffering flag', () => {
it('sets isPlaybackBuffering from the optional buffering field', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({
currentTrack: track,
isPlaying: true,
isPlaybackBuffering: false,
});
emitTauriEvent('audio:progress', {
current_time: 0,
duration: 100,
buffering: true,
});
expect(usePlayerStore.getState().isPlaybackBuffering).toBe(true);
emitTauriEvent('audio:playing', 100);
expect(usePlayerStore.getState().isPlaybackBuffering).toBe(false);
});
it('does not rewrite isPlaybackBuffering when the flag is unchanged', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({
currentTrack: track,
isPlaying: true,
isPlaybackBuffering: true,
});
const setStateSpy = vi.spyOn(usePlayerStore, 'setState');
emitTauriEvent('audio:progress', {
current_time: 0,
duration: 100,
buffering: true,
});
vi.advanceTimersByTime(2000);
emitTauriEvent('audio:progress', {
current_time: 0.9,
duration: 100,
buffering: true,
});
const bufferingWrites = setStateSpy.mock.calls.filter(
call => typeof call[0] === 'object' && call[0] !== null && 'isPlaybackBuffering' in call[0],
);
expect(bufferingWrites).toHaveLength(0);
setStateSpy.mockRestore();
});
});
@@ -0,0 +1,292 @@
/**
* Queue-mutation characterization for `playerStore` (Phase F1 / PR 2a).
*
* Covers public queue actions — `enqueue`, `enqueueAt`, `playNext`,
* `clearQueue`, `reorderQueue`, `removeTrack`, plus the undo/redo flow.
* The queue-undo stack lives at module scope (outside the Zustand state),
* so each test drains it before exercising — see `resetForQueueTest`.
*
* Playback actions (`play`, `pause`, `seek`, `next`, `previous`, repeat /
* shuffle modes) live in PR 2b.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// `playerStore` pulls `savePlayQueue` from `@/lib/api/subsonic`, which talks to a
// real server. Override only what the queue path touches; everything else
// stays as the actual module so unrelated imports don't break.
vi.mock('@/lib/api/subsonic', async () => {
const actual = await vi.importActual<typeof import('@/lib/api/subsonic')>('@/lib/api/subsonic');
return {
...actual,
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
};
});
// `enqueue` / `enqueueAt` call `orbitBulkGuard` for multi-track inserts when
// the caller hasn't pre-confirmed. Force the guard to short-circuit through.
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
orbitBulkGuard: vi.fn(async () => true),
}));
import { usePlayerStore } from '@/features/playback/store/playerStore';
import {
appendTimelineSessionPlay,
getTimelineSessionHistorySnapshot,
} from '@/features/playback/store/timelineSessionHistory';
import { onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
beforeEach(() => {
resetPlayerStore();
// `clearQueue` fires `invoke('audio_stop')`; every queue mutation triggers a
// debounced `syncQueueToServer` we don't need to advance.
onInvoke('audio_stop', () => undefined);
});
afterEach(() => {
vi.useRealTimers();
});
describe('enqueue', () => {
it('appends a single track to an empty queue', () => {
const t1 = makeTrack({ id: 't1' });
usePlayerStore.getState().enqueue([t1], true);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['t1']);
});
it('appends multiple tracks in order', () => {
const tracks = makeTracks(3);
usePlayerStore.getState().enqueue(tracks, true);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(tracks.map(t => t.id));
});
it('inserts before the first upcoming auto-added separator', () => {
const head = makeTrack({ id: 'head' });
const auto = makeTrack({ id: 'auto', autoAdded: true });
const tail = makeTrack({ id: 'tail', autoAdded: true });
seedQueue([head, auto, tail], { index: 0 });
const incoming = makeTrack({ id: 'new' });
usePlayerStore.getState().enqueue([incoming], true);
// Insert lands between `head` (the currently-playing one) and the first
// auto-added track, so the auto-added group stays at the tail.
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['head', 'new', 'auto', 'tail']);
});
it('appends at the end when there are no auto-added tracks after the cursor', () => {
const head = makeTrack({ id: 'head' });
const mid = makeTrack({ id: 'mid' });
seedQueue([head, mid], { index: 0 });
usePlayerStore.getState().enqueue([makeTrack({ id: 'tail' })], true);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['head', 'mid', 'tail']);
});
it('ignores auto-added separators that already passed (behind the cursor)', () => {
const past = makeTrack({ id: 'past', autoAdded: true });
const current = makeTrack({ id: 'current' });
seedQueue([past, current], { index: 1 });
usePlayerStore.getState().enqueue([makeTrack({ id: 'new' })], true);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['past', 'current', 'new']);
});
});
describe('enqueueAt', () => {
it('inserts at the given index', () => {
const queue = makeTracks(3);
seedQueue(queue, { index: 0 });
const ins = makeTrack({ id: 'ins' });
usePlayerStore.getState().enqueueAt([ins], 2, true);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([queue[0].id, queue[1].id, 'ins', queue[2].id]);
});
it('shifts queueIndex forward when inserting at or before the cursor', () => {
const queue = makeTracks(3);
seedQueue(queue, { index: 2 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], 1, true);
// Two tracks inserted at idx 1 → cursor (was 2) moves to 4.
expect(usePlayerStore.getState().queueIndex).toBe(4);
});
it('keeps queueIndex when inserting after the cursor', () => {
const queue = makeTracks(3);
seedQueue(queue, { index: 1 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' })], 3, true);
expect(usePlayerStore.getState().queueIndex).toBe(1);
});
it('clamps a negative insertIndex to 0', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'front' })], -5, true);
expect(usePlayerStore.getState().queueItems[0].trackId).toBe('front');
});
it('clamps an over-large insertIndex to the queue length', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0 });
usePlayerStore.getState().enqueueAt([makeTrack({ id: 'back' })], 99, true);
const q = usePlayerStore.getState().queueItems;
expect(q[q.length - 1].trackId).toBe('back');
});
});
describe('playNext', () => {
it('tags inserted tracks with playNextAdded', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]);
const inserted = usePlayerStore.getState().queueItems.find(r => r.trackId === 'pn');
expect(inserted?.playNextAdded).toBe(true);
});
it('inserts immediately after the current track', () => {
const a = makeTrack({ id: 'a' });
const b = makeTrack({ id: 'b' });
seedQueue([a, b], { index: 0, currentTrack: a });
usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['a', 'pn', 'b']);
});
it('returns early on an empty input list', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
usePlayerStore.getState().playNext([]);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(queue.map(t => t.id));
});
});
describe('clearQueue', () => {
it('empties the queue and resets playback bookkeeping', () => {
const tracks = makeTracks(3);
seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
usePlayerStore.setState({
isPlaying: true,
progress: 0.5,
currentTime: 42,
buffered: 0.8,
});
usePlayerStore.getState().clearQueue();
const s = usePlayerStore.getState();
expect(s.queueItems).toEqual([]);
expect(s.queueIndex).toBe(0);
expect(s.currentTrack).toBeNull();
expect(s.isPlaying).toBe(false);
expect(s.progress).toBe(0);
expect(s.currentTime).toBe(0);
expect(s.buffered).toBe(0);
});
it('calls audio_stop on the engine', () => {
const stop = vi.fn(() => undefined);
onInvoke('audio_stop', stop);
seedQueue(makeTracks(2), { index: 0 });
usePlayerStore.getState().clearQueue();
expect(stop).toHaveBeenCalled();
});
it('clears timeline session history', () => {
appendTimelineSessionPlay({ serverId: 's1', trackId: 'a', playedAtMs: 1 });
seedQueue(makeTracks(2), { index: 0 });
usePlayerStore.getState().clearQueue();
expect(getTimelineSessionHistorySnapshot()).toEqual([]);
});
});
describe('reorderQueue', () => {
it('moves a track from startIndex to endIndex', () => {
const [a, b, c, d] = makeTracks(4);
seedQueue([a, b, c, d], { index: 0 });
usePlayerStore.getState().reorderQueue(1, 3); // b → after d
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([a.id, c.id, d.id, b.id]);
});
it('preserves queueIndex by following the current track id, not the slot', () => {
const [a, b, c] = makeTracks(3);
seedQueue([a, b, c], { index: 1, currentTrack: b });
usePlayerStore.getState().reorderQueue(1, 2); // b moves to the end
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([a.id, c.id, b.id]);
expect(usePlayerStore.getState().queueIndex).toBe(2); // followed `b`
});
it('keeps queueIndex when the current track is unaffected by the move', () => {
const [a, b, c] = makeTracks(3);
seedQueue([a, b, c], { index: 1, currentTrack: b });
usePlayerStore.getState().reorderQueue(0, 2); // a moves to the end
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([b.id, c.id, a.id]);
expect(usePlayerStore.getState().queueIndex).toBe(0); // followed `b`
});
});
describe('removeTrack', () => {
it('removes the track at the given index', () => {
const tracks = makeTracks(3);
seedQueue(tracks, { index: 0 });
usePlayerStore.getState().removeTrack(1);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[2].id]);
});
it('clamps queueIndex when the removal makes the queue shorter than the cursor', () => {
const tracks = makeTracks(3);
seedQueue(tracks, { index: 2 });
usePlayerStore.getState().removeTrack(2);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[1].id]);
expect(usePlayerStore.getState().queueIndex).toBe(1);
});
it('keeps queueIndex when removing a track after the cursor', () => {
const tracks = makeTracks(4);
seedQueue(tracks, { index: 1 });
usePlayerStore.getState().removeTrack(3);
expect(usePlayerStore.getState().queueIndex).toBe(1);
});
});
describe('undo / redo', () => {
it('returns false on undo when the history is empty', () => {
expect(usePlayerStore.getState().undoLastQueueEdit()).toBe(false);
});
it('returns false on redo when the redo stack is empty', () => {
expect(usePlayerStore.getState().redoLastQueueEdit()).toBe(false);
});
it('rolls back the most recent destructive edit', () => {
const seed = makeTracks(2);
seedQueue(seed, { index: 0, currentTrack: seed[0] });
usePlayerStore.getState().enqueue([makeTrack({ id: 'add' })], true);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id, 'add']);
const undone = usePlayerStore.getState().undoLastQueueEdit();
expect(undone).toBe(true);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id]);
});
it('replays the undone edit via redo', () => {
const seed = makeTracks(2);
seedQueue(seed, { index: 0, currentTrack: seed[0] });
usePlayerStore.getState().removeTrack(1);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id]);
usePlayerStore.getState().undoLastQueueEdit();
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id, seed[1].id]);
usePlayerStore.getState().redoLastQueueEdit();
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([seed[0].id]);
});
it('a new edit drops any pending redo (Word-style history)', () => {
const seed = makeTracks(3);
seedQueue(seed, { index: 0, currentTrack: seed[0] });
usePlayerStore.getState().removeTrack(2); // edit A: [s0, s1]
usePlayerStore.getState().undoLastQueueEdit(); // [s0, s1, s2]
expect(usePlayerStore.getState().queueItems).toHaveLength(3);
usePlayerStore.getState().removeTrack(1); // edit B drops the pending redo
expect(usePlayerStore.getState().redoLastQueueEdit()).toBe(false);
});
});
+207
View File
@@ -0,0 +1,207 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { readInitialNetworkLovedCache, persistNetworkLovedCache } from '@/features/playback/store/networkLovedCacheStorage';
import { readInitialPlayerPrefs, persistPlayerPrefs } from '@/features/playback/store/playerPrefsStorage';
import { createHydrationGatedStorage, createSafeJSONStorage } from '@/lib/util/safeStorage';
import { emitPlaybackProgress } from '@/features/playback/store/playbackProgress';
import type { PlayerState, QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { canonicalQueueServerKey } from '@/utils/server/serverIndexKey';
import { readInitialQueueVisibility } from '@/features/playback/store/queueVisibilityStorage';
import { createNetworkLoveActions } from '@/features/playback/store/networkLoveActions';
import { createMiscActions } from '@/features/playback/store/miscActions';
import { runNext } from '@/features/playback/store/nextAction';
import { runPlayTrack } from '@/features/playback/store/playTrackAction';
import { runResume } from '@/features/playback/store/resumeAction';
import { runSeek } from '@/features/playback/store/seekAction';
import { runUpdateReplayGainForCurrentTrack } from '@/features/playback/store/updateReplayGainAction';
import { createQueueMutationActions } from '@/features/playback/store/queueMutationActions';
import { createScheduleActions } from '@/features/playback/store/scheduleActions';
import { createTransportLightActions } from '@/features/playback/store/transportLightActions';
import { createUiStateActions } from '@/features/playback/store/uiStateActions';
import { createUndoRedoActions } from '@/features/playback/store/undoRedoActions';
const initialPlayerPrefs = readInitialPlayerPrefs();
const initialNetworkLovedCache = readInitialNetworkLovedCache();
let playerPersistWritesEnabled = false;
export const usePlayerStore = create<PlayerState>()(
persist(
(set, get) => {
return {
currentTrack: null,
waveformBins: null,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
normalizationDbgSource: null,
normalizationDbgTrackId: null,
normalizationDbgCacheGainDb: null,
normalizationDbgCacheTargetLufs: null,
normalizationDbgCacheUpdatedAt: null,
normalizationDbgLastEventAt: null,
currentRadio: null,
currentPlaybackSource: null,
enginePreloadedTrackId: null,
// Thin-state: the queue is a list of refs; full Tracks resolve on demand
// through the resolver. `currentTrack` stays a full resolved singleton.
queueItems: [],
queueServerId: null,
queueIndex: 0,
isPlaying: false,
isPlaybackBuffering: false,
progress: 0,
buffered: 0,
currentTime: 0,
volume: initialPlayerPrefs.volume,
scrobbled: false,
networkLoved: false,
networkLovedCache: initialNetworkLovedCache,
starredOverrides: {},
userRatingOverrides: {},
isQueueVisible: readInitialQueueVisibility(),
isFullscreenOpen: false,
scheduledPauseAtMs: null,
scheduledPauseStartMs: null,
scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
repeatMode: initialPlayerPrefs.repeatMode,
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
songInfoModal: { isOpen: false, songId: null },
...createUiStateActions(set),
...createNetworkLoveActions(set, get),
...createQueueMutationActions(set, get),
...createTransportLightActions(set, get),
...createUndoRedoActions(set, get),
...createMiscActions(set, get),
...createScheduleActions(set, get),
playTrack: (track, queue, manual = true, _orbitConfirmed = false, targetQueueIndex) =>
runPlayTrack(set, get, track, queue, manual, _orbitConfirmed, targetQueueIndex),
resume: () => runResume(set, get),
next: (manual = true) => runNext(set, get, manual),
seek: (progress) => runSeek(set, get, progress),
updateReplayGainForCurrentTrack: () => runUpdateReplayGainForCurrentTrack(set, get),
};
},
{
name: 'psysonic-player',
// Quota-safe: a failed persist write (huge queue > localStorage quota)
// must never throw, or it aborts the `set()` it fires from — that is what
// killed `playTrack` before `audio_play`. See safeStorage.ts.
storage: createHydrationGatedStorage(
createSafeJSONStorage(),
() => playerPersistWritesEnabled,
),
partialize: (state) => ({
// volume/repeatMode → psysonic_player_prefs; isQueueVisible →
// psysonic_queue_visible; networkLovedCache → psysonic_network_loved_cache.
// Kept out of this blob so a huge queue cannot block their writes.
currentTrack: state.currentTrack,
queueServerId: state.queueServerId,
// Thin-state: persist the whole ordered ref list (tiny) — no windowed
// fat `queue: Track[]` anymore. `queueItemsIndex` doubles as the
// restore-pending sentinel a fresh rehydrate carries back, telling
// `hydrateQueueFromIndex` the refs still need a full resolve.
queueItems: state.queueItems,
queueItemsIndex: state.queueIndex,
// currentTime is intentionally NOT persisted here.
// handleAudioProgress fires every 100ms and each setState with a
// persisted field triggers a full JSON serialisation to localStorage.
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
}),
// Rebuild `queueItems` from ANY older persisted blob shape so an upgrade
// restores the queue. Order of preference: an existing `queueItems` ref
// list → the legacy `queueRefs` string list → a windowed `queue: Track[]`
// (the pre-thin-state shape). Sets the restore-pending sentinel and drops
// the obsolete fat `queue` key from the persisted object.
merge: (persisted, current) => {
const blob = (persisted ?? {}) as Record<string, unknown>;
const rawServerId = (blob.queueServerId as string | null | undefined) ?? null;
// B1: queue server identity is canonical (index key) on every write path.
// Migrate persisted blobs forward here once on rehydrate so the live
// store never has to handle a mix of UUID and index-key refs.
const canonicalSid = rawServerId ? canonicalQueueServerKey(rawServerId) : null;
let queueItems: QueueItemRef[] | undefined;
if (Array.isArray(blob.queueItems) && blob.queueItems.length > 0) {
queueItems = (blob.queueItems as QueueItemRef[]).map(ref => ({
...ref,
serverId: canonicalQueueServerKey(ref.serverId),
}));
} else if (Array.isArray(blob.queueRefs) && blob.queueRefs.length > 0) {
queueItems = (blob.queueRefs as string[]).map(trackId => ({
serverId: canonicalSid ?? '',
trackId,
}));
} else if (Array.isArray(blob.queue) && blob.queue.length > 0) {
queueItems = toQueueItemRefs(canonicalSid ?? '', blob.queue as Track[]);
}
// Restore-pending sentinel: prefer the persisted one; else the legacy
// index; else 0 when we recovered a non-empty queue from an old blob.
let queueItemsIndex: number | undefined;
if (typeof blob.queueItemsIndex === 'number') {
queueItemsIndex = blob.queueItemsIndex;
} else if (typeof blob.queueRefsIndex === 'number') {
queueItemsIndex = blob.queueRefsIndex;
} else if (queueItems && queueItems.length > 0) {
queueItemsIndex = typeof blob.queueIndex === 'number' ? blob.queueIndex : 0;
}
// Drop the obsolete windowed fat-array key — `queueItems` is canonical.
delete blob.queue;
// volume/repeatMode are owned by `psysonic_player_prefs`; strip any legacy
// fields so an old blob cannot clobber the dedicated prefs on rehydrate.
delete blob.volume;
delete blob.repeatMode;
delete blob.isQueueVisible;
delete blob.lastfmLovedCache;
delete blob.networkLovedCache;
// Persist the canonical form back onto the merged blob so subsequent
// reads of state.queueServerId always see the index key.
if (canonicalSid !== null) {
blob.queueServerId = canonicalSid;
}
return {
...current,
...blob,
queueItems: queueItems ?? current.queueItems,
...(queueItemsIndex !== undefined ? { queueItemsIndex } : {}),
} as PlayerState;
},
}
)
);
usePlayerStore.persist.onHydrate(() => {
playerPersistWritesEnabled = false;
});
usePlayerStore.persist.onFinishHydration(() => {
playerPersistWritesEnabled = true;
});
usePlayerStore.subscribe((state, prev) => {
if (state.volume !== prev.volume || state.repeatMode !== prev.repeatMode) {
persistPlayerPrefs({ volume: state.volume, repeatMode: state.repeatMode });
}
if (state.networkLovedCache !== prev.networkLovedCache) {
persistNetworkLovedCache(state.networkLovedCache);
}
});
usePlayerStore.subscribe((state, prev) => {
if (
state.currentTime === prev.currentTime &&
state.progress === prev.progress &&
state.buffered === prev.buffered
) return;
emitPlaybackProgress({
currentTime: state.currentTime,
progress: state.progress,
buffered: state.buffered,
});
});
@@ -0,0 +1,233 @@
import type { InternetRadioStation, SubsonicOpenArtistRef } from '@/lib/api/subsonicTypes';
import type { PlaybackSourceKind } from '@/features/playback/utils/playback/resolvePlaybackUrl';
export interface Track {
id: string;
title: string;
artist: string;
album: string;
albumId: string;
artistId?: string;
/** OpenSubsonic `artists` on the child song — multiple performers with ids. */
artists?: SubsonicOpenArtistRef[];
duration: number;
coverArt?: string;
discNumber?: number;
track?: number;
year?: number;
bitRate?: number;
suffix?: string;
userRating?: number;
replayGainTrackDb?: number;
replayGainAlbumDb?: number;
replayGainPeak?: number;
starred?: string;
genre?: string;
samplingRate?: number;
bitDepth?: number;
/** Subsonic `size` in bytes when provided by the server (helps hot-cache budgeting). */
size?: number;
/** Owning server profile id when the queue spans multiple servers (e.g. offline favorites). */
serverId?: string;
autoAdded?: boolean;
radioAdded?: boolean;
/** Inserted via "Play Next". Used by the preserve-order toggle to find the
* end of the current Play-Next streak. Stale flags behind queueIndex are
* harmless — the streak scan only looks forward from queueIndex+1. */
playNextAdded?: boolean;
}
/**
* Thin canonical queue item (queue thin-state plan, §5.10). Identity plus the
* queue-only flags; library metadata (title/artist/cover/…) is resolved from
* the local index or network on demand from Phase 2 on. `serverId` is per-item
* (day-1 schema for mixed-server queues); v1 fills it with the single playback
* server.
*/
export interface QueueItemRef {
serverId: string;
trackId: string;
autoAdded?: boolean;
radioAdded?: boolean;
playNextAdded?: boolean;
}
export interface PlayerState {
currentTrack: Track | null;
waveformBins: number[] | null;
normalizationNowDb: number | null;
normalizationTargetLufs: number | null;
normalizationEngineLive: 'off' | 'replaygain' | 'loudness';
normalizationDbgSource: string | null;
normalizationDbgTrackId: string | null;
normalizationDbgCacheGainDb: number | null;
normalizationDbgCacheTargetLufs: number | null;
normalizationDbgCacheUpdatedAt: number | null;
normalizationDbgLastEventAt: number | null;
currentRadio: InternetRadioStation | null;
/** Latches the source used to start the currently playing track. */
currentPlaybackSource: PlaybackSourceKind | null;
/**
* Subsonic track id for which `audio_preload` finished into the engine RAM slot (see `audio:preload-ready`).
* Cleared after a successful `audio_play` consumed that preload, or when starting another track.
*/
enginePreloadedTrackId: string | null;
/** Saved server for stream/hot-cache/offline resolution while this queue plays. */
queueServerId: string | null;
queueIndex: number;
/** F5 (transient): full ordered track-id list + index persisted alongside the
* windowed `queue`. On startup, when the library index is ready, the whole
* queue is rehydrated from these refs (`library_get_tracks_batch`) and they
* are then cleared. Absent / index-off → the windowed `queue` is used as-is. */
queueRefs?: string[];
queueRefsIndex?: number;
/** Canonical thin queue list (thin-state). Single playback server per item in
* v1; carries the queue-only flags. Persisted by `partialize`; the source the
* resolver/consumers read from — full `Track`s resolve on demand. */
queueItems: QueueItemRef[];
/** Restore-pending sentinel (transient). `partialize` writes it alongside the
* full `queueItems` on every persist; a fresh rehydrate brings it back, which
* is what tells `hydrateQueueFromIndex` the windowed `queue` still needs a
* full hydrate. Normal mutations keep `queueItems` canonical but never set
* this, so its presence — not `queueItems` — gates the restore. Cleared once
* a full hydrate succeeds. */
queueItemsIndex?: number;
isPlaying: boolean;
/** HTTP stream still buffering (network / demux probe) — show loading on cover art. */
isPlaybackBuffering: boolean;
progress: number; // 01
buffered: number; // 01 (unused in Rust backend, kept for UI compat)
currentTime: number;
volume: number;
scrobbled: boolean;
networkLoved: boolean;
networkLovedCache: Record<string, boolean>;
starredOverrides: Record<string, boolean>;
setStarredOverride: (id: string, starred: boolean) => void;
/** Optimistic track ratings (e.g. skip→1★ while UI lists still have stale `song.userRating`). */
userRatingOverrides: Record<string, number>;
setUserRatingOverride: (id: string, rating: number) => void;
playRadio: (station: InternetRadioStation) => void;
/** `_orbitConfirmed` is an internal bypass flag — callers outside the
* orbit bulk-gate should leave it `undefined`.
* `targetQueueIndex` lets callers that already know the exact target
* position (next()/previous()/queue-row click) bypass the `findIndex`
* by-id fallback, which otherwise resolves to the *first* occurrence
* and breaks navigation when the same track appears multiple times in
* the queue (issue #500). Ignored if out of range or if the track id
* at that position doesn't match. */
playTrack: (track: Track, queue?: Track[], manual?: boolean, _orbitConfirmed?: boolean, targetQueueIndex?: number) => void;
/** Queue becomes `[track]` only; if already on this track, does not restart `audio_play`. */
reseedQueueForInstantMix: (track: Track) => void;
pause: () => void;
resume: () => void;
stop: () => void;
togglePlay: () => void;
/** Wall-clock ms when auto-pause fires, or null. */
scheduledPauseAtMs: number | null;
/** Wall-clock ms when the current auto-pause timer was armed (for progress-ring totals). */
scheduledPauseStartMs: number | null;
/** Wall-clock ms when auto-resume fires, or null. */
scheduledResumeAtMs: number | null;
/** Wall-clock ms when the current auto-resume timer was armed (for progress-ring totals). */
scheduledResumeStartMs: number | null;
schedulePauseIn: (seconds: number) => void;
scheduleResumeIn: (seconds: number) => void;
clearScheduledPause: () => void;
clearScheduledResume: () => void;
next: (manual?: boolean) => void;
previous: () => void;
seek: (progress: number) => void;
setVolume: (v: number) => void;
updateReplayGainForCurrentTrack: () => void;
reanalyzeLoudnessForTrack: (trackId: string) => Promise<void>;
setProgress: (t: number, duration: number) => void;
/** `_orbitConfirmed` bypasses the bulk-append gate. `skipQueueUndo` skips the undo snapshot (macro builders such as Lucky Mix push once up-front). */
enqueue: (tracks: Track[], _orbitConfirmed?: boolean, skipQueueUndo?: boolean) => void;
enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void;
/** "Play Next" — inserts after the current track. When
* `preservePlayNextOrder` is on, appends to the existing Play-Next streak
* (Spotify-style); otherwise inserts directly after the current track and
* pushes any earlier Play-Next items down (default). Falls back to
* `playTrack` when nothing is currently playing. */
playNext: (tracks: Track[]) => void;
enqueueRadio: (tracks: Track[], artistId?: string) => void;
setRadioArtistId: (artistId: string) => void;
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only.
* When `skipQueueUndo` is true, callers must push undo separately (macro rebuild). */
pruneUpcomingToCurrent: (skipQueueUndo?: boolean) => void;
clearQueue: () => void;
isQueueVisible: boolean;
toggleQueue: () => void;
setQueueVisible: (v: boolean) => void;
isFullscreenOpen: boolean;
toggleFullscreen: () => void;
repeatMode: 'off' | 'all' | 'one';
toggleRepeat: () => void;
reorderQueue: (startIndex: number, endIndex: number) => void;
removeTrack: (index: number) => void;
shuffleQueue: () => void;
/** Shuffle only the tracks after the current one — leaves played history intact. */
shuffleUpcomingQueue: () => void;
/**
* Revert the last explicit queue edit (enqueue, reorder, remove, shuffle, manual
* `playTrack`, …). Returns true if a snapshot was applied. Snapshots include queue,
* current track, playback time, progress, and pause state. If the undone edit did
* not change which song is current (reorder, enqueue, remove another row, …), only
* the queue is restored and playback continues; otherwise the Rust engine is
* resynced to the snapshot track/position. Does not cover `clearQueue` or automatic advances from
* `next()` / gapless.
* If the snapshot had no `currentTrack` but playback is active, the playing track
* is kept: prepended when missing from the restored queue, otherwise re-bound by id.
*/
undoLastQueueEdit: () => boolean;
/** Ctrl+Shift+Z / Cmd+Shift+Z — opposite of `undoLastQueueEdit` while redo stack is non-empty. */
redoLastQueueEdit: () => boolean;
toggleNetworkLove: () => void;
setNetworkLoved: (v: boolean) => void;
setNetworkLovedForSong: (title: string, artist: string, v: boolean) => void;
syncNetworkLovedTracks: () => Promise<void>;
resetAudioPause: () => void;
initializeFromServerQueue: () => Promise<void>;
contextMenu: {
isOpen: boolean;
x: number;
y: number;
item: unknown;
type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist' | null;
queueIndex?: number;
playlistId?: string;
playlistSongIndex?: number;
/** Overrides the EntityShareKind for the "Share" action — used by Composers
* list/grid to copy a `composer` link from the otherwise artist-typed
* context menu, so paste lands on /composer/:id instead of /artist/:id. */
shareKindOverride?: 'track' | 'album' | 'artist' | 'composer';
/** Menu actions target {@link queueServerId} (set for queue-item and player-sourced album menus). */
pinToPlaybackServer?: boolean;
};
openContextMenu: (
x: number,
y: number,
item: unknown,
type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist',
queueIndex?: number,
playlistId?: string,
playlistSongIndex?: number,
shareKindOverride?: 'track' | 'album' | 'artist' | 'composer',
pinToPlaybackServer?: boolean,
) => void;
closeContextMenu: () => void;
songInfoModal: { isOpen: boolean; songId: string | null };
openSongInfo: (songId: string) => void;
closeSongInfo: () => void;
}
@@ -0,0 +1,13 @@
/**
* Side-effect wiring: keep the Rust preview sink volume aligned with the main
* player slider while a track preview is active.
*/
import { invoke } from '@tauri-apps/api/core';
import { computePreviewVolume, usePreviewStore } from '@/features/playback/store/previewStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
usePlayerStore.subscribe((state, prev) => {
if (state.volume === prev.volume) return;
if (!usePreviewStore.getState().previewingId) return;
invoke('audio_preview_set_volume', { volume: computePreviewVolume() }).catch(() => {});
});
@@ -0,0 +1,373 @@
/**
* Characterization tests for `previewStore`.
*
* Phases F0 (bootstrap, _on* handlers + stopPreview) + F4 (startPreview
* cross-store reads + failure paths + main-playback volume sync).
*
* Drives the store through its public action surface with the real
* Zustand instance, stubs the Tauri commands via `onInvoke`, and uses
* `vi.mock('@/lib/api/subsonic')` because `startPreview` calls `buildStreamUrl`.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('@/lib/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
}));
import { usePreviewStore } from '@/features/playback/store/previewStore';
import { useAuthStore } from '@/store/authStore';
import { useOrbitStore } from '@/features/orbit';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import '@/features/playback/store/previewPlayerVolumeSync';
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetAuthStore, resetPreviewStore, resetPlayerStore, resetOrbitStore } from '@/test/helpers/storeReset';
function resetStore() {
usePreviewStore.setState({
previewingId: null,
previewingTrack: null,
elapsed: 0,
duration: 30,
audioStarted: false,
});
}
describe('previewStore — event handlers', () => {
beforeEach(resetStore);
describe('_onStart', () => {
it('flips audioStarted to true when the id matches the active preview', () => {
usePreviewStore.setState({
previewingId: 'song-1',
previewingTrack: { id: 'song-1', title: 't', artist: 'a' },
});
usePreviewStore.getState()._onStart('song-1');
expect(usePreviewStore.getState().audioStarted).toBe(true);
expect(usePreviewStore.getState().previewingId).toBe('song-1');
});
it('takes over the previewingId when the engine fires start for an unknown id', () => {
usePreviewStore.setState({ previewingId: null });
usePreviewStore.getState()._onStart('song-99');
const state = usePreviewStore.getState();
expect(state.previewingId).toBe('song-99');
expect(state.elapsed).toBe(0);
expect(state.audioStarted).toBe(true);
});
});
describe('_onProgress', () => {
it('updates elapsed + duration when the id matches', () => {
usePreviewStore.setState({ previewingId: 'song-1' });
usePreviewStore.getState()._onProgress('song-1', 12.5, 30);
const state = usePreviewStore.getState();
expect(state.elapsed).toBe(12.5);
expect(state.duration).toBe(30);
});
it('ignores progress for a stale id', () => {
usePreviewStore.setState({ previewingId: 'song-1', elapsed: 5 });
usePreviewStore.getState()._onProgress('song-stale', 99, 30);
expect(usePreviewStore.getState().elapsed).toBe(5);
});
});
describe('_onEnd', () => {
it('clears state when the id matches', () => {
usePreviewStore.setState({
previewingId: 'song-1',
previewingTrack: { id: 'song-1', title: 't', artist: 'a' },
elapsed: 27,
audioStarted: true,
});
usePreviewStore.getState()._onEnd('song-1');
const state = usePreviewStore.getState();
expect(state.previewingId).toBeNull();
expect(state.previewingTrack).toBeNull();
expect(state.elapsed).toBe(0);
expect(state.audioStarted).toBe(false);
});
it('ignores end events for a stale id', () => {
usePreviewStore.setState({ previewingId: 'song-1', elapsed: 5, audioStarted: true });
usePreviewStore.getState()._onEnd('song-stale');
expect(usePreviewStore.getState().previewingId).toBe('song-1');
expect(usePreviewStore.getState().audioStarted).toBe(true);
});
});
});
describe('previewStore — stopPreview', () => {
beforeEach(resetStore);
it('returns early without invoking when no preview is active', async () => {
await usePreviewStore.getState().stopPreview();
expect(invokeMock).not.toHaveBeenCalled();
});
it('invokes audio_preview_stop when a preview is active', async () => {
usePreviewStore.setState({ previewingId: 'song-1' });
onInvoke('audio_preview_stop', () => undefined);
await usePreviewStore.getState().stopPreview();
expect(invokeMock).toHaveBeenCalledWith('audio_preview_stop');
});
it('falls back to clearing state locally if invoke rejects', async () => {
usePreviewStore.setState({
previewingId: 'song-1',
previewingTrack: { id: 'song-1', title: 't', artist: 'a' },
audioStarted: true,
});
onInvoke('audio_preview_stop', () => {
throw new Error('engine offline');
});
await usePreviewStore.getState().stopPreview();
const state = usePreviewStore.getState();
expect(state.previewingId).toBeNull();
expect(state.previewingTrack).toBeNull();
expect(state.audioStarted).toBe(false);
});
});
describe('previewStore — startPreview', () => {
beforeEach(() => {
resetPreviewStore();
resetAuthStore();
resetOrbitStore();
resetPlayerStore();
onInvoke('audio_preview_play', () => undefined);
onInvoke('audio_preview_stop', () => undefined);
onInvoke('audio_preview_set_volume', () => undefined);
});
const song = (id = 'song-1') => ({
id,
title: `Title ${id}`,
artist: 'Artist',
coverArt: id,
duration: 240,
});
it('invokes audio_preview_play with the configured args and stores the previewing track', async () => {
await usePreviewStore.getState().startPreview(song('song-1'), 'suggestions');
expect(invokeMock).toHaveBeenCalledWith(
'audio_preview_play',
expect.objectContaining({
id: 'song-1',
url: expect.stringContaining('song-1'),
durationSec: 30,
startSec: 240 * 0.33,
}),
);
const state = usePreviewStore.getState();
expect(state.previewingId).toBe('song-1');
expect(state.previewingTrack).toEqual({
id: 'song-1', title: 'Title song-1', artist: 'Artist', coverArt: 'song-1',
});
expect(state.elapsed).toBe(0);
expect(state.audioStarted).toBe(false);
expect(state.duration).toBe(30);
});
it('passes Subsonic suffix as formatSuffix for Symphonia container hints', async () => {
await usePreviewStore.getState().startPreview({ ...song('flac-1'), suffix: 'flac' }, 'albums');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
expect(call?.[1]).toMatchObject({ formatSuffix: 'flac' });
});
it('starts at 0 when the track is too short to need a mid-track seek', async () => {
// duration <= previewDuration * 1.5 → start at 0.
await usePreviewStore.getState().startPreview({ ...song(), duration: 30 }, 'suggestions');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
expect(call?.[1]).toEqual(expect.objectContaining({ startSec: 0 }));
});
it('passes camelCase keys (Tauri IPC contract — snake_case silently drops to undefined)', async () => {
await usePreviewStore.getState().startPreview(song(), 'suggestions');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
const args = call?.[1] as Record<string, unknown>;
expect(args).toHaveProperty('startSec');
expect(args).toHaveProperty('durationSec');
expect(args).not.toHaveProperty('start_sec');
expect(args).not.toHaveProperty('duration_sec');
});
it('no-ops when previews are globally disabled', async () => {
useAuthStore.setState({ trackPreviewsEnabled: false });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
expect(usePreviewStore.getState().previewingId).toBeNull();
});
it('no-ops when previews are disabled at the calling location', async () => {
useAuthStore.setState({
trackPreviewLocations: {
suggestions: false,
albums: true, playlists: true, favorites: true, artist: true, randomMix: true,
},
});
await usePreviewStore.getState().startPreview(song(), 'suggestions');
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
});
it.each(['active', 'joining', 'starting'] as const)(
'no-ops while the user is a host inside an Orbit %s phase',
async (phase) => {
useOrbitStore.setState({ role: 'host', phase });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
},
);
it.each(['active', 'joining', 'starting'] as const)(
'no-ops while the user is a guest inside an Orbit %s phase',
async (phase) => {
useOrbitStore.setState({ role: 'guest', phase });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
},
);
it('falls through to startPreview when no orbit session is active (role=null)', async () => {
useOrbitStore.setState({ role: null, phase: 'idle' });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
expect(invokeMock).toHaveBeenCalledWith('audio_preview_play', expect.anything());
});
it('treats re-clicking the active preview id as a stop', async () => {
usePreviewStore.setState({ previewingId: 'song-1' });
await usePreviewStore.getState().startPreview(song('song-1'), 'suggestions');
// Goes through stopPreview, not audio_preview_play.
expect(invokeMock).toHaveBeenCalledWith('audio_preview_stop');
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
});
it('rolls back optimistic state when the engine invoke rejects', async () => {
usePreviewStore.setState({
previewingId: 'older',
previewingTrack: { id: 'older', title: 'x', artist: 'y' },
audioStarted: true,
});
onInvoke('audio_preview_play', () => {
throw new Error('engine offline');
});
await usePreviewStore.getState().startPreview(song('song-2'), 'suggestions');
// Only rolls back when the rolled-back id is still the optimistic one.
const state = usePreviewStore.getState();
expect(state.previewingId).toBeNull();
expect(state.previewingTrack).toBeNull();
expect(state.audioStarted).toBe(false);
});
it('folds in the loudness pre-attenuation when normalization=loudness', async () => {
useAuthStore.setState({
normalizationEngine: 'loudness',
loudnessPreAnalysisAttenuationDb: -6,
});
usePlayerStore.setState({ volume: 1.0 });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
const args = call?.[1] as { volume: number };
// 1.0 * 10^(-6/20) ≈ 0.501 — clamped to [0, 1].
expect(args.volume).toBeCloseTo(Math.pow(10, -6 / 20), 4);
});
it('does NOT fold pre-attenuation when normalizationEngine is off', async () => {
useAuthStore.setState({ normalizationEngine: 'off' });
usePlayerStore.setState({ volume: 0.7 });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
const args = call?.[1] as { volume: number };
expect(args.volume).toBeCloseTo(0.7, 5);
});
it('does NOT fold a positive pre-attenuation value (Math.min(0, …) guard)', async () => {
useAuthStore.setState({
normalizationEngine: 'loudness',
loudnessPreAnalysisAttenuationDb: 3, // positive — guard pulls to 0
});
usePlayerStore.setState({ volume: 0.5 });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
const args = call?.[1] as { volume: number };
expect(args.volume).toBeCloseTo(0.5, 5);
});
});
describe('previewStore — main-player volume sync during preview', () => {
beforeEach(() => {
resetPreviewStore();
resetAuthStore();
resetPlayerStore();
onInvoke('audio_preview_set_volume', () => undefined);
invokeMock.mockClear();
});
it('pings the engine when the main player volume changes mid-preview', () => {
usePreviewStore.setState({ previewingId: 'song-1' });
usePlayerStore.setState({ volume: 0.5 });
usePlayerStore.setState({ volume: 0.8 });
expect(invokeMock).toHaveBeenCalledWith(
'audio_preview_set_volume',
expect.objectContaining({ volume: 0.8 }),
);
});
it('does NOT ping the engine when no preview is active', () => {
usePreviewStore.setState({ previewingId: null });
usePlayerStore.setState({ volume: 0.5 });
usePlayerStore.setState({ volume: 0.8 });
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_set_volume', expect.anything());
});
it('does NOT ping when the volume value did not actually change', () => {
usePreviewStore.setState({ previewingId: 'song-1' });
usePlayerStore.setState({ volume: 0.5 });
invokeMock.mockClear();
// Setting to the same value should be skipped by the subscription guard.
usePlayerStore.setState({ volume: 0.5 });
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_set_volume', expect.anything());
});
});
+187
View File
@@ -0,0 +1,187 @@
import { buildStreamUrl } from '@/lib/api/subsonicStreamUrl';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import type { TrackPreviewLocation } from '@/store/authStoreTypes';
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { isOrbitPlaybackSyncActive } from '@/store/orbitRuntime';
/** Minimal track info needed to surface the preview in the player bar UI. */
export interface PreviewingTrack {
id: string;
title: string;
artist: string;
coverArt?: string;
}
export interface PreviewSongInput {
id: string;
title: string;
artist: string;
coverArt?: string;
duration?: number;
suffix?: string;
}
/** Map a browse/playlist song row into preview input (keeps Subsonic suffix for format hints). */
export function previewInputFromSong(
song: Pick<SubsonicSong, 'id' | 'title' | 'artist' | 'coverArt' | 'duration' | 'suffix'>,
): PreviewSongInput {
return {
id: song.id,
title: song.title,
artist: song.artist,
coverArt: song.coverArt,
duration: song.duration,
suffix: song.suffix,
};
}
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: PreviewSongInput, 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.
*/
export 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.
if (isOrbitPlaybackSyncActive()) 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(),
formatSuffix: song.suffix ?? null,
});
} catch (e) {
// Roll back optimistic state on failure.
if (get().previewingId === song.id) {
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
}
console.error('Preview playback failed', 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 });
},
}));
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from '@/features/playback/store/playerStoreTypes';
const invokeMock = vi.fn();
const setEntryMock = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
}));
vi.mock('@/features/playback/store/hotCacheStore', () => ({
useHotCacheStore: {
getState: () => ({ setEntry: setEntryMock }),
},
}));
vi.mock('@/utils/media/mediaDir', () => ({
getMediaDir: () => '/media',
}));
vi.mock('@/lib/api/subsonicStreamUrl', () => ({
buildStreamUrlForServer: (_sid: string, id: string) => `https://mock/stream/${id}`,
}));
vi.mock('@/api/coverCache', () => ({
librarySqlServerId: (k: string) => k,
}));
const hasLocalPersistentPlaybackBytesMock = vi.fn((_trackId: string, _serverId: string) => false);
vi.mock('@/store/localPlaybackResolve', () => ({
hasLocalPersistentPlaybackBytes: (trackId: string, serverId: string) =>
hasLocalPersistentPlaybackBytesMock(trackId, serverId),
}));
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
function track(id: string, overrides: Partial<Track> = {}): Track {
return {
id,
title: 'T',
artist: 'A',
album: 'Al',
albumId: 'al-1',
duration: 100,
...overrides,
};
}
describe('promoteCompletedStreamToHotCache', () => {
beforeEach(() => {
invokeMock.mockReset();
setEntryMock.mockReset();
hasLocalPersistentPlaybackBytesMock.mockReset();
hasLocalPersistentPlaybackBytesMock.mockReturnValue(false);
});
it('skips promote when library or favorites already have bytes', async () => {
hasLocalPersistentPlaybackBytesMock.mockReturnValue(true);
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
expect(invokeMock).not.toHaveBeenCalled();
expect(setEntryMock).not.toHaveBeenCalled();
});
it('forwards a complete payload to the Rust command', async () => {
invokeMock.mockResolvedValueOnce({
path: '/media/cache/t1.mp3',
size: 1234,
layoutFingerprint: 'fp1',
});
await promoteCompletedStreamToHotCache(track('t1', { suffix: 'flac' }), 'srv', null);
expect(invokeMock).toHaveBeenCalledWith('promote_stream_cache_to_local', {
trackId: 't1',
serverIndexKey: 'srv',
libraryServerId: 'srv',
url: expect.stringContaining('t1'),
suffix: 'flac',
mediaDir: '/media',
});
});
it('defaults suffix to mp3', async () => {
invokeMock.mockResolvedValueOnce({ path: '/p', size: 1, layoutFingerprint: '' });
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
expect(invokeMock.mock.calls[0][1]?.suffix).toBe('mp3');
});
it('stores entry when Rust returns a path', async () => {
invokeMock.mockResolvedValueOnce({
path: '/media/cache/t1.mp3',
size: 5678,
layoutFingerprint: 'fp',
});
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
expect(setEntryMock).toHaveBeenCalledWith(
't1',
'srv',
'/media/cache/t1.mp3',
5678,
'stream-promote',
'fp',
'mp3',
);
});
it('swallows invoke errors', async () => {
invokeMock.mockRejectedValueOnce(new Error('fail'));
await expect(promoteCompletedStreamToHotCache(track('t1'), 'srv', null)).resolves.toBeUndefined();
expect(setEntryMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,45 @@
import { buildStreamUrlForServer } from '@/lib/api/subsonicStreamUrl';
import type { Track } from '@/features/playback/store/playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { useHotCacheStore } from '@/features/playback/store/hotCacheStore';
import { getMediaDir } from '@/utils/media/mediaDir';
import { librarySqlServerId } from '@/api/coverCache';
import { hasLocalPersistentPlaybackBytes } from '@/store/localPlaybackResolve';
/**
* Promote a track whose stream cache is full to the on-disk ephemeral tier.
* Best-effort: prefetch remains fallback.
*/
export async function promoteCompletedStreamToHotCache(
track: Track,
serverIndexKey: string,
_customDir: string | null,
): Promise<void> {
if (hasLocalPersistentPlaybackBytes(track.id, serverIndexKey)) return;
try {
const libraryServerId = librarySqlServerId(serverIndexKey);
const res = await invoke<{ path: string; size: number; layoutFingerprint: string } | null>(
'promote_stream_cache_to_local',
{
trackId: track.id,
serverIndexKey,
libraryServerId,
url: buildStreamUrlForServer(serverIndexKey, track.id),
suffix: track.suffix || 'mp3',
mediaDir: getMediaDir(),
},
);
if (!res?.path) return;
useHotCacheStore.getState().setEntry(
track.id,
serverIndexKey,
res.path,
res.size || 0,
'stream-promote',
res.layoutFingerprint,
track.suffix || 'mp3',
);
} catch {
// best-effort promotion; normal hot-cache prefetch remains fallback
}
}
@@ -0,0 +1,302 @@
import { invoke } from '@tauri-apps/api/core';
import { orbitBulkGuard } from '@/store/orbitRuntime';
import { useAuthStore } from '@/store/authStore';
import { setIsAudioPaused } from '@/features/playback/store/engineState';
import { prefetchLoudnessForEnqueuedTracks } from '@/features/playback/store/loudnessPrefetch';
import type { PlayerState, QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
import { syncUserQueueMutationToServer } from '@/features/playback/store/queueSync';
import {
addRadioSessionSeen,
clearRadioSessionSeenIds,
deleteRadioSessionSeen,
getCurrentRadioArtistId,
hasRadioSessionSeen,
setCurrentRadioArtistId,
} from '@/features/playback/store/radioSessionState';
import { clearSeekDebounce } from '@/features/playback/store/seekDebounce';
import { clearSeekFallbackRetry } from '@/features/playback/store/seekFallbackState';
import { clearSeekTarget } from '@/features/playback/store/seekTargetState';
import { playListenSessionFinalize } from '@/features/playback/store/playListenSession';
import {
clearQueueServerForPlayback,
ensureQueueServerPinned,
} from '@/features/playback/utils/playback/playbackServer';
import { clearTimelineSessionHistory } from '@/features/playback/store/timelineSessionHistory';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
type GetState = () => PlayerState;
/**
* The canonical working ref list for a mutation (thin-state). Mutations
* splice/filter/reorder a copy of these refs and write the result back into
* `queueItems` — the queue source of truth. Returns a fresh array each call so
* in-place splices don't mutate the live state array.
*/
const itemsOf = (state: PlayerState): QueueItemRef[] => [...state.queueItems];
/** Seed the resolver cache with tracks entering the queue, so they resolve
* without a network round-trip once `queue: Track[]` is dropped (seed-before-
* splice). No-op without a real playback server (e.g. unit tests). */
function seedIncoming(state: PlayerState, tracks: Track[]): void {
for (const t of tracks) {
const serverId = t.serverId ?? state.queueServerId ?? '';
if (serverId) seedQueueResolver(serverId, [t]);
}
}
/**
* Eleven queue-mutation actions. Shared invariant: every action except
* `setRadioArtistId` pushes a queue-undo snapshot and calls
* `syncUserQueueMutationToServer` so the Navidrome `savePlayQueue` stays in sync.
* Exceptions: `enqueue`'s optional third argument **`skipQueueUndo`** and
* **`pruneUpcomingToCurrent(true)`** — Lucky Mix pushes one snapshot up-front.
*/
export function createQueueMutationActions(set: SetState, get: GetState): Pick<
PlayerState,
| 'enqueue'
| 'enqueueAt'
| 'playNext'
| 'enqueueRadio'
| 'setRadioArtistId'
| 'pruneUpcomingToCurrent'
| 'clearQueue'
| 'reorderQueue'
| 'shuffleQueue'
| 'shuffleUpcomingQueue'
| 'removeTrack'
> {
return {
enqueue: (tracks, _orbitConfirmed = false, skipQueueUndo = false) => {
if (!_orbitConfirmed && tracks.length > 1) {
void orbitBulkGuard(tracks.length).then(ok => {
if (ok) get().enqueue(tracks, true, skipQueueUndo);
});
return;
}
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
ensureQueueServerPinned(tracks);
set(state => {
seedIncoming(state, tracks);
const items = itemsOf(state);
const incoming = toQueueItemRefs(state.queueServerId ?? '', tracks);
// Insert before the first upcoming auto-added track so the
// "Added automatically" separator always stays at the boundary.
const firstAutoIdx = items.findIndex((r, i) => r.autoAdded && i > state.queueIndex);
const newItems = firstAutoIdx === -1
? [...items, ...incoming]
: [...items.slice(0, firstAutoIdx), ...incoming, ...items.slice(firstAutoIdx)];
syncUserQueueMutationToServer(newItems, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, state.queueIndex);
return { queueItems: newItems };
});
},
setRadioArtistId: (artistId) => {
if (artistId !== getCurrentRadioArtistId()) {
clearRadioSessionSeenIds();
}
setCurrentRadioArtistId(artistId);
},
enqueueRadio: (tracks, artistId) => {
if (artistId !== undefined) {
if (artistId !== getCurrentRadioArtistId()) {
clearRadioSessionSeenIds();
}
setCurrentRadioArtistId(artistId);
}
pushQueueUndoFromGetter(get);
ensureQueueServerPinned();
set(state => {
const items = itemsOf(state);
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
// again replaces the pending radio batch instead of stacking on top.
const beforeAndCurrent = items.slice(0, state.queueIndex + 1);
const upcoming = items.slice(state.queueIndex + 1).filter(r => !r.radioAdded);
// Tracks about to leave the queue here. Callers like ContextMenu.startRadio
// pass the previous pending radio back in `tracks` to merge with new
// similars — the seen-set must not block those re-introductions.
const droppedRadioIds = items
.slice(state.queueIndex + 1)
.filter(r => r.radioAdded)
.map(r => r.trackId);
for (const id of droppedRadioIds) deleteRadioSessionSeen(id);
// Capture surviving queue ids in the seen-set so the next radio top-up
// can dedupe against the seed track + already-queued non-radio items.
for (const r of beforeAndCurrent) addRadioSessionSeen(r.trackId);
for (const r of upcoming) addRadioSessionSeen(r.trackId);
// Drop incoming tracks already seen earlier this session AND
// intra-batch duplicates (top + similar Last.fm responses commonly
// overlap). The seen-set is mutated inside the loop so a repeated
// id later in `tracks` is rejected by the same pass that admitted
// the first occurrence (issue #500).
const dedupedTracks: Track[] = [];
for (const t of tracks) {
if (hasRadioSessionSeen(t.id)) continue;
addRadioSessionSeen(t.id);
dedupedTracks.push(t);
}
seedIncoming(state, dedupedTracks);
const incoming = toQueueItemRefs(state.queueServerId ?? '', dedupedTracks);
// Insert new radio tracks before any autoAdded tracks in the upcoming section.
const firstAutoIdx = upcoming.findIndex(r => r.autoAdded);
const mergedItems = firstAutoIdx === -1
? [...upcoming, ...incoming]
: [...upcoming.slice(0, firstAutoIdx), ...incoming, ...upcoming.slice(firstAutoIdx)];
const newItems = [...beforeAndCurrent, ...mergedItems];
syncUserQueueMutationToServer(newItems, state.currentTrack, state.currentTime);
return { queueItems: newItems };
});
},
enqueueAt: (tracks, insertIndex, _orbitConfirmed = false) => {
if (!_orbitConfirmed && tracks.length > 1) {
void orbitBulkGuard(tracks.length).then(ok => {
if (ok) get().enqueueAt(tracks, insertIndex, true);
});
return;
}
pushQueueUndoFromGetter(get);
ensureQueueServerPinned(tracks);
set(state => {
seedIncoming(state, tracks);
const items = itemsOf(state);
const idx = Math.max(0, Math.min(insertIndex, items.length));
const incoming = toQueueItemRefs(state.queueServerId ?? '', tracks);
const newItems = [...items.slice(0, idx), ...incoming, ...items.slice(idx)];
const newQueueIndex = idx <= state.queueIndex
? state.queueIndex + tracks.length
: state.queueIndex;
syncUserQueueMutationToServer(newItems, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, newQueueIndex);
return { queueItems: newItems, queueIndex: newQueueIndex };
});
},
playNext: (tracks) => {
if (tracks.length === 0) return;
ensureQueueServerPinned(tracks);
const state = get();
const tagged = tracks.map(t => ({ ...t, playNextAdded: true as const }));
if (!state.currentTrack) {
state.playTrack(tagged[0], tagged);
return;
}
const baseIdx = state.queueIndex + 1;
let insertIdx = baseIdx;
if (useAuthStore.getState().preservePlayNextOrder) {
const items = itemsOf(state);
while (insertIdx < items.length && items[insertIdx].playNextAdded) insertIdx++;
}
get().enqueueAt(tagged, insertIdx);
},
pruneUpcomingToCurrent: (skipQueueUndo = false) => {
const s = get();
if (s.currentRadio) return;
if (!s.currentTrack) {
if (s.queueItems.length === 0) return;
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
set({ queueItems: [], queueIndex: 0 });
syncUserQueueMutationToServer([], null, 0);
return;
}
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
// Seed the resolver with the currently playing track so its ref always
// resolves even when it had not been in the cache window before.
seedIncoming(s, [s.currentTrack]);
const items = itemsOf(s);
const at = items.findIndex(r => r.trackId === s.currentTrack!.id);
const newItems = at >= 0
? items.slice(0, at + 1)
: toQueueItemRefs(s.queueServerId ?? '', [s.currentTrack!]);
const newIndex = at >= 0 ? at : 0;
set({ queueItems: newItems, queueIndex: newIndex });
syncUserQueueMutationToServer(newItems, s.currentTrack, s.currentTime);
},
clearQueue: () => {
void playListenSessionFinalize('stop');
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
clearSeekFallbackRetry();
clearSeekDebounce(); clearSeekTarget();
clearRadioSessionSeenIds();
setCurrentRadioArtistId(null);
clearTimelineSessionHistory();
clearQueueServerForPlayback();
set({ queueItems: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
syncUserQueueMutationToServer([], null, 0);
},
reorderQueue: (startIndex, endIndex) => {
pushQueueUndoFromGetter(get);
const state = get();
const { queueIndex, currentTrack } = state;
const result = itemsOf(state);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
let newIndex = queueIndex;
if (currentTrack) newIndex = result.findIndex(r => r.trackId === currentTrack.id);
set({ queueItems: result, queueIndex: Math.max(0, newIndex) });
syncUserQueueMutationToServer(result, currentTrack, get().currentTime);
},
shuffleQueue: () => {
const state = get();
const { currentTrack } = state;
if (state.queueItems.length < 2) return;
pushQueueUndoFromGetter(get);
const items = itemsOf(state);
const currentIdx = currentTrack ? items.findIndex(r => r.trackId === currentTrack.id) : -1;
const others = items.filter((_, i) => i !== currentIdx);
for (let i = others.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[others[i], others[j]] = [others[j], others[i]];
}
const result = currentIdx >= 0
? [items[currentIdx], ...others]
: others;
const newIndex = currentIdx >= 0 ? 0 : -1;
set({ queueItems: result, queueIndex: Math.max(0, newIndex) });
syncUserQueueMutationToServer(result, currentTrack, get().currentTime);
},
shuffleUpcomingQueue: () => {
const state = get();
const { queueIndex, currentTrack } = state;
const upcomingStart = queueIndex + 1;
const upcomingCount = state.queueItems.length - upcomingStart;
if (upcomingCount < 2) return;
pushQueueUndoFromGetter(get);
const items = itemsOf(state);
const head = items.slice(0, upcomingStart);
const upcoming = items.slice(upcomingStart);
for (let i = upcoming.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
}
const result = [...head, ...upcoming];
set({ queueItems: result });
syncUserQueueMutationToServer(result, currentTrack, get().currentTime);
},
removeTrack: (index) => {
pushQueueUndoFromGetter(get);
const state = get();
const { queueIndex } = state;
const newItems = itemsOf(state);
newItems.splice(index, 1);
set({
queueItems: newItems,
queueIndex: Math.min(queueIndex, newItems.length - 1),
});
syncUserQueueMutationToServer(newItems, get().currentTrack, get().currentTime);
},
};
}
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
_resetQueuePlaybackIdleForTest,
getIdlePullGeneration,
isIdleQueuePullSuspended,
isQueueNaturallyEnded,
markPlaybackActive,
markQueueNaturallyEnded,
resumeIdleQueuePull,
subscribeIdleQueuePullSuspended,
touchQueueMutationClock,
} from '@/features/playback/store/queuePlaybackIdle';
describe('idle queue pull suspension', () => {
beforeEach(() => {
_resetQueuePlaybackIdleForTest();
});
it('starts unsuspended', () => {
expect(isIdleQueuePullSuspended()).toBe(false);
});
it('suspends on local queue mutation', () => {
touchQueueMutationClock();
expect(isIdleQueuePullSuspended()).toBe(true);
});
it('resumes after explicit resume', () => {
touchQueueMutationClock();
resumeIdleQueuePull();
expect(isIdleQueuePullSuspended()).toBe(false);
});
it('bumps idle pull generation on mutation', () => {
expect(getIdlePullGeneration()).toBe(0);
touchQueueMutationClock();
expect(getIdlePullGeneration()).toBe(1);
touchQueueMutationClock();
expect(getIdlePullGeneration()).toBe(2);
});
it('notifies subscribers when suspension toggles', () => {
let count = 0;
const unsub = subscribeIdleQueuePullSuspended(() => {
count += 1;
});
touchQueueMutationClock();
expect(count).toBe(1);
resumeIdleQueuePull();
expect(count).toBe(2);
unsub();
});
});
describe('natural queue end', () => {
beforeEach(() => {
_resetQueuePlaybackIdleForTest();
});
it('starts clear and can be marked after queue exhaustion', () => {
expect(isQueueNaturallyEnded()).toBe(false);
markQueueNaturallyEnded();
expect(isQueueNaturallyEnded()).toBe(true);
});
it('clears when playback becomes active again', () => {
markQueueNaturallyEnded();
markPlaybackActive();
expect(isQueueNaturallyEnded()).toBe(false);
});
it('clears on local queue mutation', () => {
markQueueNaturallyEnded();
touchQueueMutationClock();
expect(isQueueNaturallyEnded()).toBe(false);
});
});
@@ -0,0 +1,100 @@
/** Timestamps and flags for idle auto-pull guards (play queue sync). */
let playbackIdleSinceMs = 0;
let lastQueueMutationAt = 0;
/** When true, idle auto-pull is disabled until manual pull re-enables it. */
let idleQueuePullSuspended = false;
/** Set when repeat-off playback reaches the queue tail — blocks idle pull until play resumes. */
let queueNaturallyEnded = false;
/** Bumped on each local queue mutation; stale in-flight idle pulls must not apply. */
let idlePullGeneration = 0;
const idlePullSuspensionListeners = new Set<() => void>();
function emitIdlePullSuspensionChange(): void {
for (const listener of idlePullSuspensionListeners) {
listener();
}
}
export function subscribeIdleQueuePullSuspended(listener: () => void): () => void {
idlePullSuspensionListeners.add(listener);
return () => idlePullSuspensionListeners.delete(listener);
}
export function getIdleQueuePullSuspendedSnapshot(): boolean {
return idleQueuePullSuspended;
}
export function markPlaybackIdle(): void {
if (playbackIdleSinceMs === 0) playbackIdleSinceMs = Date.now();
}
export function markPlaybackActive(): void {
playbackIdleSinceMs = 0;
clearQueueNaturallyEnded();
}
export function markQueueNaturallyEnded(): void {
queueNaturallyEnded = true;
}
export function clearQueueNaturallyEnded(): void {
queueNaturallyEnded = false;
}
export function isQueueNaturallyEnded(): boolean {
return queueNaturallyEnded;
}
export function getPlaybackIdleSinceMs(): number {
return playbackIdleSinceMs;
}
export function isPlaybackIdleLongEnough(thresholdMs: number): boolean {
return playbackIdleSinceMs > 0 && Date.now() - playbackIdleSinceMs >= thresholdMs;
}
export function suspendIdleQueuePull(): void {
if (idleQueuePullSuspended) return;
idleQueuePullSuspended = true;
emitIdlePullSuspensionChange();
}
export function resumeIdleQueuePull(): void {
if (!idleQueuePullSuspended) return;
idleQueuePullSuspended = false;
emitIdlePullSuspensionChange();
}
export function isIdleQueuePullSuspended(): boolean {
return idleQueuePullSuspended;
}
export function getIdlePullGeneration(): number {
return idlePullGeneration;
}
export function touchQueueMutationClock(): void {
lastQueueMutationAt = Date.now();
clearQueueNaturallyEnded();
suspendIdleQueuePull();
idlePullGeneration += 1;
}
export function getLastQueueMutationAt(): number {
return lastQueueMutationAt;
}
export function hasRecentQueueMutation(withinMs: number): boolean {
return lastQueueMutationAt > 0 && Date.now() - lastQueueMutationAt < withinMs;
}
/** Test-only reset. */
export function _resetQueuePlaybackIdleForTest(): void {
playbackIdleSinceMs = 0;
lastQueueMutationAt = 0;
idleQueuePullSuspended = false;
queueNaturallyEnded = false;
idlePullGeneration = 0;
}
@@ -0,0 +1,22 @@
/**
* Side-effect wiring (queue thin-state): keep the queue track resolver cache
* warm around the current index whenever the canonical `queueItems` ref list or
* the playing index changes. The store is refs-canonical now, so this fills the
* cache (via `resolveVisibleRange` — index batch → getSong fallback) so the
* queue selectors / list rows resolve without a synchronous miss. Render paths
* stay pure (no cache mutation in render); the seed travels with the playing
* track here, off the render path.
*/
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { resolveVisibleRange } from '@/utils/library/queueTrackResolver';
usePlayerStore.subscribe((state, prev) => {
// Re-seed when the queue refs or the current index change — the prefetch
// window (resolveVisibleRange's PREFETCH_BACK/AHEAD) travels with the index.
if (
state.queueItems === prev.queueItems &&
state.queueIndex === prev.queueIndex
) return;
if (state.queueItems.length === 0) return;
resolveVisibleRange(state.queueItems, state.queueIndex, state.queueIndex);
});
@@ -0,0 +1,204 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
const { savePlayQueueMock, playerState, progressSnapshot, isSubsonicServerReachableMock } = vi.hoisted(() => ({
savePlayQueueMock: vi.fn(async () => undefined),
isSubsonicServerReachableMock: vi.fn((_serverId: string) => true),
playerState: {
queueItems: [] as QueueItemRef[],
currentTrack: null as Track | null,
currentRadio: null as { id: string } | null,
},
progressSnapshot: { currentTime: 0, progress: 0, buffered: 0 },
}));
vi.mock('@/lib/api/subsonicPlayQueue', () => ({ savePlayQueue: savePlayQueueMock }));
vi.mock('@/utils/network/subsonicNetworkGuard', () => ({
isSubsonicServerReachable: (serverId: string) => isSubsonicServerReachableMock(serverId),
}));
vi.mock('@/features/playback/utils/playback/playbackServer', () => ({
getPlaybackServerId: () => 'srv-a',
playbackProfileIdForTrack: (track: Track) => track.serverId ?? 'srv-a',
filterQueueRefsForPlaybackServer: (refs: QueueItemRef[]) =>
refs.filter(r => r.serverId === 'a.test' || r.serverId === 'srv-a'),
}));
vi.mock('@/features/playback/utils/playback/trackServerScope', () => ({
filterQueueRefsForServerProfile: (refs: QueueItemRef[], profileId: string) =>
refs.filter(r => r.serverId === profileId || (profileId === 'srv-a' && r.serverId === 'srv-a')),
}));
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: { getState: () => playerState },
}));
vi.mock('@/features/playback/store/playbackProgress', () => ({
getPlaybackProgressSnapshot: () => progressSnapshot,
}));
import {
_resetQueueSyncForTest,
finalizePlayQueueAtTrackEnd,
flushPlayQueueForServer,
flushPlayQueuePosition,
flushQueueSyncToServer,
getLastQueueHeartbeatAt,
hasPendingQueueSync,
pushQueueOnPlaybackStart,
syncQueueToServer,
syncUserQueueMutationToServer,
} from '@/features/playback/store/queueSync';
import {
_resetQueuePlaybackIdleForTest,
isIdleQueuePullSuspended,
isQueueNaturallyEnded,
} from '@/features/playback/store/queuePlaybackIdle';
function track(id: string, serverId = 'srv-a'): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100, serverId };
}
function ref(id: string, serverId = 'a.test'): QueueItemRef {
return { serverId, trackId: id };
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
isSubsonicServerReachableMock.mockReturnValue(true);
savePlayQueueMock.mockClear();
savePlayQueueMock.mockResolvedValue(undefined);
playerState.queueItems = [];
playerState.currentTrack = null;
playerState.currentRadio = null;
progressSnapshot.currentTime = 0;
_resetQueuePlaybackIdleForTest();
});
afterEach(() => {
_resetQueueSyncForTest();
vi.useRealTimers();
});
describe('syncQueueToServer (debounced)', () => {
const queue = [ref('a'), ref('b')];
it('skips sync while the playback server is unreachable', () => {
isSubsonicServerReachableMock.mockReturnValue(false);
syncQueueToServer(queue, track('a'), 30);
vi.advanceTimersByTime(5000);
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
it('fires once after 5 s with id list + current id + position in ms', () => {
syncQueueToServer(queue, track('a'), 30);
vi.advanceTimersByTime(5000);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a');
});
it('sends only refs owned by the playback server in a mixed queue', () => {
const mixed = [ref('a', 'a.test'), ref('b', 'b.test')];
syncQueueToServer(mixed, track('a', 'srv-a'), 12);
vi.advanceTimersByTime(5000);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
});
it('does not suspend idle pull during playback sync', () => {
syncQueueToServer(queue, track('a'), 30);
expect(isIdleQueuePullSuspended()).toBe(false);
});
});
describe('syncUserQueueMutationToServer (debounced)', () => {
const queue = [ref('a'), ref('b')];
it('suspends idle pull on user mutation and stays suspended after successful debounced push', async () => {
syncUserQueueMutationToServer(queue, track('a'), 30);
expect(isIdleQueuePullSuspended()).toBe(true);
expect(hasPendingQueueSync()).toBe(true);
vi.advanceTimersByTime(5000);
await Promise.resolve();
expect(savePlayQueueMock).toHaveBeenCalled();
expect(isIdleQueuePullSuspended()).toBe(true);
});
it('keeps idle pull suspended when debounced push fails', async () => {
savePlayQueueMock.mockRejectedValueOnce(new Error('offline'));
syncUserQueueMutationToServer(queue, track('a'), 30);
vi.advanceTimersByTime(5000);
await Promise.resolve();
expect(isIdleQueuePullSuspended()).toBe(true);
});
});
describe('pushQueueOnPlaybackStart', () => {
const queue = [ref('a'), ref('b')];
it('flushes immediately and clears idle pull suspension when locally edited', async () => {
syncUserQueueMutationToServer(queue, track('a'), 30);
expect(hasPendingQueueSync()).toBe(true);
pushQueueOnPlaybackStart(queue, track('a'), 42);
expect(hasPendingQueueSync()).toBe(false);
await vi.runAllTimersAsync();
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000, 'srv-a');
expect(isIdleQueuePullSuspended()).toBe(false);
});
it('debounces when idle pull is not suspended', () => {
pushQueueOnPlaybackStart(queue, track('a'), 12);
expect(hasPendingQueueSync()).toBe(true);
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
});
describe('flushPlayQueueForServer', () => {
it('flushes only the target server slice', async () => {
playerState.queueItems = [ref('a', 'srv-a'), ref('b', 'b.test')];
playerState.currentTrack = track('a', 'srv-a');
progressSnapshot.currentTime = 9;
await flushPlayQueueForServer('srv-a');
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 9000, 'srv-a');
});
});
describe('flushQueueSyncToServer (immediate)', () => {
it('fires synchronously with no debounce', async () => {
await flushQueueSyncToServer([ref('a')], track('a'), 12);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
});
it('records the heartbeat timestamp', async () => {
expect(getLastQueueHeartbeatAt()).toBe(0);
await flushQueueSyncToServer([ref('a')], track('a'), 5);
expect(getLastQueueHeartbeatAt()).toBe(Date.now());
});
});
describe('flushPlayQueuePosition', () => {
it('reads the current playerStore queue + playback-progress time', async () => {
playerState.queueItems = [ref('a'), ref('b')];
playerState.currentTrack = track('a');
progressSnapshot.currentTime = 42;
await flushPlayQueuePosition();
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000, 'srv-a');
});
it('is a no-op when a radio session is active', async () => {
playerState.queueItems = [ref('a')];
playerState.currentTrack = track('a');
playerState.currentRadio = { id: 'radio-1' };
await flushPlayQueuePosition();
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
});
describe('finalizePlayQueueAtTrackEnd', () => {
it('flushes immediately at track duration and marks the queue naturally ended', async () => {
const queue = [ref('a'), ref('b')];
const current = track('b');
current.duration = 245;
syncQueueToServer(queue, current, 120);
await finalizePlayQueueAtTrackEnd(queue, current);
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'b', 245000, 'srv-a');
expect(isQueueNaturallyEnded()).toBe(true);
expect(hasPendingQueueSync()).toBe(false);
});
});
+203
View File
@@ -0,0 +1,203 @@
import { savePlayQueue } from '@/lib/api/subsonicPlayQueue';
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
import { isSubsonicServerReachable } from '@/utils/network/subsonicNetworkGuard';
import {
filterQueueRefsForPlaybackServer,
getPlaybackServerId,
playbackProfileIdForTrack,
} from '@/features/playback/utils/playback/playbackServer';
import { filterQueueRefsForServerProfile } from '@/features/playback/utils/playback/trackServerScope';
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
import { touchQueueMutationClock, isIdleQueuePullSuspended, resumeIdleQueuePull, markQueueNaturallyEnded } from '@/features/playback/store/queuePlaybackIdle';
import { usePlayerStore } from '@/features/playback/store/playerStore';
/**
* Server-side play-queue persistence. Subsonic's `savePlayQueue` accepts
* the current queue, the active track id, and the position in ms — so the
* server can hand the same playback state back when the user opens
* another client.
*
* Two flush shapes:
* - `syncQueueToServer` debounces playback position/queue pushes (track
* changes, resume) without blocking idle auto-pull.
* - `syncUserQueueMutationToServer` — same debounce plus idle-pull
* suspension for user-initiated queue edits.
* - `flushQueueSyncToServer` cancels the debounce and pushes immediately —
* called from the playback heartbeat, `pause()`, and the app-close path
* where the user might switch devices mid-track.
*
* Mixed-server queues push only refs owned by the playback server.
* Queues are capped at 1000 ids to match Subsonic's max-length contract.
* Radio sessions skip persistence (the seed station is restored separately).
*/
const SYNC_DEBOUNCE_MS = 5000;
const QUEUE_ID_LIMIT = 1000;
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
let lastQueueHeartbeatAt = 0;
function isPlaybackServerReachable(): boolean {
const serverId = getPlaybackServerId();
return serverId ? isSubsonicServerReachable(serverId) : false;
}
function pushRefsForServer(
refs: QueueItemRef[],
currentTrack: Track | null,
currentTime: number,
serverId: string,
): Promise<void> {
if (!serverId || refs.length === 0 || !currentTrack) return Promise.resolve();
if (playbackProfileIdForTrack(currentTrack) !== serverId) return Promise.resolve();
const ids = refs.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
const pos = Math.floor(currentTime * 1000);
return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(() => {
// Expected when offline or the playback server is unreachable.
});
}
function scheduleQueueSyncToServer(
queue: QueueItemRef[],
currentTrack: Track | null,
currentTime: number,
): void {
if (!isPlaybackServerReachable()) return;
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(() => {
syncTimeout = null;
if (!isPlaybackServerReachable()) return;
const serverId = getPlaybackServerId();
const refs = filterQueueRefsForPlaybackServer(queue);
void pushRefsForServer(refs, currentTrack, currentTime, serverId);
}, SYNC_DEBOUNCE_MS);
}
/** Debounced push during playback (track advance, resume) — does not suspend idle pull. */
export function syncQueueToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): void {
scheduleQueueSyncToServer(queue, currentTrack, currentTime);
}
/** Debounced push after a user queue edit — suspends idle auto-pull until manual sync or Play. */
export function syncUserQueueMutationToServer(
queue: QueueItemRef[],
currentTrack: Track | null,
currentTime: number,
): void {
touchQueueMutationClock();
scheduleQueueSyncToServer(queue, currentTrack, currentTime);
}
export function flushQueueSyncToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): Promise<void> {
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = null;
}
if (!isPlaybackServerReachable()) return Promise.resolve();
if (!currentTrack || queue.length === 0) return Promise.resolve();
lastQueueHeartbeatAt = Date.now();
const serverId = getPlaybackServerId();
const refs = filterQueueRefsForPlaybackServer(queue);
return pushRefsForServer(refs, currentTrack, currentTime, serverId);
}
/**
* Immediate flush of one server's queue slice (e.g. before browse switch).
* Does not mutate local player state.
*/
export function flushPlayQueueForServer(serverProfileId: string): Promise<void> {
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = null;
}
if (!serverProfileId || !isSubsonicServerReachable(serverProfileId)) return Promise.resolve();
const s = usePlayerStore.getState();
if (s.currentRadio) return Promise.resolve();
const refs = filterQueueRefsForServerProfile(s.queueItems, serverProfileId);
if (refs.length === 0 || !s.currentTrack) return Promise.resolve();
const currentTime = getPlaybackProgressSnapshot().currentTime;
return pushRefsForServer(refs, s.currentTrack, currentTime, serverProfileId);
}
/** True while a debounced savePlayQueue is scheduled. */
export function hasPendingQueueSync(): boolean {
return syncTimeout !== null;
}
/** Last heartbeat timestamp (ms epoch). Used by the playback heartbeat to throttle the 15-second auto-flush cadence. */
export function getLastQueueHeartbeatAt(): number {
return lastQueueHeartbeatAt;
}
/**
* Flush the current playerStore queue to the server immediately. Skips
* radio sessions (the seed station is restored separately). Reads the
* live current-time via the playback-progress snapshot so the position
* isn't stale by the debounced store commit.
*/
export function flushPlayQueuePosition(): Promise<void> {
const s = usePlayerStore.getState();
if (s.currentRadio) return Promise.resolve();
return flushQueueSyncToServer(s.queueItems, s.currentTrack, getPlaybackProgressSnapshot().currentTime);
}
/**
* Queue exhausted (repeat off): push the final track at end-of-file so idle
* auto-pull does not rewind to an earlier debounced position on the server.
*/
export function finalizePlayQueueAtTrackEnd(
queue: QueueItemRef[],
currentTrack: Track,
): Promise<void> {
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = null;
}
markQueueNaturallyEnded();
const endSec = Math.max(0, currentTrack.duration ?? 0);
return flushQueueSyncToServer(queue, currentTrack, endSec);
}
/**
* When the user edited the queue while paused, idle pull is suspended (yellow LED).
* Starting playback makes this client authoritative — push the local queue immediately
* and re-enable idle auto-pull (blocked anyway while `isPlaying`).
*/
export function pushQueueOnPlaybackStart(
queue: QueueItemRef[],
currentTrack: Track | null,
currentTime: number,
): void {
if (!currentTrack || queue.length === 0) return;
if (isIdleQueuePullSuspended()) {
void flushQueueSyncToServer(queue, currentTrack, currentTime).then(() => {
resumeIdleQueuePull();
});
return;
}
syncQueueToServer(queue, currentTrack, currentTime);
}
export function flushLocalQueueWhenTakingPlayback(): Promise<void> {
if (!isIdleQueuePullSuspended()) return Promise.resolve();
const s = usePlayerStore.getState();
if (s.currentRadio || !s.currentTrack || s.queueItems.length === 0) {
return Promise.resolve();
}
return flushQueueSyncToServer(
s.queueItems,
s.currentTrack,
getPlaybackProgressSnapshot().currentTime,
).then(() => {
resumeIdleQueuePull();
});
}
/** Test-only: drop the debounce + reset the heartbeat. */
export function _resetQueueSyncForTest(): void {
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = null;
}
lastQueueHeartbeatAt = 0;
}
@@ -0,0 +1,20 @@
/** UI hint: user switched browse server; manual pull is suggested until sync. */
let queueHandoffPending = false;
export function markQueueHandoffPending(): void {
queueHandoffPending = true;
}
export function clearQueueHandoffPending(): void {
queueHandoffPending = false;
}
export function isQueueHandoffPending(): boolean {
return queueHandoffPending;
}
/** Test-only reset. */
export function _resetQueueSyncUiForTest(): void {
queueHandoffPending = false;
}
@@ -0,0 +1,130 @@
/**
* Module-scoped queue undo / redo stack. The interesting behaviours are
* (a) max-size enforcement, (b) the redo stack being wiped on a fresh undo
* push, and (c) the scroll-top reader / consumer pair that QueuePanel uses
* to restore list scroll position after an undo/redo commit.
*/
import type { PlayerState, Track } from '@/features/playback/store/playerStoreTypes';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { beforeEach, describe, expect, it } from 'vitest';
import {
QUEUE_UNDO_MAX,
_resetQueueUndoStacksForTest,
consumePendingQueueListScrollTop,
popQueueRedoSnapshot,
popQueueUndoSnapshot,
pushQueueRedoSnapshot,
pushQueueUndoFromGetter,
pushQueueUndoSnapshot,
queueUndoSnapshotFromState,
registerQueueListScrollTopReader,
setPendingQueueListScrollTop,
} from '@/features/playback/store/queueUndo';
function track(id: string): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
}
// Thin-state: the snapshot reads `queueItems`; the `tracks` arg is a convenience
// for tests — it's lowered to refs (with the currentTrack defaulting to the head).
function state(tracks: Track[], overrides: Partial<PlayerState> = {}): PlayerState {
return {
queueItems: toQueueItemRefs('', tracks),
queueIndex: 0,
currentTrack: tracks[0] ?? null,
currentTime: 0,
progress: 0,
isPlaying: false,
queueServerId: null,
...overrides,
} as PlayerState;
}
beforeEach(() => {
_resetQueueUndoStacksForTest();
registerQueueListScrollTopReader(null);
// Drain any leftover pending scroll-top
consumePendingQueueListScrollTop();
});
describe('queueUndoSnapshotFromState', () => {
it('captures the queue as thin refs and clones currentTrack', () => {
const original = state([track('a'), track('b')]);
const snap = queueUndoSnapshotFromState(original);
expect(snap.currentTrack).not.toBe(original.currentTrack);
expect(snap.queueItems.map(r => r.trackId)).toEqual(['a', 'b']);
});
it('preserves currentTrack=null', () => {
const snap = queueUndoSnapshotFromState(state([]));
expect(snap.currentTrack).toBeNull();
});
it('includes queueListScrollTop only when a reader is registered', () => {
expect(queueUndoSnapshotFromState(state([track('a')])).queueListScrollTop).toBeUndefined();
registerQueueListScrollTopReader(() => 240);
expect(queueUndoSnapshotFromState(state([track('a')])).queueListScrollTop).toBe(240);
});
});
describe('pushQueueUndoFromGetter', () => {
it('captures the current state on top of the undo stack', () => {
pushQueueUndoFromGetter(() => state([track('a')]));
const snap = popQueueUndoSnapshot();
expect(snap?.queueItems[0].trackId).toBe('a');
});
it('wipes the redo stack — a fresh action invalidates redo history', () => {
pushQueueRedoSnapshot(queueUndoSnapshotFromState(state([track('z')])));
pushQueueUndoFromGetter(() => state([track('a')]));
expect(popQueueRedoSnapshot()).toBeUndefined();
});
it(`caps the undo stack at QUEUE_UNDO_MAX (=${QUEUE_UNDO_MAX})`, () => {
for (let i = 0; i < QUEUE_UNDO_MAX + 5; i++) {
pushQueueUndoFromGetter(() => state([track(`t${i}`)]));
}
let depth = 0;
while (popQueueUndoSnapshot()) depth++;
expect(depth).toBe(QUEUE_UNDO_MAX);
});
});
describe('pushQueueUndoSnapshot / pushQueueRedoSnapshot', () => {
it('respect QUEUE_UNDO_MAX when pushing prebuilt snapshots', () => {
const snap = queueUndoSnapshotFromState(state([track('a')]));
for (let i = 0; i < QUEUE_UNDO_MAX + 3; i++) pushQueueRedoSnapshot(snap);
let depth = 0;
while (popQueueRedoSnapshot()) depth++;
expect(depth).toBe(QUEUE_UNDO_MAX);
});
it('undo-snapshot push keeps order LIFO', () => {
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('first')])));
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('second')])));
expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('second');
expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('first');
});
});
describe('_resetQueueUndoStacksForTest', () => {
it('clears both stacks', () => {
pushQueueUndoFromGetter(() => state([track('a')]));
pushQueueRedoSnapshot(queueUndoSnapshotFromState(state([track('b')])));
_resetQueueUndoStacksForTest();
expect(popQueueUndoSnapshot()).toBeUndefined();
expect(popQueueRedoSnapshot()).toBeUndefined();
});
});
describe('pending queue-list scroll-top', () => {
it('returns undefined when nothing was set', () => {
expect(consumePendingQueueListScrollTop()).toBeUndefined();
});
it('round-trips a stored value once and then drains', () => {
setPendingQueueListScrollTop(512);
expect(consumePendingQueueListScrollTop()).toBe(512);
expect(consumePendingQueueListScrollTop()).toBeUndefined();
});
});
+105
View File
@@ -0,0 +1,105 @@
import type { PlayerState, QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
/** Hard cap on undo/redo depth — keeps memory bounded for very long sessions. */
export const QUEUE_UNDO_MAX = 32;
export type QueueUndoSnapshot = {
/** Thin queue refs (thin-state phase 4) — not hydrated `Track[]`, so 32
* snapshots of a 50k queue cost refs, not 32×50k full tracks. Rebuilt to a
* display `Track[]` through the resolver on restore. */
queueItems: QueueItemRef[];
queueIndex: number;
/** Kept full — one resolved playing track, restored to the engine on undo. */
currentTrack: Track | null;
/** Seconds — captured with the snapshot (older entries may omit). */
currentTime?: number;
progress?: number;
isPlaying?: boolean;
/** Main queue panel list `scrollTop` when the snapshot was taken. */
queueListScrollTop?: number;
/** Canonical playback-server identity at snapshot time. Restore uses this
* for any ref it has to prepend (e.g. a still-playing track absent from the
* snapshot's queue) so a mid-restore server switch can't bind the prepended
* ref to the wrong server (B1/H3). Older in-memory entries may omit it;
* callers fall back to the live `queueServerId` in that case. */
queueServerId?: string | null;
};
const queueUndoStack: QueueUndoSnapshot[] = [];
const queueRedoStack: QueueUndoSnapshot[] = [];
/** Test-only: clears module-scoped undo/redo stacks so each test starts clean. */
export function _resetQueueUndoStacksForTest(): void {
queueUndoStack.length = 0;
queueRedoStack.length = 0;
}
/** QueuePanel registers a reader so undo snapshots capture list scroll position. */
let queueListScrollTopReader: (() => number | undefined) | null = null;
export function registerQueueListScrollTopReader(reader: (() => number | undefined) | null): void {
queueListScrollTopReader = reader;
}
function readQueueListScrollTopForUndo(): number | undefined {
return queueListScrollTopReader?.() ?? undefined;
}
/** Set in applyQueueHistorySnapshot; QueuePanel consumes in useLayoutEffect after commit. */
let pendingQueueListScrollTop: number | undefined;
export function setPendingQueueListScrollTop(value: number): void {
pendingQueueListScrollTop = value;
}
export function consumePendingQueueListScrollTop(): number | undefined {
const v = pendingQueueListScrollTop;
pendingQueueListScrollTop = undefined;
return v;
}
export function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot {
const scrollTop = readQueueListScrollTopForUndo();
return {
// Thin refs straight off the canonical list — 32 snapshots cost refs, not
// 32×50k full tracks (the undo "hidden multiplier" the thin-state plan kills).
queueItems: [...s.queueItems],
queueIndex: s.queueIndex,
currentTrack: s.currentTrack ? { ...s.currentTrack } : null,
currentTime: s.currentTime,
progress: s.progress,
isPlaying: s.isPlaying,
queueServerId: s.queueServerId,
...(scrollTop !== undefined ? { queueListScrollTop: scrollTop } : {}),
};
}
/**
* Snapshot the current state onto the undo stack and clear the redo stack —
* standard "new action invalidates redo history" behaviour. Called from every
* mutating queue action.
*/
export function pushQueueUndoFromGetter(get: () => PlayerState): void {
queueRedoStack.length = 0;
queueUndoStack.push(queueUndoSnapshotFromState(get()));
while (queueUndoStack.length > QUEUE_UNDO_MAX) queueUndoStack.shift();
}
export function popQueueUndoSnapshot(): QueueUndoSnapshot | undefined {
return queueUndoStack.pop();
}
export function popQueueRedoSnapshot(): QueueUndoSnapshot | undefined {
return queueRedoStack.pop();
}
/** Push a prebuilt snapshot onto the redo stack — used after an undo step. */
export function pushQueueRedoSnapshot(snap: QueueUndoSnapshot): void {
queueRedoStack.push(snap);
while (queueRedoStack.length > QUEUE_UNDO_MAX) queueRedoStack.shift();
}
/** Push a prebuilt snapshot onto the undo stack — used after a redo step. */
export function pushQueueUndoSnapshot(snap: QueueUndoSnapshot): void {
queueUndoStack.push(snap);
while (queueUndoStack.length > QUEUE_UNDO_MAX) queueUndoStack.shift();
}
@@ -0,0 +1,186 @@
/**
* Smoke test for the queue-undo engine-restore orchestrator. Verifies
* the audio_play payload shape, the seek follow-up that fires when
* atSeconds > 0.05, the wantPlaying=false branch that issues audio_pause,
* and the generation-mismatch bail-out.
*/
import type { Track } from '@/features/playback/store/playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const auth = {
activeServerId: 'srv',
servers: [],
replayGainMode: 'track' as 'track' | 'album',
replayGainPreGainDb: 0,
replayGainFallbackDb: -6,
enableHiRes: false,
};
const player = {
volume: 0.8,
enginePreloadedTrackId: null as string | null,
};
return {
auth,
player,
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => undefined),
setDeferHotCachePrefetchMock: vi.fn(),
resolvePlaybackUrlMock: vi.fn((id: string) => `https://mock/${id}`),
resolveReplayGainDbMock: vi.fn(() => -6),
isReplayGainActiveMock: vi.fn(() => false),
loudnessGainDbForEngineBindMock: vi.fn(() => null as number | null),
recordEnginePlayUrlMock: vi.fn(),
playbackSourceHintMock: vi.fn(() => 'stream'),
touchHotCacheOnPlaybackMock: vi.fn(),
playerSetStateMock: vi.fn(),
setIsAudioPausedMock: vi.fn(),
getPlayGeneration: vi.fn(() => 1),
};
});
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('@/utils/cache/hotCacheGate', () => ({ setDeferHotCachePrefetch: hoisted.setDeferHotCachePrefetchMock }));
vi.mock('@/features/playback/utils/playback/resolvePlaybackUrl', () => ({ resolvePlaybackUrl: hoisted.resolvePlaybackUrlMock }));
vi.mock('@/features/playback/utils/audio/resolveReplayGainDb', () => ({ resolveReplayGainDb: hoisted.resolveReplayGainDbMock }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
vi.mock('@/features/playback/store/engineState', () => ({
getPlayGeneration: hoisted.getPlayGeneration,
setIsAudioPaused: hoisted.setIsAudioPausedMock,
}));
vi.mock('@/features/playback/store/hotCacheTouch', () => ({ touchHotCacheOnPlayback: hoisted.touchHotCacheOnPlaybackMock }));
vi.mock('@/features/playback/store/loudnessGainCache', () => ({
isReplayGainActive: hoisted.isReplayGainActiveMock,
loudnessGainDbForEngineBind: hoisted.loudnessGainDbForEngineBindMock,
}));
vi.mock('@/features/playback/store/playbackUrlRouting', () => ({
playbackSourceHintForResolvedUrl: hoisted.playbackSourceHintMock,
recordEnginePlayUrl: hoisted.recordEnginePlayUrlMock,
}));
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: {
getState: () => hoisted.player,
setState: hoisted.playerSetStateMock,
},
}));
import { queueUndoRestoreAudioEngine } from '@/features/playback/store/queueUndoAudioRestore';
function track(id: string, duration = 100): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration };
}
beforeEach(() => {
hoisted.invokeMock.mockReset();
hoisted.invokeMock.mockResolvedValue(undefined);
hoisted.setDeferHotCachePrefetchMock.mockClear();
hoisted.touchHotCacheOnPlaybackMock.mockClear();
hoisted.playerSetStateMock.mockClear();
hoisted.setIsAudioPausedMock.mockClear();
hoisted.recordEnginePlayUrlMock.mockClear();
hoisted.getPlayGeneration.mockReturnValue(1);
hoisted.player.enginePreloadedTrackId = null;
});
describe('queueUndoRestoreAudioEngine', () => {
it('issues audio_play with the snapshot track parameters', async () => {
queueUndoRestoreAudioEngine({
generation: 1,
track: track('t1'),
queue: [track('t1')],
queueIndex: 0,
atSeconds: 0,
wantPlaying: true,
});
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
url: 'https://mock/t1',
durationHint: 100,
manual: false,
analysisTrackId: 't1',
}));
expect(hoisted.recordEnginePlayUrlMock).toHaveBeenCalledWith('t1', 'https://mock/t1');
expect(hoisted.setDeferHotCachePrefetchMock).toHaveBeenCalledWith(true);
expect(hoisted.touchHotCacheOnPlaybackMock).toHaveBeenCalledWith('t1', 'srv');
});
it('fires audio_seek when atSeconds > 0.05', async () => {
queueUndoRestoreAudioEngine({
generation: 1,
track: track('t1'),
queue: [track('t1')],
queueIndex: 0,
atSeconds: 30,
wantPlaying: true,
});
await Promise.resolve();
await Promise.resolve();
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 });
});
it('skips audio_seek when atSeconds is near zero', async () => {
queueUndoRestoreAudioEngine({
generation: 1,
track: track('t1'),
queue: [track('t1')],
queueIndex: 0,
atSeconds: 0,
wantPlaying: true,
});
await Promise.resolve();
await Promise.resolve();
const seekCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_seek');
expect(seekCall).toBeUndefined();
});
it('loads with startPaused and skips audio_pause when wantPlaying=false', async () => {
queueUndoRestoreAudioEngine({
generation: 1,
track: track('t1'),
queue: [track('t1')],
queueIndex: 0,
atSeconds: 0,
wantPlaying: false,
});
await Promise.resolve();
await Promise.resolve();
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
startPaused: true,
}));
const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause');
expect(pauseCall).toBeUndefined();
expect(hoisted.setIsAudioPausedMock).toHaveBeenCalledWith(true);
});
it('bails out of the .then chain when generation has moved on', async () => {
hoisted.getPlayGeneration.mockReturnValue(2); // user navigated, new gen
queueUndoRestoreAudioEngine({
generation: 1,
track: track('t1'),
queue: [track('t1')],
queueIndex: 0,
atSeconds: 30,
wantPlaying: false,
});
await Promise.resolve();
await Promise.resolve();
// Should NOT have called audio_seek or audio_pause — gen moved on.
const seekCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_seek');
const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause');
expect(seekCall).toBeUndefined();
expect(pauseCall).toBeUndefined();
});
it('clears the deferHotCachePrefetch gate in .finally even on error', async () => {
hoisted.invokeMock.mockRejectedValueOnce(new Error('rust down'));
queueUndoRestoreAudioEngine({
generation: 1,
track: track('t1'),
queue: [track('t1')],
queueIndex: 0,
atSeconds: 0,
wantPlaying: true,
});
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(hoisted.setDeferHotCachePrefetchMock).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,24 @@
import type { Track } from '@/features/playback/store/playerStoreTypes';
import { engineLoadTrackAtPosition } from '@/features/playback/store/engineLoadTrackAtPosition';
/**
* Reload the Rust audio engine to match a queue-undo snapshot. Zustand
* alone can rewrite the queue + currentTrack, but the engine is still
* playing whatever cold-started before the undo — so we need a full
* `audio_play` (+ optional `audio_seek` to the snapshot position) to
* line the audible playback back up with the restored UI state.
*
* Captures the play-generation at start so a later concurrent `playTrack`
* (e.g. user clicks another track) invalidates the seek/pause follow-up
* without clobbering the new engine state.
*/
export function queueUndoRestoreAudioEngine(opts: {
generation: number;
track: Track;
queue: Track[];
queueIndex: number;
atSeconds: number;
wantPlaying: boolean;
}): void {
engineLoadTrackAtPosition(opts);
}

Some files were not shown because too many files have changed in this diff Show More