fix: track a playback currentIndex so prev and repeat-all work

next() did `queue.slice(idx + 1)`, so the current track was always
queue[0]. prev()'s `idx > 0` guard could therefore never pass after an
auto-advance — Previous did nothing, ever — and repeat: 'all' jumped to
queue[0], which is the track that just finished, looping the last track of
an album instead of restarting it.

Replaced with a currentIndex cursor; the queue is no longer trimmed behind
the playhead. The old slice did serve a purpose — bounding Vibe-prefetch
growth — so that is preserved as a MAX_HISTORY = 50 cap that drops the
oldest entries and re-bases the index, rather than dropped outright.
setQueue/playTrack/setCurrentTrack recompute the cursor, next()/prev() fall
back to findIndex if it drifts, and shuffle now picks by index so the
cursor stays valid.

Consumer audit: NowPlayingPanel and Vibe.tsx already derived position via
findIndex and needed no change. TrackRow.handlePlay did
`setQueue(queue.slice(index))`, which re-broke prev at the point of click
even with the store fixed; it now passes the intact queue.

This commit also includes a pre-existing uncommitted fix from the working
tree (not authored by Claude): the end-of-queue auto-resume loop, which
stops playback at the end of the queue instead of restarting. It is correct
and independent of the cursor bug, and is preserved verbatim here.

