diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx index 4812141ab..664624900 100644 --- a/ui/src/audioplayer/PlayerToolbar.jsx +++ b/ui/src/audioplayer/PlayerToolbar.jsx @@ -5,10 +5,11 @@ import { GlobalHotKeys } from 'react-hotkeys' import IconButton from '@material-ui/core/IconButton' import { useMediaQuery } from '@material-ui/core' import { RiSaveLine } from 'react-icons/ri' -import { LoveButton, useToggleLove } from '../common' +import { LoveButton, RatingField, useToggleLove } from '../common' import { openSaveQueueDialog } from '../actions' import { keyMap } from '../hotkeys' import { makeStyles } from '@material-ui/core/styles' +import config from '../config' const useStyles = makeStyles((theme) => ({ toolbar: { @@ -53,6 +54,10 @@ const useStyles = makeStyles((theme) => ({ display: 'flex', alignItems: 'center', }, + rating: { + display: 'flex', + alignItems: 'center', + }, })) const PlayerToolbar = ({ id, isRadio }) => { @@ -99,16 +104,34 @@ const PlayerToolbar = ({ id, isRadio }) => { /> ) + // Inline star rating for the currently-playing track. Only shown when star + // rating is enabled and we have a real (non-radio) song loaded. `alwaysVisible` + // keeps the empty stars on screen so an unrated track can be rated in place. + const ratingControl = + config.enableStarRating && !isRadio && !!id && data ? ( + + ) : null + return ( <> {isDesktop ? (
  • + {ratingControl} {saveQueueButton} {loveButton}
  • ) : ( <> + {ratingControl && ( +
  • {ratingControl}
  • + )}
  • {saveQueueButton}
  • {loveButton}
  • diff --git a/ui/src/audioplayer/PlayerToolbar.test.jsx b/ui/src/audioplayer/PlayerToolbar.test.jsx index d0368b0f0..2569c9d91 100644 --- a/ui/src/audioplayer/PlayerToolbar.test.jsx +++ b/ui/src/audioplayer/PlayerToolbar.test.jsx @@ -30,6 +30,11 @@ vi.mock('../common', () => ({ Love ), + RatingField: ({ className }) => ( + + Rating + + ), useToggleLove: vi.fn(), })) @@ -61,14 +66,15 @@ describe('', () => { useMediaQuery.mockReturnValue(true) // isDesktop = true }) - it('renders desktop toolbar with both buttons', () => { + it('renders desktop toolbar with rating and buttons', () => { render() - // Both buttons should be in a single list item + // Rating + both buttons should be in a single list item const listItems = screen.getAllByRole('listitem') expect(listItems).toHaveLength(1) - // Verify both buttons are rendered + // Verify rating and both buttons are rendered + expect(screen.getByTestId('rating-field')).toBeInTheDocument() expect(screen.getByTestId('save-queue-button')).toBeInTheDocument() expect(screen.getByTestId('love-button')).toBeInTheDocument() @@ -76,6 +82,13 @@ describe('', () => { expect(listItems[0].className).toContain('toolbar') }) + it('hides the rating for radio streams', () => { + render() + + expect(screen.queryByTestId('rating-field')).not.toBeInTheDocument() + expect(screen.getByTestId('love-button')).toBeInTheDocument() + }) + it('disables save queue button when isRadio is true', () => { render() @@ -109,20 +122,22 @@ describe('', () => { useMediaQuery.mockReturnValue(false) // isDesktop = false }) - it('renders mobile toolbar with buttons in separate list items', () => { + it('renders mobile toolbar with rating and buttons in separate list items', () => { render() - // Each button should be in its own list item + // Rating + each button should be in its own list item const listItems = screen.getAllByRole('listitem') - expect(listItems).toHaveLength(2) + expect(listItems).toHaveLength(3) - // Verify both buttons are rendered + // Verify rating and both buttons are rendered + expect(screen.getByTestId('rating-field')).toBeInTheDocument() expect(screen.getByTestId('save-queue-button')).toBeInTheDocument() expect(screen.getByTestId('love-button')).toBeInTheDocument() // Verify mobile classes are applied expect(listItems[0].className).toContain('mobileListItem') expect(listItems[1].className).toContain('mobileListItem') + expect(listItems[2].className).toContain('mobileListItem') }) it('disables save queue button when isRadio is true', () => { diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.jsx index f92b0d948..4306ae0bd 100644 --- a/ui/src/common/RatingField.jsx +++ b/ui/src/common/RatingField.jsx @@ -27,6 +27,7 @@ export const RatingField = ({ className, size, color, + alwaysVisible, ...rest }) => { const record = useRecordContext(rest) || {} @@ -59,7 +60,7 @@ export const RatingField = ({ className={clsx( className, classes.rating, - rating > 0 ? classes.show : classes.hide, + rating > 0 || alwaysVisible ? classes.show : classes.hide, )} value={rating} size={size} @@ -75,10 +76,15 @@ RatingField.propTypes = { record: PropTypes.object, visible: PropTypes.bool, size: PropTypes.string, + // When true, the empty stars are always shown (not just on hover / when + // already rated). Used by the player toolbar so the current track can be + // rated directly. + alwaysVisible: PropTypes.bool, } RatingField.defaultProps = { visible: true, size: 'small', color: 'inherit', + alwaysVisible: false, }