mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
281e86fd3b
* fix(queue): persist timeline play history across queue replace (#1096) Add a session-scoped play-history buffer (with play_session cold bootstrap) and timeline UI that shows history + current + upcoming without mutating the canonical queue or Subsonic sync. * fix(queue): pin timeline current to top and replay history in-place Timeline scroll matches queue mode (current at top). History clicks insert after the playing track instead of replacing the queue, and replayed tracks stay visible in the history strip. * docs: add CHANGELOG and credits for timeline play history (PR #1204) * fix(queue): resolve cross-server cover art for timeline history Include album/cover ids in play_session bootstrap rows, prefetch history refs through the queue resolver per server, and resolve before replay so Now Playing artwork works for inactive-server tracks. * fix(now-playing): stop playbackReport on cross-server track switch Send stopped to the previous server's playbackReport session when the playback server changes (queue click, history replay, etc.) so Who is listening clears on the server that was showing the prior track. * fix(queue): close timeline history review gaps for PR #1204 Defer play_session bootstrap until the library index is ready with retry while timeline mode is active; resolve history and queue rows by serverId + trackId for mixed-server queues; add tests for bootstrap defer and ref lookup. * chore(queue): remove dead timeline scroll guard in QueueList Timeline scroll is handled in the virtual-rows effect; the legacy branch is queue/playlist only. * fix(queue): address PR review nits for timeline play history Use authoritative row.ref.serverId for history clicks before resolver fill; simplify empty bootstrap seed; tighten completion types; unify recent_plays SQL. * fix(queue): immutable session history append for useSyncExternalStore Replace in-place push with a fresh array so getSnapshot returns a new reference and React re-renders on live appends without a playerStore update.
292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
/**
|
|
* 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 `@/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('@/api/subsonic', async () => {
|
|
const actual = await vi.importActual<typeof import('@/api/subsonic')>('@/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('@/utils/orbitBulkGuard', () => ({
|
|
orbitBulkGuard: vi.fn(async () => true),
|
|
}));
|
|
|
|
import { usePlayerStore } from './playerStore';
|
|
import {
|
|
appendTimelineSessionPlay,
|
|
getTimelineSessionHistorySnapshot,
|
|
} from './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);
|
|
});
|
|
});
|