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:
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user