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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user