From d8b955ff0c2d8d74fc83dbbe72d3f18ea89fcd77 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 08:50:24 -0400 Subject: [PATCH] feat(web): radio queue auto-refreshes at 80% via ?exclude= param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds _radioSeedId state and a module-level $effect.root that triggers a /api/radio?seed_track=…&exclude=… fetch when queue consumption reaches 80%, appending new tracks without resetting the radio seed. Clears seed on any non-radio enqueue (playQueue/enqueueTrack/enqueueTracks). Five new vitest cases cover the trigger threshold, append logic, in-flight guard, and manual-enqueue reset path. --- web/src/lib/player/store.svelte.ts | 50 ++++++++++++++++++- web/src/lib/player/store.test.ts | 78 ++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts index a3cff894..2078894b 100644 --- a/web/src/lib/player/store.svelte.ts +++ b/web/src/lib/player/store.svelte.ts @@ -28,6 +28,12 @@ let _shuffle = $state(false); let _repeat = $state('off'); let _error = $state(null); +// M4c: track when the queue was seeded by a radio call so we can +// auto-refresh at 80% consumption. Cleared when the user enqueues +// from a non-radio source. +let _radioSeedId = $state(null); +let _radioRefreshInFlight = false; + let _audioEl: HTMLAudioElement | null = null; export const player = { @@ -49,6 +55,7 @@ export function registerAudioEl(el: HTMLAudioElement | null): void { } export function playQueue(tracks: TrackRef[], startIndex = 0): void { + _radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state _queue = tracks; if (tracks.length === 0) { _index = 0; @@ -199,11 +206,13 @@ export function reportStateFromAudio( } export function enqueueTrack(t: TrackRef): void { + _radioSeedId = null; // M4c _queue = [..._queue, t]; if (_state === 'idle') _index = 0; } export function enqueueTracks(ts: TrackRef[]): void { + _radioSeedId = null; // M4c if (ts.length === 0) return; _queue = [..._queue, ...ts]; if (_state === 'idle') _index = 0; @@ -213,5 +222,44 @@ 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); + if (resp.tracks.length === 0) return; + playQueue(resp.tracks, 0); + _radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it) } + +// 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. +$effect.root(() => { + $effect(() => { + if (_radioSeedId === null) return; + if (_radioRefreshInFlight) return; + if (_queue.length === 0) return; + const consumedRatio = (_index + 1) / _queue.length; + if (consumedRatio < 0.8) return; + + _radioRefreshInFlight = true; + const seed = _radioSeedId; + const exclude = _queue.map((t) => t.id).join(','); + api + .get( + `/api/radio?seed_track=${encodeURIComponent(seed)}&exclude=${exclude}` + ) + .then((resp) => { + // Strip the seed at index 0 (already in our queue) and any other + // tracks that happen to match the original seed id. + const newTracks = resp.tracks.slice(1).filter((t) => t.id !== seed); + if (newTracks.length > 0) { + // Append directly without calling enqueueTracks — that would + // clear _radioSeedId and prevent further refreshes. + _queue = [..._queue, ...newTracks]; + } + }) + .catch(() => { + // Swallow; next track-advance can retry. + }) + .finally(() => { + _radioRefreshInFlight = false; + }); + }); +}); diff --git a/web/src/lib/player/store.test.ts b/web/src/lib/player/store.test.ts index 693bf661..57a50a30 100644 --- a/web/src/lib/player/store.test.ts +++ b/web/src/lib/player/store.test.ts @@ -333,3 +333,81 @@ describe('player store — enqueue + playRadio', () => { apiGet.mockRestore(); }); }); + +describe('M4c radio auto-refresh at 80%', () => { + test('refresh fires at 80% queue consumption', async () => { + const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); + await playRadio('seed'); + apiGet.mockClear(); + apiGet.mockResolvedValueOnce({ + tracks: [track('seed'), track('new1')] + }); + // Advance to index 3 (4 of 5 = 80%) + skipNext(); skipNext(); skipNext(); + // Allow $effect to flush + await new Promise((r) => setTimeout(r, 30)); + expect(apiGet).toHaveBeenCalled(); + const callArg = apiGet.mock.calls[0][0] as string; + expect(callArg).toContain('seed_track=seed'); + expect(callArg).toContain('exclude='); + apiGet.mockRestore(); + }); + + test('refresh below 80% does not fire', async () => { + const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); + await playRadio('seed'); + apiGet.mockClear(); + skipNext(); skipNext(); // index = 2 (3 of 5 = 60%) + await new Promise((r) => setTimeout(r, 30)); + expect(apiGet).not.toHaveBeenCalled(); + apiGet.mockRestore(); + }); + + test('refresh appends new tracks excluding seed at index 0', async () => { + const initial = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: initial }); + await playRadio('seed'); + apiGet.mockClear(); + // Refresh response: 5 tracks where the first is the seed (will be stripped). + const refreshTracks = [track('seed'), track('n1'), track('n2'), track('n3'), track('n4')]; + apiGet.mockResolvedValueOnce({ tracks: refreshTracks }); + skipNext(); skipNext(); skipNext(); // index = 3 (80%) + await new Promise((r) => setTimeout(r, 30)); + // Initial 5 + 4 new (5 in response - 1 seed stripped) = 9 + expect(player.queue.length).toBe(9); + apiGet.mockRestore(); + }); + + test('refresh does NOT double-fire while in-flight', async () => { + const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); + await playRadio('seed'); + apiGet.mockClear(); + // Hang the refresh call so the in-flight flag stays set. + let resolveFn!: (v: unknown) => void; + const hanging = new Promise((r) => { resolveFn = r; }); + apiGet.mockReturnValueOnce(hanging as unknown as Promise); + skipNext(); skipNext(); skipNext(); // 80% — fires refresh + await new Promise((r) => setTimeout(r, 30)); + // Now advance further while in-flight; should NOT fire a second call. + skipNext(); + await new Promise((r) => setTimeout(r, 30)); + expect(apiGet).toHaveBeenCalledTimes(1); + resolveFn({ tracks: [] }); + apiGet.mockRestore(); + }); + + test('refresh resets when user enqueues from non-radio source', async () => { + const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); + await playRadio('seed'); + enqueueTrack(track('manual1')); + apiGet.mockClear(); + skipNext(); skipNext(); skipNext(); skipNext(); // advance past 80% on the 6-track queue + await new Promise((r) => setTimeout(r, 30)); + expect(apiGet).not.toHaveBeenCalled(); + apiGet.mockRestore(); + }); +});