Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
dc3bfc5bca feat(ui): add Instant Mix button to player toolbar
Add an Instant Mix button to the now-playing player bar that fetches
similar songs and queues them after the current track without restarting
playback. Uses the existing getSimilarSongs2 API with playNext to insert
songs after the currently playing track.

Closes discussion #5097

https://claude.ai/code/session_011zb723ppbEHbXwSVSeRRsc
2026-02-25 16:47:18 +00:00
3 changed files with 122 additions and 10 deletions

View File

@@ -1,12 +1,15 @@
import React, { useCallback } from 'react'
import React, { useCallback, useState } from 'react'
import { useDispatch } from 'react-redux'
import { useGetOne } from 'react-admin'
import { useGetOne, useNotify } from 'react-admin'
import { GlobalHotKeys } from 'react-hotkeys'
import IconButton from '@material-ui/core/IconButton'
import { useMediaQuery } from '@material-ui/core'
import { CircularProgress, useMediaQuery } from '@material-ui/core'
import { RiSaveLine } from 'react-icons/ri'
import { IoIosRadio } from 'react-icons/io'
import { LoveButton, useToggleLove } from '../common'
import { openSaveQueueDialog } from '../actions'
import { addSimilarToQueue } from '../common/playbackActions'
import config from '../config'
import { keyMap } from '../hotkeys'
import { makeStyles } from '@material-ui/core/styles'
@@ -57,10 +60,12 @@ const useStyles = makeStyles((theme) => ({
const PlayerToolbar = ({ id, isRadio }) => {
const dispatch = useDispatch()
const notify = useNotify()
const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio })
const [toggleLove, toggling] = useToggleLove('song', data)
const isDesktop = useMediaQuery('(min-width:810px)')
const classes = useStyles()
const [mixLoading, setMixLoading] = useState(false)
const handlers = {
TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]),
@@ -74,9 +79,43 @@ const PlayerToolbar = ({ id, isRadio }) => {
[dispatch],
)
const handleInstantMix = useCallback(
async (e) => {
e.stopPropagation()
setMixLoading(true)
notify('message.startingInstantMix', { type: 'info' })
try {
await addSimilarToQueue(dispatch, notify, id)
} catch (err) {
// eslint-disable-next-line no-console
console.error('Error starting instant mix:', err)
notify('ra.page.error', { type: 'warning' })
} finally {
setMixLoading(false)
}
},
[dispatch, notify, id],
)
const buttonClass = isDesktop ? classes.button : classes.mobileButton
const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem
const instantMixButton = config.enableExternalServices && (
<IconButton
size={isDesktop ? 'small' : undefined}
onClick={handleInstantMix}
disabled={isRadio || !id || loading || mixLoading}
data-testid="instant-mix-button"
className={buttonClass}
>
{mixLoading ? (
<CircularProgress size={isDesktop ? 20 : 18} />
) : (
<IoIosRadio className={!isDesktop ? classes.mobileIcon : undefined} />
)}
</IconButton>
)
const saveQueueButton = (
<IconButton
size={isDesktop ? 'small' : undefined}
@@ -104,11 +143,13 @@ const PlayerToolbar = ({ id, isRadio }) => {
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
{isDesktop ? (
<li className={`${listItemClass} item`}>
{instantMixButton}
{saveQueueButton}
{loveButton}
</li>
) : (
<>
<li className={`${listItemClass} item`}>{instantMixButton}</li>
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
<li className={`${listItemClass} item`}>{loveButton}</li>
</>

View File

@@ -1,10 +1,11 @@
import React from 'react'
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
import { useMediaQuery } from '@material-ui/core'
import { useGetOne } from 'react-admin'
import { useGetOne, useNotify } from 'react-admin'
import { useDispatch } from 'react-redux'
import { useToggleLove } from '../common'
import { openSaveQueueDialog } from '../actions'
import { addSimilarToQueue } from '../common/playbackActions'
import PlayerToolbar from './PlayerToolbar'
// Mock dependencies
@@ -18,6 +19,7 @@ vi.mock('@material-ui/core', async () => {
vi.mock('react-admin', () => ({
useGetOne: vi.fn(),
useNotify: vi.fn(),
}))
vi.mock('react-redux', () => ({
@@ -41,9 +43,20 @@ vi.mock('react-hotkeys', () => ({
GlobalHotKeys: () => <div data-testid="global-hotkeys" />,
}))
vi.mock('../common/playbackActions', () => ({
addSimilarToQueue: vi.fn(),
}))
vi.mock('../config', () => ({
default: {
enableExternalServices: true,
},
}))
describe('<PlayerToolbar />', () => {
const mockToggleLove = vi.fn()
const mockDispatch = vi.fn()
const mockNotify = vi.fn()
const mockSongData = { id: 'song-1', name: 'Test Song', starred: false }
beforeEach(() => {
@@ -51,6 +64,7 @@ describe('<PlayerToolbar />', () => {
useGetOne.mockReturnValue({ data: mockSongData, loading: false })
useToggleLove.mockReturnValue([mockToggleLove, false])
useDispatch.mockReturnValue(mockDispatch)
useNotify.mockReturnValue(mockNotify)
openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' })
})
@@ -61,14 +75,15 @@ describe('<PlayerToolbar />', () => {
useMediaQuery.mockReturnValue(true) // isDesktop = true
})
it('renders desktop toolbar with both buttons', () => {
it('renders desktop toolbar with all buttons', () => {
render(<PlayerToolbar id="song-1" />)
// Both buttons should be in a single list item
// All buttons should be in a single list item
const listItems = screen.getAllByRole('listitem')
expect(listItems).toHaveLength(1)
// Verify both buttons are rendered
// Verify all buttons are rendered
expect(screen.getByTestId('instant-mix-button')).toBeInTheDocument()
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
expect(screen.getByTestId('love-button')).toBeInTheDocument()
@@ -114,15 +129,17 @@ describe('<PlayerToolbar />', () => {
// Each button should be in its own list item
const listItems = screen.getAllByRole('listitem')
expect(listItems).toHaveLength(2)
expect(listItems).toHaveLength(3)
// Verify both buttons are rendered
// Verify all buttons are rendered
expect(screen.getByTestId('instant-mix-button')).toBeInTheDocument()
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
expect(screen.getByTestId('love-button')).toBeInTheDocument()
// Verify mobile classes are applied
expect(listItems[0].className).toContain('mobileListItem')
expect(listItems[1].className).toContain('mobileListItem')
expect(listItems[2].className).toContain('mobileListItem')
})
it('disables save queue button when isRadio is true', () => {
@@ -142,6 +159,36 @@ describe('<PlayerToolbar />', () => {
})
})
describe('Instant Mix button', () => {
beforeEach(() => {
useMediaQuery.mockReturnValue(true)
})
it('disables instant mix button when isRadio is true', () => {
render(<PlayerToolbar id="song-1" isRadio={true} />)
const instantMixButton = screen.getByTestId('instant-mix-button')
expect(instantMixButton).toBeDisabled()
})
it('calls addSimilarToQueue when clicked', async () => {
addSimilarToQueue.mockResolvedValue()
render(<PlayerToolbar id="song-1" />)
const instantMixButton = screen.getByTestId('instant-mix-button')
fireEvent.click(instantMixButton)
expect(mockNotify).toHaveBeenCalledWith('message.startingInstantMix', {
type: 'info',
})
expect(addSimilarToQueue).toHaveBeenCalledWith(
mockDispatch,
mockNotify,
'song-1',
)
})
})
describe('Common behavior', () => {
it('renders global hotkeys in both layouts', () => {
// Test desktop layout

View File

@@ -1,5 +1,5 @@
import subsonic from '../subsonic/index.js'
import { playTracks } from '../actions/index.js'
import { playTracks, playNext } from '../actions/index.js'
const shuffleArray = (array) => {
const shuffled = [...array]
@@ -74,3 +74,27 @@ export const playSimilar = async (dispatch, notify, id, options = {}) => {
dispatch(playTracks(songData, ids))
}
}
export const addSimilarToQueue = async (dispatch, notify, id) => {
const res = await subsonic.getSimilarSongs2(id, 100)
const data = res.json['subsonic-response']
if (data.status !== 'ok') {
throw new Error(
`Error fetching similar songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`,
)
}
const songs = data.similarSongs2?.song || []
if (!songs.length) {
notify('message.noSimilarSongsFound', 'warning')
return
}
const { songData, ids } = processSongsForPlayback(songs)
// Remove the currently playing song from the results
const filteredIds = ids.filter((songId) => songId !== id)
dispatch(playNext(songData, filteredIds))
}