feat(web/m7-364): queue mutations + drawer state on player store
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ let _volume = $state(readStoredVolume());
|
|||||||
let _shuffle = $state(false);
|
let _shuffle = $state(false);
|
||||||
let _repeat = $state<RepeatMode>('off');
|
let _repeat = $state<RepeatMode>('off');
|
||||||
let _error = $state<string | null>(null);
|
let _error = $state<string | null>(null);
|
||||||
|
let _queueDrawerOpen = $state(false);
|
||||||
|
|
||||||
// M4c: track when the queue was seeded by a radio call so we can
|
// M4c: track when the queue was seeded by a radio call so we can
|
||||||
// auto-refresh at 80% consumption. Cleared when the user enqueues
|
// auto-refresh at 80% consumption. Cleared when the user enqueues
|
||||||
@@ -47,7 +48,8 @@ export const player = {
|
|||||||
get volume() { return _volume; },
|
get volume() { return _volume; },
|
||||||
get shuffle() { return _shuffle; },
|
get shuffle() { return _shuffle; },
|
||||||
get repeat() { return _repeat; },
|
get repeat() { return _repeat; },
|
||||||
get error() { return _error; }
|
get error() { return _error; },
|
||||||
|
get queueDrawerOpen() { return _queueDrawerOpen; }
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerAudioEl(el: HTMLAudioElement | null): void {
|
export function registerAudioEl(el: HTMLAudioElement | null): void {
|
||||||
@@ -243,6 +245,70 @@ export async function playRadio(seedTrackId: string): Promise<void> {
|
|||||||
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
|
_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.
|
// M4c: auto-refresh radio queue when 80% consumed.
|
||||||
// Fires whenever the consumption ratio crosses 80% AND the queue was
|
// Fires whenever the consumption ratio crosses 80% AND the queue was
|
||||||
// seeded by playRadio() AND no refresh is currently in-flight.
|
// seeded by playRadio() AND no refresh is currently in-flight.
|
||||||
|
|||||||
@@ -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';
|
import * as client from '$lib/api/client';
|
||||||
|
|
||||||
describe('player store — enqueue + playRadio', () => {
|
describe('player store — enqueue + playRadio', () => {
|
||||||
@@ -427,3 +437,86 @@ describe('M4c radio auto-refresh at 80%', () => {
|
|||||||
apiGet.mockRestore();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user