diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts index 2676d10b..ff738ad3 100644 --- a/web/src/lib/player/store.svelte.ts +++ b/web/src/lib/player/store.svelte.ts @@ -27,6 +27,7 @@ let _volume = $state(readStoredVolume()); let _shuffle = $state(false); let _repeat = $state('off'); let _error = $state(null); +let _queueDrawerOpen = $state(false); // M4c: track when the queue was seeded by a radio call so we can // auto-refresh at 80% consumption. Cleared when the user enqueues @@ -47,7 +48,8 @@ export const player = { get volume() { return _volume; }, get shuffle() { return _shuffle; }, get repeat() { return _repeat; }, - get error() { return _error; } + get error() { return _error; }, + get queueDrawerOpen() { return _queueDrawerOpen; } }; export function registerAudioEl(el: HTMLAudioElement | null): void { @@ -243,6 +245,70 @@ export async function playRadio(seedTrackId: string): Promise { _radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it) } +export function moveQueueItem(from: number, to: number): void { + if (from === to) return; + if (from < 0 || from >= _queue.length) return; + if (to < 0 || to >= _queue.length) return; + const playingId = _queue[_index]?.id; + const next = _queue.slice(); + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + _queue = next; + if (playingId) { + const found = next.findIndex((x) => x.id === playingId); + if (found >= 0) _index = found; + } +} + +export function removeFromQueue(idx: number): void { + if (idx < 0 || idx >= _queue.length) return; + const next = _queue.slice(); + next.splice(idx, 1); + if (idx < _index) { + _queue = next; + _index = _index - 1; + return; + } + if (idx > _index) { + _queue = next; + return; + } + // idx === _index — removing the currently-playing track. + _queue = next; + if (next.length === 0) { + _index = 0; + _state = 'idle'; + _position = 0; + _duration = 0; + _error = null; + return; + } + // _index stays — now points at what was the next track. + if (_index >= next.length) _index = next.length - 1; + _position = 0; + _duration = 0; + _state = 'loading'; + _error = null; +} + +export function playFromQueueIndex(idx: number): void { + if (idx < 0 || idx >= _queue.length) return; + _radioSeedId = null; + _index = idx; + _position = 0; + _duration = 0; + _state = 'loading'; + _error = null; +} + +export function toggleQueueDrawer(): void { + _queueDrawerOpen = !_queueDrawerOpen; +} + +export function closeQueueDrawer(): void { + _queueDrawerOpen = false; +} + // M4c: auto-refresh radio queue when 80% consumed. // Fires whenever the consumption ratio crosses 80% AND the queue was // seeded by playRadio() AND no refresh is currently in-flight. diff --git a/web/src/lib/player/store.test.ts b/web/src/lib/player/store.test.ts index 97fc7642..961b44d1 100644 --- a/web/src/lib/player/store.test.ts +++ b/web/src/lib/player/store.test.ts @@ -280,7 +280,17 @@ describe('player store — shuffle + repeat + audio reports', () => { }); }); -import { enqueueTrack, enqueueTracks, playNext, playRadio } from './store.svelte'; +import { + enqueueTrack, + enqueueTracks, + playNext, + playRadio, + moveQueueItem, + removeFromQueue, + playFromQueueIndex, + toggleQueueDrawer, + closeQueueDrawer +} from './store.svelte'; import * as client from '$lib/api/client'; describe('player store — enqueue + playRadio', () => { @@ -427,3 +437,86 @@ describe('M4c radio auto-refresh at 80%', () => { apiGet.mockRestore(); }); }); + +describe('queue mutations', () => { + test('moveQueueItem reorders correctly when the playing track stays put', () => { + playQueue([track('a'), track('b'), track('c')], 0); + moveQueueItem(1, 2); + expect(player.queue.map((x) => x.id)).toEqual(['a', 'c', 'b']); + expect(player.index).toBe(0); + }); + + test('moveQueueItem keeps _index pointing at the playing track when its row moves', () => { + playQueue([track('a'), track('b'), track('c')], 1); + moveQueueItem(1, 0); + expect(player.queue.map((x) => x.id)).toEqual(['b', 'a', 'c']); + expect(player.index).toBe(0); + }); + + test('moveQueueItem preserves _index logical pointer when other rows reorder around it', () => { + playQueue([track('a'), track('b'), track('c'), track('d')], 1); + moveQueueItem(0, 3); + expect(player.queue.map((x) => x.id)).toEqual(['b', 'c', 'd', 'a']); + expect(player.index).toBe(0); + }); + + test('removeFromQueue with idx < _index decrements _index', () => { + playQueue([track('a'), track('b'), track('c')], 2); + removeFromQueue(0); + expect(player.queue.map((x) => x.id)).toEqual(['b', 'c']); + expect(player.index).toBe(1); + }); + + test('removeFromQueue with idx > _index leaves _index unchanged', () => { + playQueue([track('a'), track('b'), track('c')], 0); + removeFromQueue(2); + expect(player.queue.map((x) => x.id)).toEqual(['a', 'b']); + expect(player.index).toBe(0); + }); + + test('removeFromQueue with idx === _index advances to next, sets state to loading', () => { + playQueue([track('a'), track('b'), track('c')], 1); + removeFromQueue(1); + expect(player.queue.map((x) => x.id)).toEqual(['a', 'c']); + expect(player.index).toBe(1); + expect(player.state).toBe('loading'); + }); + + test('removeFromQueue emptying the queue sets state to idle', () => { + playQueue([track('a')], 0); + removeFromQueue(0); + expect(player.queue).toEqual([]); + expect(player.state).toBe('idle'); + }); + + test('playFromQueueIndex sets index, resets position, sets state to loading', () => { + playQueue([track('a'), track('b'), track('c')], 0); + playFromQueueIndex(2); + expect(player.index).toBe(2); + expect(player.position).toBe(0); + expect(player.state).toBe('loading'); + }); + + test('playFromQueueIndex with out-of-range idx is a no-op', () => { + playQueue([track('a'), track('b')], 0); + playFromQueueIndex(5); + expect(player.index).toBe(0); + }); + + test('toggleQueueDrawer flips _queueDrawerOpen', () => { + expect(player.queueDrawerOpen).toBe(false); + toggleQueueDrawer(); + expect(player.queueDrawerOpen).toBe(true); + toggleQueueDrawer(); + expect(player.queueDrawerOpen).toBe(false); + }); + + test('closeQueueDrawer is idempotent', () => { + closeQueueDrawer(); + expect(player.queueDrawerOpen).toBe(false); + toggleQueueDrawer(); + expect(player.queueDrawerOpen).toBe(true); + closeQueueDrawer(); + expect(player.queueDrawerOpen).toBe(false); + }); +});