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 <youtube@rossmanngroup.com>
This commit is contained in:
realrossmanngroup 2026-06-05 19:46:27 -05:00
parent 29c123854c
commit 8fbe8e2864
3 changed files with 53 additions and 9 deletions

View File

@ -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 ? (
<RatingField
record={data}
resource={'song'}
size={'small'}
alwaysVisible
className={classes.rating}
/>
) : null
return (
<>
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
{isDesktop ? (
<li className={`${listItemClass} item`}>
{ratingControl}
{saveQueueButton}
{loveButton}
</li>
) : (
<>
{ratingControl && (
<li className={`${listItemClass} item`}>{ratingControl}</li>
)}
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
<li className={`${listItemClass} item`}>{loveButton}</li>
</>

View File

@ -30,6 +30,11 @@ vi.mock('../common', () => ({
Love
</button>
),
RatingField: ({ className }) => (
<span data-testid="rating-field" className={className}>
Rating
</span>
),
useToggleLove: vi.fn(),
}))
@ -61,14 +66,15 @@ describe('<PlayerToolbar />', () => {
useMediaQuery.mockReturnValue(true) // isDesktop = true
})
it('renders desktop toolbar with both buttons', () => {
it('renders desktop toolbar with rating and buttons', () => {
render(<PlayerToolbar id="song-1" />)
// 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('<PlayerToolbar />', () => {
expect(listItems[0].className).toContain('toolbar')
})
it('hides the rating for radio streams', () => {
render(<PlayerToolbar id="song-1" isRadio={true} />)
expect(screen.queryByTestId('rating-field')).not.toBeInTheDocument()
expect(screen.getByTestId('love-button')).toBeInTheDocument()
})
it('disables save queue button when isRadio is true', () => {
render(<PlayerToolbar id="song-1" isRadio={true} />)
@ -109,20 +122,22 @@ describe('<PlayerToolbar />', () => {
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(<PlayerToolbar id="song-1" />)
// 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', () => {

View File

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