From 7dd77a4723f3a7556f2519df364edc4b16dbf401 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 15:22:03 -0400 Subject: [PATCH] feat(web): add enqueueTrack, enqueueTracks, playRadio to player store enqueueTrack/enqueueTracks append to the queue without disturbing playback state (no auto-play on append, no state mutation when queue was non-empty). playRadio fetches /api/radio?seed_track= and feeds the response into playQueue. Empty response leaves queue alone. --- web/src/lib/player/store.svelte.ts | 21 +++++++++++- web/src/lib/player/store.test.ts | 54 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts index 0185d5aa..a3cff894 100644 --- a/web/src/lib/player/store.svelte.ts +++ b/web/src/lib/player/store.svelte.ts @@ -1,4 +1,5 @@ -import type { TrackRef } from '$lib/api/types'; +import type { TrackRef, RadioResponse } from '$lib/api/types'; +import { api } from '$lib/api/client'; export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error'; export type RepeatMode = 'off' | 'all' | 'one'; @@ -196,3 +197,21 @@ export function reportStateFromAudio( return; } } + +export function enqueueTrack(t: TrackRef): void { + _queue = [..._queue, t]; + if (_state === 'idle') _index = 0; +} + +export function enqueueTracks(ts: TrackRef[]): void { + if (ts.length === 0) return; + _queue = [..._queue, ...ts]; + if (_state === 'idle') _index = 0; +} + +export async function playRadio(seedTrackId: string): Promise { + const resp = await api.get( + `/api/radio?seed_track=${encodeURIComponent(seedTrackId)}` + ); + if (resp.tracks.length > 0) playQueue(resp.tracks, 0); +} diff --git a/web/src/lib/player/store.test.ts b/web/src/lib/player/store.test.ts index 1e767e5b..693bf661 100644 --- a/web/src/lib/player/store.test.ts +++ b/web/src/lib/player/store.test.ts @@ -279,3 +279,57 @@ describe('player store — shuffle + repeat + audio reports', () => { expect(player.error).toBe('network lost'); }); }); + +import { enqueueTrack, enqueueTracks, playRadio } from './store.svelte'; +import * as client from '$lib/api/client'; + +describe('player store — enqueue + playRadio', () => { + test('enqueueTrack appends to a non-empty queue, _index unchanged', () => { + playQueue([track('1'), track('2')], 1); + enqueueTrack(track('3')); + expect(player.queue.length).toBe(3); + expect(player.queue[2].id).toBe('3'); + expect(player.index).toBe(1); + }); + + test('enqueueTrack on idle (empty queue) sets index=0 and _state stays idle', () => { + playQueue([]); + expect(player.state).toBe('idle'); + enqueueTrack(track('1')); + expect(player.queue.length).toBe(1); + expect(player.index).toBe(0); + expect(player.state).toBe('idle'); + }); + + test('enqueueTracks([]) is a no-op', () => { + playQueue([track('1')]); + enqueueTracks([]); + expect(player.queue.length).toBe(1); + }); + + test('enqueueTracks concatenates non-empty list', () => { + playQueue([track('1')]); + enqueueTracks([track('2'), track('3')]); + expect(player.queue.map((t) => t.id)).toEqual(['1', '2', '3']); + }); + + test('playRadio fetches /api/radio and feeds tracks into playQueue', async () => { + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ + tracks: [track('seed'), track('next')] + }); + await playRadio('seed'); + expect(apiGet).toHaveBeenCalledWith('/api/radio?seed_track=seed'); + expect(player.queue.length).toBe(2); + expect(player.index).toBe(0); + expect(player.state).toBe('loading'); + apiGet.mockRestore(); + }); + + test('playRadio with empty response leaves queue alone', async () => { + playQueue([track('a')]); + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: [] }); + await playRadio('seed'); + expect(player.queue.map((t) => t.id)).toEqual(['a']); + apiGet.mockRestore(); + }); +});