feat(ui): Add Artist Radio and Shuffle options (#4186)

* Add Play Similar option

* Add pt-br translation for Play Similar

* Refactor playSimilar and add helper

* Improve Play Similar feedback

* Add artist actions bar with shuffle and radio

* Add Play Similar menu and align artist actions

* Refine artist actions and revert menu option

* fix(ui): enhance layout of ArtistActions and ArtistShow components

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(i18n): revert unused changes

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): improve layout for mobile

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): improve error handling for fetching similar songs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): enhance error logging for fetching songs in shuffle

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(ui): shuffle handling to use async/await for better readability

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(ui): simplify button label handling in ArtistActions component

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-06-09 17:06:10 -04:00
committed by GitHub
parent 7928adb3d1
commit 5882889a80
8 changed files with 278 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useDispatch } from 'react-redux'
import { useMediaQuery } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import {
Button,
TopToolbar,
sanitizeListRestProps,
useDataProvider,
useNotify,
useTranslate,
} from 'react-admin'
import ShuffleIcon from '@material-ui/icons/Shuffle'
import { IoIosRadio } from 'react-icons/io'
import { playTracks } from '../actions'
import { playSimilar } from '../utils'
const useStyles = makeStyles((theme) => ({
toolbar: {
minHeight: 'auto',
padding: '0 !important',
background: 'transparent',
boxShadow: 'none',
'& .MuiToolbar-root': {
minHeight: 'auto',
padding: '0 !important',
background: 'transparent',
},
},
button: {
[theme.breakpoints.down('xs')]: {
minWidth: 'auto',
padding: '8px 12px',
fontSize: '0.75rem',
'& .MuiButton-startIcon': {
marginRight: '4px',
},
},
},
radioIcon: {
[theme.breakpoints.down('xs')]: {
fontSize: '1.5rem',
},
},
}))
const ArtistActions = ({ className, record, ...rest }) => {
const dispatch = useDispatch()
const translate = useTranslate()
const dataProvider = useDataProvider()
const notify = useNotify()
const classes = useStyles()
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const handleShuffle = React.useCallback(async () => {
try {
const res = await dataProvider.getList('song', {
pagination: { page: 1, perPage: 500 },
sort: { field: 'random', order: 'ASC' },
filter: { album_artist_id: record.id, missing: false },
})
const data = {}
const ids = []
res.data.forEach((s) => {
data[s.id] = s
ids.push(s.id)
})
dispatch(playTracks(data, ids))
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error fetching songs for shuffle:', e)
notify('ra.page.error', 'warning')
}
}, [dataProvider, dispatch, record, notify])
const handleRadio = React.useCallback(async () => {
try {
await playSimilar(dispatch, notify, record.id)
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error starting radio for artist:', e)
notify('ra.page.error', 'warning')
}
}, [dispatch, notify, record])
return (
<TopToolbar
className={`${className} ${classes.toolbar}`}
{...sanitizeListRestProps(rest)}
>
<Button
onClick={handleShuffle}
label={translate('resources.artist.actions.shuffle')}
className={classes.button}
size={isMobile ? 'small' : 'medium'}
>
<ShuffleIcon />
</Button>
<Button
onClick={handleRadio}
label={translate('resources.artist.actions.radio')}
className={classes.button}
size={isMobile ? 'small' : 'medium'}
>
<IoIosRadio className={classes.radioIcon} />
</Button>
</TopToolbar>
)
}
ArtistActions.propTypes = {
className: PropTypes.string,
record: PropTypes.object.isRequired,
}
ArtistActions.defaultProps = {
className: '',
}
export default ArtistActions
+79
View File
@@ -0,0 +1,79 @@
import React from 'react'
import { render, fireEvent, waitFor, screen } from '@testing-library/react'
import { TestContext } from 'ra-test'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import ArtistActions from './ArtistActions'
import subsonic from '../subsonic'
import { ThemeProvider, createMuiTheme } from '@material-ui/core/styles'
const mockDispatch = vi.fn()
vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch }))
vi.mock('../subsonic', () => ({
default: { getSimilarSongs2: vi.fn() },
}))
const mockNotify = vi.fn()
const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] })
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useNotify: () => mockNotify,
useDataProvider: () => ({ getList: mockGetList }),
useTranslate: () => (x) => x,
}
})
describe('ArtistActions', () => {
beforeEach(() => {
vi.clearAllMocks()
subsonic.getSimilarSongs2.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
similarSongs2: { song: [{ id: 'rec1' }] },
},
},
})
})
it('shuffles songs when Shuffle is clicked', async () => {
const theme = createMuiTheme()
render(
<TestContext>
<ThemeProvider theme={theme}>
<ArtistActions record={{ id: 'ar1' }} />
</ThemeProvider>
</TestContext>,
)
fireEvent.click(screen.getByText('resources.artist.actions.shuffle'))
await waitFor(() =>
expect(mockGetList).toHaveBeenCalledWith('song', {
pagination: { page: 1, perPage: 500 },
sort: { field: 'random', order: 'ASC' },
filter: { album_artist_id: 'ar1', missing: false },
}),
)
expect(mockDispatch).toHaveBeenCalled()
})
it('starts radio when Radio is clicked', async () => {
const theme = createMuiTheme()
render(
<TestContext>
<ThemeProvider theme={theme}>
<ArtistActions record={{ id: 'ar1' }} />
</ThemeProvider>
</TestContext>,
)
fireEvent.click(screen.getByText('resources.artist.actions.radio'))
await waitFor(() =>
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100),
)
expect(mockDispatch).toHaveBeenCalled()
})
})
+34
View File
@@ -14,6 +14,34 @@ import AlbumGridView from '../album/AlbumGridView'
import MobileArtistDetails from './MobileArtistDetails'
import DesktopArtistDetails from './DesktopArtistDetails'
import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js'
import ArtistActions from './ArtistActions'
import { makeStyles } from '@material-ui/core'
const useStyles = makeStyles((theme) => ({
actions: {
width: '100%',
justifyContent: 'flex-start',
display: 'flex',
paddingTop: '0.25em',
paddingBottom: '0.25em',
paddingLeft: '1em',
paddingRight: '1em',
flexWrap: 'wrap',
overflowX: 'auto',
[theme.breakpoints.down('xs')]: {
paddingLeft: '0.5em',
paddingRight: '0.5em',
gap: '0.5em',
justifyContent: 'space-around',
},
},
actionsContainer: {
paddingLeft: '.75rem',
[theme.breakpoints.down('xs')]: {
padding: '.5rem',
},
},
}))
const ArtistDetails = (props) => {
const record = useRecordContext(props)
@@ -56,6 +84,7 @@ const ArtistShowLayout = (props) => {
const record = useRecordContext()
const { width } = props
const [, perPageOptions] = useAlbumsPerPage(width)
const classes = useStyles()
useResourceRefresh('artist', 'album')
const maxPerPage = 90
@@ -79,6 +108,11 @@ const ArtistShowLayout = (props) => {
<>
{record && <RaTitle title={<Title subTitle={record.name} />} />}
{record && <ArtistDetails />}
{record && (
<div className={classes.actionsContainer}>
<ArtistActions record={record} className={classes.actions} />
</div>
)}
{record && (
<ReferenceManyField
{...showContext}