mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-27 04:16:03 -05:00
Compare commits
1 Commits
feat/plugi
...
claude/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc3bfc5bca |
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user