ac6b76bf98
"Start radio" in the track menu seeds the queue from a track; RadioController
(headless, in AppShell) tops it up via /radio/next as it drains. QueuePanel's
radio scaffolding is now live: the exploration slider drives familiar<->new and
each entry shows its reason ("why this is playing"). Adds the radio endpoints
(start/next/similar), radio context + actions on the queue slice, and the pick->
entry mapper. Degrades to metadata picks while ML is offline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
52 lines
1.8 KiB
TypeScript
52 lines
1.8 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
|
import { useRadioNextMutation } from '../../api/endpoints/radio';
|
|
import { appendToQueue } from '../../store/slices/queue';
|
|
import { pickToEntry } from '../../lib/radio';
|
|
|
|
// Prefetch more radio tracks once the current position is within this many of
|
|
// the end of the queue.
|
|
const LOOKAHEAD = 3;
|
|
|
|
/**
|
|
* Headless controller: while a radio drives the queue, top it up as it drains
|
|
* (§6.5 / B6 infinite feed). Mounted once in AppShell. A failed prefetch just
|
|
* stops the feed — playback of what's already queued is unaffected.
|
|
*/
|
|
export function RadioController() {
|
|
const dispatch = useAppDispatch();
|
|
const radio = useAppSelector((s) => s.queue.radio);
|
|
const currentIndex = useAppSelector((s) => s.queue.currentIndex);
|
|
const entries = useAppSelector((s) => s.queue.entries);
|
|
const [radioNext] = useRadioNextMutation();
|
|
const loadingRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!radio) return;
|
|
const nearEnd = currentIndex >= entries.length - LOOKAHEAD;
|
|
if (!nearEnd || loadingRef.current) return;
|
|
|
|
loadingRef.current = true;
|
|
void (async () => {
|
|
try {
|
|
const res = await radioNext({
|
|
seedTrackId: radio.seedTrackId ?? undefined,
|
|
fromLikes: radio.fromLikes,
|
|
exploration: radio.exploration,
|
|
count: 10,
|
|
excludeIds: entries.map((e) => e.trackId),
|
|
}).unwrap();
|
|
if (res.picks.length > 0) {
|
|
dispatch(appendToQueue(res.picks.map(pickToEntry)));
|
|
}
|
|
} catch {
|
|
// best-effort — leave the queue as-is
|
|
} finally {
|
|
loadingRef.current = false;
|
|
}
|
|
})();
|
|
}, [radio, currentIndex, entries, radioNext, dispatch]);
|
|
|
|
return null;
|
|
}
|