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:
2026-04-29 08:50:24 -04:00
parent 8647f9ebe0
commit d8b955ff0c
2 changed files with 127 additions and 1 deletions
+49 -1
View File
@@ -28,6 +28,12 @@ let _shuffle = $state(false);
let _repeat = $state<RepeatMode>('off');
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;
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<void> {
const resp = await api.get<RadioResponse>(
`/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;
});
});
});