REVIEW-2026-07-30.md finding 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-30 23:50:08 +04:00
parent 755de34501
commit 543031e48c
2 changed files with 117 additions and 31 deletions
+4 -2
View File
@@ -26,7 +26,7 @@ interface TrackRowProps {
showVibe?: boolean; showVibe?: boolean;
} }
export function TrackRow({ track, queue, index, showActions = true, variant = 'default', showVibe = false }: TrackRowProps) { export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false }: TrackRowProps) {
const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore(); const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore();
const dislikeTrack = useDislikeTrack(); const dislikeTrack = useDislikeTrack();
const router = useRouter(); const router = useRouter();
@@ -35,7 +35,9 @@ export function TrackRow({ track, queue, index, showActions = true, variant = 'd
const handlePlay = () => { const handlePlay = () => {
if (isCurrent) { isPlaying ? pause() : play(); return; } if (isCurrent) { isPlaying ? pause() : play(); return; }
setQueue(queue.slice(index)); // Queue the whole list and start at this track, so Previous can walk back
// into the tracks before it.
setQueue(queue);
playTrack(track); playTrack(track);
}; };
+113 -29
View File
@@ -3,9 +3,17 @@ import type { Track } from '../types';
export type RepeatMode = 'none' | 'all' | 'one'; export type RepeatMode = 'none' | 'all' | 'one';
/**
* How many already-played tracks to keep behind the cursor. Bounds queue growth
* from the Vibe prefetch loop while still leaving real history for prev().
*/
const MAX_HISTORY = 50;
interface PlaybackState { interface PlaybackState {
currentTrack: Track | null; currentTrack: Track | null;
queue: Track[]; queue: Track[];
/** Cursor into `queue` for the current track, or -1 when the current track is not queued. */
currentIndex: number;
isPlaying: boolean; isPlaying: boolean;
position: number; position: number;
duration: number; duration: number;
@@ -29,9 +37,33 @@ interface PlaybackState {
cycleRepeat: () => void; cycleRepeat: () => void;
} }
/**
* Move to `index` in `queue`, trimming stale history so the queue stays bounded.
* Returns the state patch (queue may be re-sliced, so the index is adjusted).
*/
function advanceTo(queue: Track[], index: number) {
let nextQueue = queue;
let nextIndex = index;
if (index > MAX_HISTORY) {
const drop = index - MAX_HISTORY;
nextQueue = queue.slice(drop);
nextIndex = index - drop;
}
const track = nextQueue[nextIndex];
return {
queue: nextQueue,
currentIndex: nextIndex,
currentTrack: track,
position: 0,
duration: track?.duration ?? 0,
isPlaying: true,
};
}
export const usePlaybackStore = create<PlaybackState>((set, get) => ({ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
currentTrack: null, currentTrack: null,
queue: [], queue: [],
currentIndex: -1,
isPlaying: false, isPlaying: false,
position: 0, position: 0,
duration: 0, duration: 0,
@@ -40,25 +72,42 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
repeat: 'none', repeat: 'none',
shufflePlayed: new Set<string>(), shufflePlayed: new Set<string>(),
setQueue: (queue) => set({ queue, shufflePlayed: new Set() }), setQueue: (queue) =>
set((state) => ({
queue,
// Keep the cursor pointing at whatever is playing, if it is still queued.
currentIndex: state.currentTrack ? queue.findIndex((t) => t.id === state.currentTrack!.id) : -1,
shufflePlayed: new Set(),
})),
playTrack: (track) => playTrack: (track) =>
set({ set((state) => ({
currentTrack: track, currentTrack: track,
currentIndex: state.queue.findIndex((t) => t.id === track.id),
isPlaying: true, isPlaying: true,
position: 0, position: 0,
duration: track.duration ?? 0, duration: track.duration ?? 0,
shufflePlayed: new Set(), shufflePlayed: new Set(),
}), })),
play: () => set({ isPlaying: true }), play: () => set({ isPlaying: true }),
pause: () => set({ isPlaying: false }), pause: () => set({ isPlaying: false }),
next: () => { next: () => {
const { queue, currentTrack, shuffle, repeat, shufflePlayed } = get(); const { queue, currentTrack, shuffle, repeat, shufflePlayed } = get();
if (queue.length === 0) return; if (queue.length === 0) {
set({ isPlaying: false, position: 0 });
return;
}
const idx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1; // Prefer the tracked cursor; fall back to a lookup if it has drifted.
const tracked = get().currentIndex;
const idx =
tracked >= 0 && queue[tracked]?.id === currentTrack?.id
? tracked
: currentTrack
? queue.findIndex((t) => t.id === currentTrack.id)
: -1;
// Repeat one: replay current track // Repeat one: replay current track
if (repeat === 'one' && currentTrack) { if (repeat === 'one' && currentTrack) {
@@ -71,49 +120,84 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
// so repeat-all doesn't bounce between the same few tracks. // so repeat-all doesn't bounce between the same few tracks.
const played = new Set(shufflePlayed); const played = new Set(shufflePlayed);
if (currentTrack) played.add(currentTrack.id); if (currentTrack) played.add(currentTrack.id);
let remaining = queue.filter((t) => !played.has(t.id)); const indices = queue.map((_, i) => i);
let remaining = indices.filter((i) => !played.has(queue[i].id));
if (remaining.length === 0) { if (remaining.length === 0) {
if (repeat !== 'all') return; if (repeat !== 'all') {
set({ isPlaying: false, position: 0 });
return;
}
// Lap complete — start a fresh one. // Lap complete — start a fresh one.
played.clear(); played.clear();
if (currentTrack) played.add(currentTrack.id); if (currentTrack) played.add(currentTrack.id);
remaining = queue.filter((t) => t.id !== currentTrack?.id); remaining = indices.filter((i) => queue[i].id !== currentTrack?.id);
if (remaining.length === 0) return; if (remaining.length === 0) {
set({ isPlaying: false, position: 0 });
return;
}
} }
const pick = remaining[Math.floor(Math.random() * remaining.length)]; const pickIdx = remaining[Math.floor(Math.random() * remaining.length)];
played.add(pick.id); played.add(queue[pickIdx].id);
set({ currentTrack: pick, shufflePlayed: played, position: 0, duration: pick.duration ?? 0, isPlaying: true }); const pick = queue[pickIdx];
set({
currentTrack: pick,
currentIndex: pickIdx,
shufflePlayed: played,
position: 0,
duration: pick.duration ?? 0,
isPlaying: true,
});
return; return;
} }
// Sequential // Sequential — keep the queue intact and move the cursor, so prev() has
const nextTrack = idx >= 0 ? queue[idx + 1] : null; // history and repeat-all restarts from the true first track.
if (nextTrack) { if (idx < 0) {
// Trim played tracks from queue to prevent unbounded growth (Vibe prefetch leak) // Nothing playing (or the current track left the queue) — start at the top.
const trimmed = queue.slice(idx + 1); set(advanceTo(queue, 0));
set({ queue: trimmed, currentTrack: nextTrack, position: 0, duration: nextTrack.duration ?? 0, isPlaying: true }); } else if (idx + 1 < queue.length) {
} else if (repeat === 'all') { set(advanceTo(queue, idx + 1));
const first = queue[0]; } else if (repeat === 'all' && queue.length > 0) {
if (first) { set(advanceTo(queue, 0));
set({ currentTrack: first, position: 0, duration: first.duration ?? 0, isPlaying: true }); } else {
} // End of queue with no looping — stop playback so the store, DOM audio
// element, and MediaSession all agree nothing is playing. Without this
// the browser's now-playing widget can auto-resume the finished track
// from its near-end position, causing a tight "play last second → end →
// auto-resume → play last second → …" loop that also degrades performance.
set({ isPlaying: false, position: 0 });
} }
}, },
prev: () => { prev: () => {
const { queue, currentTrack } = get(); const { queue, currentTrack, currentIndex } = get();
if (queue.length === 0) return; if (queue.length === 0) return;
const idx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1; const idx =
const prevTrack = idx > 0 ? queue[idx - 1] : null; currentIndex >= 0 && queue[currentIndex]?.id === currentTrack?.id
if (prevTrack) { ? currentIndex
set({ currentTrack: prevTrack, position: 0, duration: prevTrack.duration ?? 0, isPlaying: true }); : currentTrack
? queue.findIndex((t) => t.id === currentTrack.id)
: -1;
if (idx > 0) {
const prevTrack = queue[idx - 1];
set({
currentTrack: prevTrack,
currentIndex: idx - 1,
position: 0,
duration: prevTrack.duration ?? 0,
isPlaying: true,
});
} }
}, },
setPosition: (position) => set({ position }), setPosition: (position) => set({ position }),
setDuration: (duration) => set({ duration }), setDuration: (duration) => set({ duration }),
setVolume: (volume) => set({ volume }), setVolume: (volume) => set({ volume }),
setCurrentTrack: (currentTrack) => set({ currentTrack }), setCurrentTrack: (currentTrack) =>
set((state) => ({
currentTrack,
currentIndex: currentTrack ? state.queue.findIndex((t) => t.id === currentTrack.id) : -1,
})),
toggleShuffle: () => set((state) => ({ shuffle: !state.shuffle })), toggleShuffle: () => set((state) => ({ shuffle: !state.shuffle })),
cycleRepeat: () => cycleRepeat: () =>