fix(ui): preserve pending track selection through queue sync and premature callbacks

When clicking a song while another was playing, PLAYER_SYNC_QUEUE and
PLAYER_CURRENT would fire before the music player switched tracks,
wiping the playIndex set by PLAYER_PLAY_TRACKS. This caused the player
to stay on the old track instead of switching to the clicked one.

Now reduceSyncQueue and reduceCurrent preserve a pending playIndex until
the music player confirms it actually reached the requested track.
This commit is contained in:
Deluan
2026-03-09 12:44:19 -04:00
parent 09e1cf6ae7
commit e08d4bef16
2 changed files with 107 additions and 5 deletions
+13 -4
View File
@@ -173,8 +173,11 @@ const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => {
return {
...state,
queue: audioLists,
clear: false,
playIndex: undefined,
// Keep clear and playIndex alive so the music player can still
// pick up a pending track selection set by PLAYER_PLAY_TRACKS.
// They will be consumed by the next PLAYER_CURRENT dispatch.
clear: state.playIndex != null ? state.clear : false,
playIndex: state.playIndex != null ? state.playIndex : undefined,
}
}
@@ -183,11 +186,17 @@ const reduceCurrent = (state, { data }) => {
const savedPlayIndex = state.queue.findIndex(
(item) => item.uuid === current.uuid,
)
// When a track selection is pending (playIndex is set), keep it alive
// until the music player confirms it actually switched to the requested
// track. Without this, a premature onAudioPlay callback for the
// still-playing old track would overwrite the pending selection.
const pending = state.playIndex != null && savedPlayIndex !== state.playIndex
return {
...state,
current,
playIndex: undefined,
savedPlayIndex,
playIndex: pending ? state.playIndex : undefined,
clear: pending ? state.clear : false,
savedPlayIndex: pending ? state.savedPlayIndex : savedPlayIndex,
volume: data.volume,
}
}
+94 -1
View File
@@ -1,8 +1,101 @@
import { describe, it, expect } from 'vitest'
import { playerReducer } from './playerReducer'
import { PLAYER_REFRESH_QUEUE } from '../actions'
import {
PLAYER_SYNC_QUEUE,
PLAYER_CURRENT,
PLAYER_REFRESH_QUEUE,
} from '../actions'
describe('playerReducer', () => {
describe('pending track selection survives SYNC_QUEUE and premature CURRENT', () => {
// Simulates the real sequence when clicking a new song while one is playing:
// 1. PLAYER_PLAY_TRACKS sets playIndex and clear
// 2. PLAYER_SYNC_QUEUE fires when music player syncs its internal queue
// 3. PLAYER_CURRENT fires for the OLD still-playing track
// 4. PLAYER_CURRENT fires for the NEW track (player switched)
const stateAfterPlayTracks = {
queue: [
{ trackId: 's1', uuid: 'aaa', name: 'Song 1' },
{ trackId: 's2', uuid: 'bbb', name: 'Song 2' },
{ trackId: 's3', uuid: 'ccc', name: 'Song 3' },
],
current: { uuid: 'ccc', name: 'Song 3' },
playIndex: 0, // user clicked Song 1
savedPlayIndex: 2, // Song 3 was playing
clear: true,
volume: 1,
}
it('SYNC_QUEUE preserves pending playIndex and clear', () => {
const newQueue = [
{ trackId: 's1', uuid: 'xxx', name: 'Song 1' },
{ trackId: 's2', uuid: 'yyy', name: 'Song 2' },
{ trackId: 's3', uuid: 'zzz', name: 'Song 3' },
]
const action = {
type: PLAYER_SYNC_QUEUE,
data: { audioInfo: {}, audioLists: newQueue },
}
const result = playerReducer(stateAfterPlayTracks, action)
expect(result.playIndex).toBe(0)
expect(result.clear).toBe(true)
expect(result.queue).toBe(newQueue)
})
it('SYNC_QUEUE clears playIndex when no pending selection', () => {
const stateNoPending = { ...stateAfterPlayTracks, playIndex: undefined }
const action = {
type: PLAYER_SYNC_QUEUE,
data: { audioInfo: {}, audioLists: stateNoPending.queue },
}
const result = playerReducer(stateNoPending, action)
expect(result.playIndex).toBeUndefined()
expect(result.clear).toBe(false)
})
it('CURRENT for old track preserves pending playIndex', () => {
// After SYNC_QUEUE, queue has new UUIDs. The old track's UUID (zzz)
// is at index 2, but playIndex is 0. This is a premature callback.
const stateAfterSync = {
...stateAfterPlayTracks,
queue: [
{ trackId: 's1', uuid: 'xxx', name: 'Song 1' },
{ trackId: 's2', uuid: 'yyy', name: 'Song 2' },
{ trackId: 's3', uuid: 'zzz', name: 'Song 3' },
],
}
const action = {
type: PLAYER_CURRENT,
data: { uuid: 'zzz', name: 'Song 3', volume: 1 },
}
const result = playerReducer(stateAfterSync, action)
expect(result.playIndex).toBe(0)
expect(result.clear).toBe(true)
expect(result.savedPlayIndex).toBe(2) // preserved from before
})
it('CURRENT for correct track consumes pending playIndex', () => {
const stateAfterSync = {
...stateAfterPlayTracks,
queue: [
{ trackId: 's1', uuid: 'xxx', name: 'Song 1' },
{ trackId: 's2', uuid: 'yyy', name: 'Song 2' },
{ trackId: 's3', uuid: 'zzz', name: 'Song 3' },
],
}
// Player switched to Song 1 (uuid 'xxx', index 0 == playIndex)
const action = {
type: PLAYER_CURRENT,
data: { uuid: 'xxx', name: 'Song 1', volume: 1 },
}
const result = playerReducer(stateAfterSync, action)
expect(result.playIndex).toBeUndefined()
expect(result.clear).toBe(false)
expect(result.savedPlayIndex).toBe(0)
expect(result.current.name).toBe('Song 1')
})
})
describe('PLAYER_REFRESH_QUEUE', () => {
it('clamps negative savedPlayIndex to 0', () => {
const state = {