Files
navidrome/ui/src/reducers/playerReducer.js
T
Deluan e08d4bef16 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.
2026-03-09 12:44:19 -04:00

252 lines
6.2 KiB
JavaScript

import { v4 as uuidv4 } from 'uuid'
import subsonic from '../subsonic'
import { decisionService } from '../transcode'
import {
PLAYER_ADD_TRACKS,
PLAYER_CLEAR_QUEUE,
PLAYER_CURRENT,
PLAYER_PLAY_NEXT,
PLAYER_PLAY_TRACKS,
PLAYER_SET_TRACK,
PLAYER_SET_VOLUME,
PLAYER_SYNC_QUEUE,
PLAYER_SET_MODE,
PLAYER_REFRESH_QUEUE,
} from '../actions'
import config from '../config'
const initialState = {
queue: [],
current: {},
clear: false,
volume: config.defaultUIVolume / 100,
savedPlayIndex: 0,
}
const pad = (value) => {
const str = value.toString()
if (str.length === 1) {
return `0${str}`
} else {
return str
}
}
const makeMusicSrc = (trackId) =>
decisionService.getProfile()
? () =>
decisionService
.resolveStreamUrl(trackId)
.catch(() => subsonic.streamUrl(trackId))
: subsonic.streamUrl(trackId)
const mapToAudioLists = (item) => {
// If item comes from a playlist, trackId is mediaFileId
const trackId = item.mediaFileId || item.id
if (item.isRadio) {
return {
trackId,
uuid: uuidv4(),
name: item.name,
song: item,
musicSrc: item.streamUrl,
cover: item.cover,
isRadio: true,
}
}
const { lyrics } = item
let lyricText = ''
if (lyrics) {
const structured = JSON.parse(lyrics)
for (const structuredLyric of structured) {
if (structuredLyric.synced) {
for (const line of structuredLyric.line) {
let time = Math.floor(line.start / 10)
const ms = time % 100
time = Math.floor(time / 100)
const sec = time % 60
time = Math.floor(time / 60)
const min = time % 60
ms.toString()
lyricText += `[${pad(min)}:${pad(sec)}.${pad(ms)}] ${line.value}\n`
}
}
}
}
return {
trackId,
uuid: uuidv4(),
song: item,
name: item.title,
lyric: lyricText,
singer: item.artist,
duration: item.duration,
musicSrc: makeMusicSrc(trackId),
cover: subsonic.getCoverArtUrl(
{
id: trackId,
updatedAt: item.updatedAt,
album: item.album,
},
300,
),
}
}
const reduceClearQueue = () => ({ ...initialState, clear: true })
const reducePlayTracks = (state, { data, id }) => {
let playIndex = 0
const queue = Object.keys(data).map((key, idx) => {
if (key === id) {
playIndex = idx
}
return mapToAudioLists(data[key])
})
return {
...state,
queue,
playIndex,
clear: true,
}
}
const reduceSetTrack = (state, { data }) => {
return {
...state,
queue: [mapToAudioLists(data)],
playIndex: 0,
clear: true,
}
}
const reduceAddTracks = (state, { data }) => {
const queue = state.queue
Object.keys(data).forEach((id) => {
queue.push(mapToAudioLists(data[id]))
})
return { ...state, queue, clear: false }
}
const reducePlayNext = (state, { data }) => {
const newQueue = []
const current = state.current || {}
let foundPos = false
let currentIndex = 0
state.queue.forEach((item) => {
newQueue.push(item)
if (item.uuid === current.uuid) {
foundPos = true
currentIndex = newQueue.length - 1
Object.keys(data).forEach((id) => {
newQueue.push(mapToAudioLists(data[id]))
})
}
})
if (!foundPos) {
Object.keys(data).forEach((id) => {
newQueue.push(mapToAudioLists(data[id]))
})
}
return {
...state,
queue: newQueue,
playIndex: foundPos ? currentIndex : undefined,
clear: true,
}
}
const reduceSetVolume = (state, { data: { volume } }) => {
return {
...state,
volume,
}
}
const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => {
return {
...state,
queue: audioLists,
// 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,
}
}
const reduceCurrent = (state, { data }) => {
const current = data.ended ? {} : 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: pending ? state.playIndex : undefined,
clear: pending ? state.clear : false,
savedPlayIndex: pending ? state.savedPlayIndex : savedPlayIndex,
volume: data.volume,
}
}
const reduceMode = (state, { data: { mode } }) => {
return {
...state,
mode,
}
}
export const playerReducer = (previousState = initialState, payload) => {
const { type } = payload
switch (type) {
case PLAYER_CLEAR_QUEUE:
return reduceClearQueue()
case PLAYER_PLAY_TRACKS:
return reducePlayTracks(previousState, payload)
case PLAYER_SET_TRACK:
return reduceSetTrack(previousState, payload)
case PLAYER_ADD_TRACKS:
return reduceAddTracks(previousState, payload)
case PLAYER_PLAY_NEXT:
return reducePlayNext(previousState, payload)
case PLAYER_SET_VOLUME:
return reduceSetVolume(previousState, payload)
case PLAYER_SYNC_QUEUE:
return reduceSyncQueue(previousState, payload)
case PLAYER_CURRENT:
return reduceCurrent(previousState, payload)
case PLAYER_SET_MODE:
return reduceMode(previousState, payload)
case PLAYER_REFRESH_QUEUE: {
const resolvedUrls = payload.data || {}
return {
...previousState,
queue: previousState.queue.map((item) => ({
...item,
musicSrc: item.isRadio
? item.musicSrc
: resolvedUrls[item.trackId] || subsonic.streamUrl(item.trackId),
})),
clear: true,
autoPlay: false,
playIndex:
previousState.savedPlayIndex >= 0 ? previousState.savedPlayIndex : 0,
}
}
default:
return previousState
}
}