Compare commits
3 Commits
ac6b76bf98
...
sloppyslop
| Author | SHA1 | Date | |
|---|---|---|---|
| 22f7fd25ff | |||
| 5b31e77b1e | |||
| 7f116f0657 |
@@ -8,6 +8,11 @@ import { pickToEntry } from '../../lib/radio';
|
|||||||
// the end of the queue.
|
// the end of the queue.
|
||||||
const LOOKAHEAD = 3;
|
const LOOKAHEAD = 3;
|
||||||
|
|
||||||
|
// Only the most recent tracks are sent as "don't repeat these" — bounding the
|
||||||
|
// request so a long radio session doesn't ship an ever-growing exclude list
|
||||||
|
// (and a huge NOT IN on the server). Recent-repeat avoidance is what matters.
|
||||||
|
const EXCLUDE_WINDOW = 150;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Headless controller: while a radio drives the queue, top it up as it drains
|
* 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
|
* (§6.5 / B6 infinite feed). Mounted once in AppShell. A failed prefetch just
|
||||||
@@ -34,7 +39,7 @@ export function RadioController() {
|
|||||||
fromLikes: radio.fromLikes,
|
fromLikes: radio.fromLikes,
|
||||||
exploration: radio.exploration,
|
exploration: radio.exploration,
|
||||||
count: 10,
|
count: 10,
|
||||||
excludeIds: entries.map((e) => e.trackId),
|
excludeIds: entries.slice(-EXCLUDE_WINDOW).map((e) => e.trackId),
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
if (res.picks.length > 0) {
|
if (res.picks.length > 0) {
|
||||||
dispatch(appendToQueue(res.picks.map(pickToEntry)));
|
dispatch(appendToQueue(res.picks.map(pickToEntry)));
|
||||||
|
|||||||
@@ -65,6 +65,23 @@ function fieldStyle(): React.CSSProperties {
|
|||||||
return { width: '100%' };
|
return { width: '100%' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap how many bulk metadata ops run at once — a large selection would otherwise
|
||||||
|
// fire hundreds of simultaneous requests (each also spins up a worker enqueue).
|
||||||
|
const BULK_CONCURRENCY = 6;
|
||||||
|
|
||||||
|
async function settleInBatches<T>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
op: (item: T) => Promise<unknown>,
|
||||||
|
): Promise<PromiseSettledResult<unknown>[]> {
|
||||||
|
const results: PromiseSettledResult<unknown>[] = [];
|
||||||
|
for (let i = 0; i < items.length; i += limit) {
|
||||||
|
const batch = items.slice(i, i + limit);
|
||||||
|
results.push(...(await Promise.allSettled(batch.map(op))));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
||||||
* match picker with a current-vs-proposed diff. `/metadata/batch` operates on a
|
* match picker with a current-vs-proposed diff. `/metadata/batch` operates on a
|
||||||
@@ -723,7 +740,7 @@ function BatchEditor() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
const ids = [...selected];
|
const ids = [...selected];
|
||||||
const outcomes = await Promise.allSettled(ids.map(op));
|
const outcomes = await settleInBatches(ids, BULK_CONCURRENCY, op);
|
||||||
const ok = outcomes.filter((o) => o.status === 'fulfilled').length;
|
const ok = outcomes.filter((o) => o.status === 'fulfilled').length;
|
||||||
setResult({ ok, fail: ids.length - ok });
|
setResult({ ok, fail: ids.length - ok });
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|||||||
+38
-21
@@ -31,38 +31,43 @@ export function useAudioPlayer() {
|
|||||||
// on demand; a cache miss transparently falls back to the original.
|
// on demand; a cache miss transparently falls back to the original.
|
||||||
const { data: settings } = useGetSettingsQuery();
|
const { data: settings } = useGetSettingsQuery();
|
||||||
const streamQuality = settings?.streamQuality;
|
const streamQuality = settings?.streamQuality;
|
||||||
const isSetup = useRef(false);
|
// `ended` reads the latest loop flag through a ref so the listener doesn't
|
||||||
// `ended` is registered once below; read the latest loop flag through a ref
|
// need re-binding on every queue change.
|
||||||
// so the listener doesn't need to be re-bound on every queue change.
|
|
||||||
const loopRef = useRef(queue.loop);
|
const loopRef = useRef(queue.loop);
|
||||||
loopRef.current = queue.loop;
|
loopRef.current = queue.loop;
|
||||||
|
// The track id the current <audio>.src points at — lets us tell a real track
|
||||||
|
// change (start at 0) from a same-track URL change (preserve position).
|
||||||
|
const loadedTrackRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSetup.current) return;
|
|
||||||
isSetup.current = true;
|
|
||||||
|
|
||||||
const audio = getAudio();
|
const audio = getAudio();
|
||||||
|
// Named handlers + cleanup so a remount (React StrictMode, or the hook
|
||||||
audio.addEventListener('timeupdate', () => {
|
// mounting elsewhere) can't leave duplicate listeners double-dispatching.
|
||||||
dispatch(setPosition(audio.currentTime));
|
const onTime = () => dispatch(setPosition(audio.currentTime));
|
||||||
});
|
const onDuration = () => dispatch(setDuration(audio.duration || 0));
|
||||||
audio.addEventListener('durationchange', () => {
|
const onEnded = () => {
|
||||||
dispatch(setDuration(audio.duration || 0));
|
|
||||||
});
|
|
||||||
audio.addEventListener('ended', () => {
|
|
||||||
if (loopRef.current) {
|
if (loopRef.current) {
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
void audio.play();
|
void audio.play();
|
||||||
} else {
|
} else {
|
||||||
dispatch(nextTrack());
|
dispatch(nextTrack());
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
audio.addEventListener('pause', () => {
|
const onPause = () => dispatch(pause());
|
||||||
dispatch(pause());
|
const onPlay = () => dispatch(resume());
|
||||||
});
|
|
||||||
audio.addEventListener('play', () => {
|
audio.addEventListener('timeupdate', onTime);
|
||||||
dispatch(resume());
|
audio.addEventListener('durationchange', onDuration);
|
||||||
});
|
audio.addEventListener('ended', onEnded);
|
||||||
|
audio.addEventListener('pause', onPause);
|
||||||
|
audio.addEventListener('play', onPlay);
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('timeupdate', onTime);
|
||||||
|
audio.removeEventListener('durationchange', onDuration);
|
||||||
|
audio.removeEventListener('ended', onEnded);
|
||||||
|
audio.removeEventListener('pause', onPause);
|
||||||
|
audio.removeEventListener('play', onPlay);
|
||||||
|
};
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -70,8 +75,20 @@ export function useAudioPlayer() {
|
|||||||
const audio = getAudio();
|
const audio = getAudio();
|
||||||
const url = getStreamUrl(player.currentTrackId, accessToken, streamQuality);
|
const url = getStreamUrl(player.currentTrackId, accessToken, streamQuality);
|
||||||
if (audio.src !== url) {
|
if (audio.src !== url) {
|
||||||
|
// Same track, new URL (quality changed, or the access token was refreshed
|
||||||
|
// mid-play) → keep the playback position instead of restarting from 0.
|
||||||
|
const sameTrack = loadedTrackRef.current === player.currentTrackId;
|
||||||
|
const resumeAt = sameTrack ? audio.currentTime : 0;
|
||||||
audio.src = url;
|
audio.src = url;
|
||||||
audio.load();
|
audio.load();
|
||||||
|
loadedTrackRef.current = player.currentTrackId;
|
||||||
|
if (resumeAt > 0) {
|
||||||
|
const onMeta = () => {
|
||||||
|
audio.currentTime = resumeAt;
|
||||||
|
audio.removeEventListener('loadedmetadata', onMeta);
|
||||||
|
};
|
||||||
|
audio.addEventListener('loadedmetadata', onMeta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (player.isPlaying) {
|
if (player.isPlaying) {
|
||||||
void audio.play();
|
void audio.play();
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export interface QueueState {
|
|||||||
radio: RadioContext | null;
|
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 = {
|
export const queueInitialState: QueueState = {
|
||||||
entries: [],
|
entries: [],
|
||||||
currentIndex: -1,
|
currentIndex: -1,
|
||||||
@@ -83,6 +89,17 @@ export const queueSlice = createSlice({
|
|||||||
},
|
},
|
||||||
appendToQueue(state, action: PayloadAction<QueueEntry[]>) {
|
appendToQueue(state, action: PayloadAction<QueueEntry[]>) {
|
||||||
state.entries.push(...action.payload);
|
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>) {
|
setExploration(state, action: PayloadAction<number>) {
|
||||||
if (state.radio) state.radio.exploration = action.payload;
|
if (state.radio) state.radio.exploration = action.payload;
|
||||||
|
|||||||
Reference in New Issue
Block a user