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