mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
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:
@@ -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 1–2 ticks with the old
|
||||
// position before the seek takes effect. Block until current_time is
|
||||
// within 2 s of the requested target, then clear the guard.
|
||||
const activeSeekTarget = getSeekTarget();
|
||||
if (activeSeekTarget !== null) {
|
||||
if (Math.abs(current_time - activeSeekTarget) > 2.0) {
|
||||
// If a seek command hangs while streaming is stalled, do not freeze UI.
|
||||
if (Date.now() - getSeekTargetSetAt() <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
|
||||
clearSeekTarget();
|
||||
} else {
|
||||
clearSeekTarget();
|
||||
}
|
||||
}
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
const track = store.currentTrack;
|
||||
if (!track) return;
|
||||
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 8–32 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 500–600 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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; // 0–1
|
||||
buffered: number; // 0–1 (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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user