feat(web): radio queue auto-refreshes at 80% via ?exclude= param
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.
This commit is contained in:
@@ -28,6 +28,12 @@ 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);
|
||||||
|
|
||||||
|
// 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<string | null>(null);
|
||||||
|
let _radioRefreshInFlight = false;
|
||||||
|
|
||||||
let _audioEl: HTMLAudioElement | null = null;
|
let _audioEl: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
export const player = {
|
export const player = {
|
||||||
@@ -49,6 +55,7 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function playQueue(tracks: TrackRef[], startIndex = 0): void {
|
export function playQueue(tracks: TrackRef[], startIndex = 0): void {
|
||||||
|
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
||||||
_queue = tracks;
|
_queue = tracks;
|
||||||
if (tracks.length === 0) {
|
if (tracks.length === 0) {
|
||||||
_index = 0;
|
_index = 0;
|
||||||
@@ -199,11 +206,13 @@ export function reportStateFromAudio(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function enqueueTrack(t: TrackRef): void {
|
export function enqueueTrack(t: TrackRef): void {
|
||||||
|
_radioSeedId = null; // M4c
|
||||||
_queue = [..._queue, t];
|
_queue = [..._queue, t];
|
||||||
if (_state === 'idle') _index = 0;
|
if (_state === 'idle') _index = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enqueueTracks(ts: TrackRef[]): void {
|
export function enqueueTracks(ts: TrackRef[]): void {
|
||||||
|
_radioSeedId = null; // M4c
|
||||||
if (ts.length === 0) return;
|
if (ts.length === 0) return;
|
||||||
_queue = [..._queue, ...ts];
|
_queue = [..._queue, ...ts];
|
||||||
if (_state === 'idle') _index = 0;
|
if (_state === 'idle') _index = 0;
|
||||||
@@ -213,5 +222,44 @@ export async function playRadio(seedTrackId: string): Promise<void> {
|
|||||||
const resp = await api.get<RadioResponse>(
|
const resp = await api.get<RadioResponse>(
|
||||||
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
`/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<RadioResponse>(
|
||||||
|
`/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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -333,3 +333,81 @@ describe('player store — enqueue + playRadio', () => {
|
|||||||
apiGet.mockRestore();
|
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<unknown>);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user