From 8fbe8e2864e4e160bf0aedd743fb354cc8fc9456 Mon Sep 17 00:00:00 2001 From: realrossmanngroup Date: Fri, 5 Jun 2026 19:46:27 -0500 Subject: [PATCH] feat(ui): rate the current track from the player toolbar - #5569 Add an inline star rating control to the player toolbar, next to the Love button. It is shown only when EnableStarRating is enabled and is hidden for radio streams, and reflects/updates the current track's rating live. Adds an `alwaysVisible` prop to RatingField so the empty stars are shown for unrated tracks (the list views only reveal them on row hover). Signed-off-by: realrossmanngroup --- ui/src/audioplayer/PlayerToolbar.jsx | 25 ++++++++++++++++++- ui/src/audioplayer/PlayerToolbar.test.jsx | 29 +++++++++++++++++------ ui/src/common/RatingField.jsx | 8 ++++++- 3 files changed, 53 insertions(+), 9 deletions(-) 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, }