mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
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:
@@ -124,6 +124,10 @@
|
||||
"remixer": "Remixador |||| Remixadores",
|
||||
"djmixer": "DJ Mixer |||| DJ Mixers",
|
||||
"performer": "Músico |||| Músicos"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "Aleatório",
|
||||
"radio": "Rádio"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -407,6 +411,7 @@
|
||||
"transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}",
|
||||
"transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão",
|
||||
"songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist",
|
||||
"noSimilarSongsFound": "Nenhuma música semelhante encontrada",
|
||||
"noPlaylistsAvailable": "Nenhuma playlist",
|
||||
"delete_user_title": "Excluir usuário '%{name}'",
|
||||
"delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?",
|
||||
|
||||
122
ui/src/artist/ArtistActions.jsx
Normal file
122
ui/src/artist/ArtistActions.jsx
Normal 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
ui/src/artist/ArtistActions.test.jsx
Normal file
79
ui/src/artist/ArtistActions.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -125,6 +125,10 @@
|
||||
"remixer": "Remixer |||| Remixers",
|
||||
"djmixer": "DJ Mixer |||| DJ Mixers",
|
||||
"performer": "Performer |||| Performers"
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "Shuffle",
|
||||
"radio": "Radio"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -410,6 +414,7 @@
|
||||
"transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.",
|
||||
"transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.",
|
||||
"songsAddedToPlaylist": "Added 1 song to playlist |||| Added %{smart_count} songs to playlist",
|
||||
"noSimilarSongsFound": "No similar songs found",
|
||||
"noPlaylistsAvailable": "None available",
|
||||
"delete_user_title": "Delete user '%{name}'",
|
||||
"delete_user_content": "Are you sure you want to delete this user and all their data (including playlists and preferences)?",
|
||||
|
||||
@@ -82,6 +82,10 @@ const getAlbumInfo = (id) => {
|
||||
return httpClient(url('getAlbumInfo', id))
|
||||
}
|
||||
|
||||
const getSimilarSongs2 = (id, count = 100) => {
|
||||
return httpClient(url('getSimilarSongs2', id, { count }))
|
||||
}
|
||||
|
||||
const streamUrl = (id, options) => {
|
||||
return baseUrl(
|
||||
url('stream', id, {
|
||||
@@ -106,4 +110,5 @@ export default {
|
||||
streamUrl,
|
||||
getAlbumInfo,
|
||||
getArtistInfo,
|
||||
getSimilarSongs2,
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './intersperse'
|
||||
export * from './notifications'
|
||||
export * from './openInNewTab'
|
||||
export * from './urls'
|
||||
export * from './playSimilar'
|
||||
|
||||
27
ui/src/utils/playSimilar.js
Normal file
27
ui/src/utils/playSimilar.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import subsonic from '../subsonic'
|
||||
import { playTracks } from '../actions'
|
||||
|
||||
export const playSimilar = 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 = {}
|
||||
const ids = []
|
||||
songs.forEach((s) => {
|
||||
songData[s.id] = s
|
||||
ids.push(s.id)
|
||||
})
|
||||
dispatch(playTracks(songData, ids))
|
||||
}
|
||||
Reference in New Issue
Block a user