feat(radio): wire radio + similarity into the queue (A11/B6, §6.5)

"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>
This commit is contained in:
Цвылев Александр Вадимович
2026-07-28 22:15:47 +03:00
parent e45ef785cf
commit ac6b76bf98
12 changed files with 254 additions and 1 deletions
+38
View File
@@ -15,6 +15,15 @@ export interface QueueEntry {
albumTitle: string;
durationMs: number;
albumArtUrl?: string;
/** Radio reason code (`ml`/`similar`/`from_likes`/`discover`) — §6.5. */
reason?: string;
}
/** Active-radio context: what seeds the infinite feed + the exploration bias. */
export interface RadioContext {
seedTrackId: string | null;
fromLikes: boolean;
exploration: number;
}
export interface QueueState {
@@ -25,6 +34,8 @@ export interface QueueState {
sourceName: string | null;
shuffle: boolean;
loop: boolean;
/** Non-null while a radio drives the queue (enables infinite loading). */
radio: RadioContext | null;
}
export const queueInitialState: QueueState = {
@@ -35,6 +46,7 @@ export const queueInitialState: QueueState = {
sourceName: null,
shuffle: false,
loop: false,
radio: null,
};
export const queueSlice = createSlice({
@@ -56,6 +68,27 @@ export const queueSlice = createSlice({
state.source = action.payload.source;
state.sourceId = action.payload.sourceId ?? null;
state.sourceName = action.payload.sourceName ?? null;
state.radio = null; // a manual/album/… queue ends any active radio
},
startRadioQueue(
state,
action: PayloadAction<{ entries: QueueEntry[]; radio: RadioContext }>,
) {
state.entries = action.payload.entries;
state.currentIndex = 0;
state.source = 'radio';
state.sourceId = null;
state.sourceName = null;
state.radio = action.payload.radio;
},
appendToQueue(state, action: PayloadAction<QueueEntry[]>) {
state.entries.push(...action.payload);
},
setExploration(state, action: PayloadAction<number>) {
if (state.radio) state.radio.exploration = action.payload;
},
stopRadio(state) {
state.radio = null;
},
addToQueue(state, action: PayloadAction<QueueEntry>) {
state.entries.push(action.payload);
@@ -102,6 +135,7 @@ export const queueSlice = createSlice({
clearQueue(state) {
state.entries = [];
state.currentIndex = -1;
state.radio = null;
},
toggleShuffle(state) {
state.shuffle = !state.shuffle;
@@ -114,6 +148,10 @@ export const queueSlice = createSlice({
export const {
setQueue,
startRadioQueue,
appendToQueue,
setExploration,
stopRadio,
addToQueue,
addNextInQueue,
playNow,