5b31e77b1e
An infinite radio appends forever: cap the queue at QUEUE_CAP, trimming only already-played entries (keep KEEP_BEHIND for prev-nav), and send only the most recent EXCLUDE_WINDOW ids as "don't repeat" so the request and server NOT IN don't grow unbounded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
5.2 KiB
TypeScript
185 lines
5.2 KiB
TypeScript
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
|
|
|
export type QueueSource =
|
|
| 'manual'
|
|
| 'album'
|
|
| 'playlist'
|
|
| 'artist'
|
|
| 'search'
|
|
| 'radio';
|
|
|
|
export interface QueueEntry {
|
|
trackId: string;
|
|
title: string;
|
|
artistName: string;
|
|
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 {
|
|
entries: QueueEntry[];
|
|
currentIndex: number;
|
|
source: QueueSource;
|
|
sourceId: string | null;
|
|
sourceName: string | null;
|
|
shuffle: boolean;
|
|
loop: boolean;
|
|
/** Non-null while a radio drives the queue (enables infinite loading). */
|
|
radio: RadioContext | null;
|
|
}
|
|
|
|
// An infinite radio keeps appending, so cap the backlog: once the queue grows
|
|
// past QUEUE_CAP, drop the oldest *already-played* entries (never the current
|
|
// track or the look-ahead), keeping at least KEEP_BEHIND for prev-navigation.
|
|
const QUEUE_CAP = 300;
|
|
const KEEP_BEHIND = 50;
|
|
|
|
export const queueInitialState: QueueState = {
|
|
entries: [],
|
|
currentIndex: -1,
|
|
source: 'manual',
|
|
sourceId: null,
|
|
sourceName: null,
|
|
shuffle: false,
|
|
loop: false,
|
|
radio: null,
|
|
};
|
|
|
|
export const queueSlice = createSlice({
|
|
name: 'queue',
|
|
initialState: queueInitialState,
|
|
reducers: {
|
|
setQueue(
|
|
state,
|
|
action: PayloadAction<{
|
|
entries: QueueEntry[];
|
|
startIndex?: number;
|
|
source: QueueSource;
|
|
sourceId?: string;
|
|
sourceName?: string;
|
|
}>,
|
|
) {
|
|
state.entries = action.payload.entries;
|
|
state.currentIndex = action.payload.startIndex ?? 0;
|
|
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);
|
|
if (state.entries.length > QUEUE_CAP) {
|
|
// Only trim entries safely behind the current one.
|
|
const drop = Math.min(
|
|
state.entries.length - QUEUE_CAP,
|
|
Math.max(0, state.currentIndex - KEEP_BEHIND),
|
|
);
|
|
if (drop > 0) {
|
|
state.entries.splice(0, drop);
|
|
state.currentIndex -= drop;
|
|
}
|
|
}
|
|
},
|
|
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);
|
|
},
|
|
addNextInQueue(state, action: PayloadAction<QueueEntry>) {
|
|
state.entries.splice(state.currentIndex + 1, 0, action.payload);
|
|
},
|
|
playNow(state, action: PayloadAction<QueueEntry>) {
|
|
const insertAt = state.currentIndex + 1;
|
|
state.entries.splice(insertAt, 0, action.payload);
|
|
state.currentIndex = insertAt;
|
|
},
|
|
removeFromQueue(state, action: PayloadAction<number>) {
|
|
state.entries.splice(action.payload, 1);
|
|
if (action.payload < state.currentIndex) state.currentIndex--;
|
|
},
|
|
moveInQueue(state, action: PayloadAction<{ from: number; to: number }>) {
|
|
const { from, to } = action.payload;
|
|
const [entry] = state.entries.splice(from, 1);
|
|
state.entries.splice(to, 0, entry);
|
|
if (state.currentIndex === from) state.currentIndex = to;
|
|
else if (from < state.currentIndex && to >= state.currentIndex)
|
|
state.currentIndex--;
|
|
else if (from > state.currentIndex && to <= state.currentIndex)
|
|
state.currentIndex++;
|
|
},
|
|
goToIndex(state, action: PayloadAction<number>) {
|
|
state.currentIndex = action.payload;
|
|
},
|
|
nextTrack(state) {
|
|
if (state.shuffle && state.entries.length > 1) {
|
|
let next = state.currentIndex;
|
|
while (next === state.currentIndex) {
|
|
next = Math.floor(Math.random() * state.entries.length);
|
|
}
|
|
state.currentIndex = next;
|
|
} else if (state.currentIndex < state.entries.length - 1) {
|
|
state.currentIndex++;
|
|
}
|
|
},
|
|
prevTrack(state) {
|
|
if (state.currentIndex > 0) state.currentIndex--;
|
|
},
|
|
clearQueue(state) {
|
|
state.entries = [];
|
|
state.currentIndex = -1;
|
|
state.radio = null;
|
|
},
|
|
toggleShuffle(state) {
|
|
state.shuffle = !state.shuffle;
|
|
},
|
|
toggleLoop(state) {
|
|
state.loop = !state.loop;
|
|
},
|
|
},
|
|
});
|
|
|
|
export const {
|
|
setQueue,
|
|
startRadioQueue,
|
|
appendToQueue,
|
|
setExploration,
|
|
stopRadio,
|
|
addToQueue,
|
|
addNextInQueue,
|
|
playNow,
|
|
removeFromQueue,
|
|
moveInQueue,
|
|
goToIndex,
|
|
nextTrack,
|
|
prevTrack,
|
|
clearQueue,
|
|
toggleShuffle,
|
|
toggleLoop,
|
|
} = queueSlice.actions;
|
|
export default queueSlice.reducer;
|