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