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

View File

@@ -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)?",

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

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()
})
})

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}

View File

@@ -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)?",

View File

@@ -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,
}

View File

@@ -3,3 +3,4 @@ export * from './intersperse'
export * from './notifications'
export * from './openInNewTab'
export * from './urls'
export * from './playSimilar'

View 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))
}