From 5882889a80af6872adb2aec855d376e3de32dfdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 9 Jun 2025 17:06:10 -0400 Subject: [PATCH] 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 * fix(i18n): revert unused changes Signed-off-by: Deluan * fix(ui): improve layout for mobile Signed-off-by: Deluan * fix(ui): improve error handling for fetching similar songs Signed-off-by: Deluan * fix(ui): enhance error logging for fetching songs in shuffle Signed-off-by: Deluan * refactor(ui): shuffle handling to use async/await for better readability Signed-off-by: Deluan * refactor(ui): simplify button label handling in ArtistActions component Signed-off-by: Deluan --------- Signed-off-by: Deluan --- resources/i18n/pt-br.json | 5 ++ ui/src/artist/ArtistActions.jsx | 122 +++++++++++++++++++++++++++ ui/src/artist/ArtistActions.test.jsx | 79 +++++++++++++++++ ui/src/artist/ArtistShow.jsx | 34 ++++++++ ui/src/i18n/en.json | 5 ++ ui/src/subsonic/index.js | 5 ++ ui/src/utils/index.js | 1 + ui/src/utils/playSimilar.js | 27 ++++++ 8 files changed, 278 insertions(+) create mode 100644 ui/src/artist/ArtistActions.jsx create mode 100644 ui/src/artist/ArtistActions.test.jsx create mode 100644 ui/src/utils/playSimilar.js diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index cfb3c8485..e105f1349 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -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)?", diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx new file mode 100644 index 000000000..33b9732eb --- /dev/null +++ b/ui/src/artist/ArtistActions.jsx @@ -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 ( + + + + + ) +} + +ArtistActions.propTypes = { + className: PropTypes.string, + record: PropTypes.object.isRequired, +} + +ArtistActions.defaultProps = { + className: '', +} + +export default ArtistActions diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx new file mode 100644 index 000000000..2d9768971 --- /dev/null +++ b/ui/src/artist/ArtistActions.test.jsx @@ -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( + + + + + , + ) + + 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( + + + + + , + ) + + fireEvent.click(screen.getByText('resources.artist.actions.radio')) + await waitFor(() => + expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100), + ) + expect(mockDispatch).toHaveBeenCalled() + }) +}) diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index e8e03f52e..c7b51780b 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -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 && } />} {record && } + {record && ( +
+ +
+ )} {record && ( { 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, } diff --git a/ui/src/utils/index.js b/ui/src/utils/index.js index 779b6f886..40470b01e 100644 --- a/ui/src/utils/index.js +++ b/ui/src/utils/index.js @@ -3,3 +3,4 @@ export * from './intersperse' export * from './notifications' export * from './openInNewTab' export * from './urls' +export * from './playSimilar' diff --git a/ui/src/utils/playSimilar.js b/ui/src/utils/playSimilar.js new file mode 100644 index 000000000..a4d7554fe --- /dev/null +++ b/ui/src/utils/playSimilar.js @@ -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)) +}