feat(ui): add Now Playing panel for admins (#4209)

* feat(ui): add Now Playing panel and integrate now playing count updates

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: check return value in test to satisfy linter

* fix: format React code with prettier

* fix: resolve race condition in play tracker test

* fix: log error when fetching now playing data fails

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): refactor Now Playing panel with new components and error handling

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): adjust padding and height in Now Playing panel for improved layout

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(cache): add automatic cleanup to prevent goroutine leak on cache garbage collection

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-06-10 17:22:13 -04:00
committed by GitHub
parent a65140b965
commit 76042ba173
16 changed files with 744 additions and 3 deletions
+6
View File
@@ -1,6 +1,7 @@
export const EVENT_SCAN_STATUS = 'scanStatus'
export const EVENT_SERVER_START = 'serverStart'
export const EVENT_REFRESH_RESOURCE = 'refreshResource'
export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount'
export const processEvent = (type, data) => ({
type,
@@ -11,6 +12,11 @@ export const scanStatusUpdate = (data) => ({
data: data,
})
export const nowPlayingCountUpdate = (data) => ({
type: EVENT_NOW_PLAYING_COUNT,
data: data,
})
export const serverDown = () => ({
type: EVENT_SERVER_START,
data: {},
+1
View File
@@ -33,6 +33,7 @@ const startEventStream = async (dispatchFn) => {
throttledEventHandler(dispatchFn),
)
newStream.addEventListener('refreshResource', eventHandler(dispatchFn))
newStream.addEventListener('nowPlayingCount', eventHandler(dispatchFn))
newStream.addEventListener('keepAlive', eventHandler(dispatchFn))
newStream.onerror = (e) => {
// eslint-disable-next-line no-console
+5
View File
@@ -540,6 +540,11 @@
"status": "Scan Error",
"elapsedTime": "Elapsed Time"
},
"nowPlaying": {
"title": "Now Playing",
"empty": "Nothing playing",
"minutesAgo": "%{smart_count} minute ago |||| %{smart_count} minutes ago"
},
"help": {
"title": "Navidrome Hotkeys",
"hotkeys": {
+4
View File
@@ -14,6 +14,7 @@ import { Dialogs } from '../dialogs/Dialogs'
import { AboutDialog } from '../dialogs'
import PersonalMenu from './PersonalMenu'
import ActivityPanel from './ActivityPanel'
import NowPlayingPanel from './NowPlayingPanel'
import UserMenu from './UserMenu'
import config from '../config'
@@ -119,6 +120,9 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
return (
<>
{config.devActivityPanel && permissions === 'admin' && (
<NowPlayingPanel />
)}
{config.devActivityPanel && permissions === 'admin' && <ActivityPanel />}
<UserMenu {...rest}>
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />
+338
View File
@@ -0,0 +1,338 @@
import React, { useState, useEffect, useCallback } from 'react'
import PropTypes from 'prop-types'
import { useSelector, useDispatch } from 'react-redux'
import { useTranslate, Link, useNotify } from 'react-admin'
import {
Popover,
IconButton,
makeStyles,
Tooltip,
List,
ListItem,
ListItemText,
ListItemAvatar,
Avatar,
Badge,
Card,
CardContent,
Typography,
useTheme,
useMediaQuery,
} from '@material-ui/core'
import { FaRegCirclePlay } from 'react-icons/fa6'
import subsonic from '../subsonic'
import { useInterval } from '../common'
import { nowPlayingCountUpdate } from '../actions'
import config from '../config'
const useStyles = makeStyles((theme) => ({
button: { color: 'inherit' },
list: {
width: '30em',
maxHeight: (props) => {
// Calculate height for up to 4 entries before scrolling
const entryHeight = 80
const maxEntries = Math.min(props.entryCount || 0, 4)
return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em'
},
overflowY: 'auto',
padding: 0,
},
card: {
padding: 0,
},
cardContent: {
padding: `${theme.spacing(1)}px !important`, // Minimal padding, override default
'&:last-child': {
paddingBottom: `${theme.spacing(1)}px !important`, // Override Material-UI's last-child padding
},
},
listItem: {
paddingTop: theme.spacing(0.5),
paddingBottom: theme.spacing(0.5),
paddingLeft: theme.spacing(1),
paddingRight: theme.spacing(1),
},
avatar: {
width: theme.spacing(6),
height: theme.spacing(6),
cursor: 'pointer',
'&:hover': {
opacity: 0.8,
},
},
badge: {
'& .MuiBadge-badge': {
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
},
},
artistLink: {
cursor: 'pointer',
'&:hover': {
textDecoration: 'underline',
},
},
primaryText: {
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
},
}))
// NowPlayingButton component - handles the button with badge
const NowPlayingButton = React.memo(({ count, onClick }) => {
const classes = useStyles()
const translate = useTranslate()
return (
<Tooltip title={translate('nowPlaying.title')}>
<IconButton
className={classes.button}
onClick={onClick}
aria-label={translate('nowPlaying.title')}
aria-haspopup="true"
>
<Badge
badgeContent={count}
color="primary"
overlap="rectangular"
className={classes.badge}
>
<FaRegCirclePlay size={20} />
</Badge>
</IconButton>
</Tooltip>
)
})
NowPlayingButton.displayName = 'NowPlayingButton'
NowPlayingButton.propTypes = {
count: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired,
}
// NowPlayingItem component - individual list item
const NowPlayingItem = React.memo(
({ nowPlayingEntry, onLinkClick, getArtistLink }) => {
const classes = useStyles()
const translate = useTranslate()
return (
<ListItem key={nowPlayingEntry.playerId} className={classes.listItem}>
<ListItemAvatar>
<Link
to={`/album/${nowPlayingEntry.albumId}/show`}
onClick={onLinkClick}
>
<Avatar
className={classes.avatar}
src={subsonic.getCoverArtUrl(nowPlayingEntry, 80)}
variant="square"
alt={`${nowPlayingEntry.album} cover art`}
loading="lazy"
/>
</Link>
</ListItemAvatar>
<ListItemText
primary={
<div className={classes.primaryText}>
{nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? (
<Link
to={getArtistLink(
nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId,
)}
className={classes.artistLink}
onClick={onLinkClick}
>
{nowPlayingEntry.albumArtist || nowPlayingEntry.artist}
</Link>
) : (
<span>
{nowPlayingEntry.albumArtist || nowPlayingEntry.artist}
</span>
)}
&nbsp;-&nbsp;{nowPlayingEntry.title}
</div>
}
secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''}${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`}
/>
</ListItem>
)
},
)
NowPlayingItem.displayName = 'NowPlayingItem'
NowPlayingItem.propTypes = {
nowPlayingEntry: PropTypes.shape({
playerId: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
albumId: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
albumArtistId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
artistId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
albumArtist: PropTypes.string,
artist: PropTypes.string,
title: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
playerName: PropTypes.string,
minutesAgo: PropTypes.number.isRequired,
album: PropTypes.string,
}).isRequired,
onLinkClick: PropTypes.func.isRequired,
getArtistLink: PropTypes.func.isRequired,
}
// NowPlayingList component - handles the popover content
const NowPlayingList = React.memo(
({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink }) => {
const classes = useStyles({ entryCount: entries.length })
const translate = useTranslate()
return (
<Popover
id="panel-nowplaying"
anchorEl={anchorEl}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
open={open}
onClose={onClose}
aria-labelledby="now-playing-title"
>
<Card className={classes.card}>
<CardContent className={classes.cardContent}>
{entries.length === 0 ? (
<Typography id="now-playing-title">
{translate('nowPlaying.empty')}
</Typography>
) : (
<List
className={classes.list}
dense
aria-label={translate('nowPlaying.title')}
>
{entries.map((nowPlayingEntry) => (
<NowPlayingItem
key={nowPlayingEntry.playerId}
nowPlayingEntry={nowPlayingEntry}
onLinkClick={onLinkClick}
getArtistLink={getArtistLink}
/>
))}
</List>
)}
</CardContent>
</Card>
</Popover>
)
},
)
NowPlayingList.displayName = 'NowPlayingList'
NowPlayingList.propTypes = {
anchorEl: PropTypes.object,
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
entries: PropTypes.arrayOf(PropTypes.object).isRequired,
onLinkClick: PropTypes.func.isRequired,
getArtistLink: PropTypes.func.isRequired,
}
// Main NowPlayingPanel component
const NowPlayingPanel = () => {
const dispatch = useDispatch()
const count = useSelector((state) => state.activity.nowPlayingCount)
const translate = useTranslate()
const notify = useNotify()
const theme = useTheme()
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm'))
const [anchorEl, setAnchorEl] = useState(null)
const [entries, setEntries] = useState([])
const open = Boolean(anchorEl)
const handleMenuOpen = useCallback((event) => {
setAnchorEl(event.currentTarget)
}, [])
const handleMenuClose = useCallback(() => {
setAnchorEl(null)
}, [])
// Close panel when link is clicked on small screens
const handleLinkClick = useCallback(() => {
if (isSmallScreen) {
handleMenuClose()
}
}, [isSmallScreen, handleMenuClose])
const getArtistLink = useCallback((artistId) => {
if (!artistId) return null
return config.devShowArtistPage && artistId !== config.variousArtistsId
? `/artist/${artistId}/show`
: `/album?filter={"artist_id":"${artistId}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=15`
}, [])
const fetchList = useCallback(
() =>
subsonic
.getNowPlaying()
.then((resp) => resp.json['subsonic-response'])
.then((data) => {
if (data.status === 'ok') {
const nowPlayingEntries = data.nowPlaying?.entry || []
setEntries(nowPlayingEntries)
// Also update the count in Redux store
dispatch(nowPlayingCountUpdate({ count: nowPlayingEntries.length }))
} else {
throw new Error(
data.error?.message || 'Failed to fetch now playing data',
)
}
})
.catch((error) => {
notify('ra.page.error', 'warning', {
messageArgs: { error: error.message || 'Unknown error' },
})
}),
[dispatch, notify],
)
// Initialize count and entries on mount
useEffect(() => {
fetchList()
}, [fetchList])
// Refresh when count changes from WebSocket events (if panel is open)
useEffect(() => {
if (open) fetchList()
}, [count, open, fetchList])
useInterval(
() => {
if (open) fetchList()
},
open ? 10000 : null,
)
return (
<div>
<NowPlayingButton count={count} onClick={handleMenuOpen} />
<NowPlayingList
anchorEl={anchorEl}
open={open}
onClose={handleMenuClose}
entries={entries}
onLinkClick={handleLinkClick}
getArtistLink={getArtistLink}
/>
</div>
)
}
NowPlayingPanel.propTypes = {}
export default NowPlayingPanel
+234
View File
@@ -0,0 +1,234 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, beforeEach, vi } from 'vitest'
import { Provider } from 'react-redux'
import { createStore, combineReducers } from 'redux'
import { activityReducer } from '../reducers'
import NowPlayingPanel from './NowPlayingPanel'
import subsonic from '../subsonic'
vi.mock('../subsonic', () => ({
default: {
getNowPlaying: vi.fn(),
getAvatarUrl: vi.fn(() => '/avatar'),
getCoverArtUrl: vi.fn(() => '/cover'),
},
}))
// Create a mock for useMediaQuery
const mockUseMediaQuery = vi.fn()
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
const redux = await import('react-redux')
return {
...actual,
useTranslate: () => (x) => x,
useSelector: redux.useSelector,
useDispatch: redux.useDispatch,
Link: ({ to, children, onClick, ...props }) => (
<a
href={to}
onClick={(e) => {
e.preventDefault() // Prevent navigation in tests
if (onClick) onClick(e)
}}
{...props}
>
{children}
</a>
),
}
})
// Mock the specific Material-UI hooks we need
vi.mock('@material-ui/core/useMediaQuery', () => ({
default: () => mockUseMediaQuery(),
}))
vi.mock('@material-ui/core/styles/useTheme', () => ({
default: () => ({
breakpoints: {
down: () => '(max-width:959.95px)', // Mock breakpoint string
},
}),
}))
describe('<NowPlayingPanel />', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseMediaQuery.mockReturnValue(false) // Default to large screen
subsonic.getNowPlaying.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
nowPlaying: {
entry: [
{
playerId: 1,
username: 'u1',
playerName: 'Chrome Browser',
title: 'Song',
albumArtist: 'Artist',
albumId: 'album1',
albumArtistId: 'artist1',
minutesAgo: 2,
},
],
},
},
},
})
})
it('fetches and displays entries when opened', async () => {
const store = createStore(combineReducers({ activity: activityReducer }), {
activity: { nowPlayingCount: 1 },
})
render(
<Provider store={store}>
<NowPlayingPanel />
</Provider>,
)
// Wait for initial fetch to complete
await waitFor(() => {
expect(subsonic.getNowPlaying).toHaveBeenCalled()
})
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('Artist')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Artist' })).toHaveAttribute(
'href',
'/artist/artist1/show',
)
})
})
it('displays player name after username', async () => {
const store = createStore(combineReducers({ activity: activityReducer }), {
activity: { nowPlayingCount: 1 },
})
render(
<Provider store={store}>
<NowPlayingPanel />
</Provider>,
)
// Wait for initial fetch to complete
await waitFor(() => {
expect(subsonic.getNowPlaying).toHaveBeenCalled()
})
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(
screen.getByText('u1 (Chrome Browser) • nowPlaying.minutesAgo'),
).toBeInTheDocument()
})
})
it('handles entries without player name', async () => {
subsonic.getNowPlaying.mockResolvedValueOnce({
json: {
'subsonic-response': {
status: 'ok',
nowPlaying: {
entry: [
{
playerId: 1,
username: 'u1',
title: 'Song',
albumArtist: 'Artist',
albumId: 'album1',
albumArtistId: 'artist1',
minutesAgo: 2,
},
],
},
},
},
})
const store = createStore(combineReducers({ activity: activityReducer }), {
activity: { nowPlayingCount: 1 },
})
render(
<Provider store={store}>
<NowPlayingPanel />
</Provider>,
)
// Wait for initial fetch to complete
await waitFor(() => {
expect(subsonic.getNowPlaying).toHaveBeenCalled()
})
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('u1 • nowPlaying.minutesAgo')).toBeInTheDocument()
})
})
it('shows empty message when no entries', async () => {
subsonic.getNowPlaying.mockResolvedValueOnce({
json: {
'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } },
},
})
const store = createStore(combineReducers({ activity: activityReducer }), {
activity: { nowPlayingCount: 0 },
})
render(
<Provider store={store}>
<NowPlayingPanel />
</Provider>,
)
// Wait for initial fetch
await waitFor(() => {
expect(subsonic.getNowPlaying).toHaveBeenCalled()
})
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('nowPlaying.empty')).toBeInTheDocument()
})
})
it('does not close panel when artist link is clicked on large screens', async () => {
mockUseMediaQuery.mockReturnValue(false) // Simulate large screen
const store = createStore(combineReducers({ activity: activityReducer }), {
activity: { nowPlayingCount: 1 },
})
render(
<Provider store={store}>
<NowPlayingPanel />
</Provider>,
)
// Wait for initial fetch to complete
await waitFor(() => {
expect(subsonic.getNowPlaying).toHaveBeenCalled()
})
// Open the panel
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('Artist')).toBeInTheDocument()
})
// Check that the popover is open
expect(screen.getByRole('presentation')).toBeInTheDocument()
// Click the artist link
fireEvent.click(screen.getByRole('link', { name: 'Artist' }))
// Panel should remain open (popover should still be in document)
expect(screen.getByRole('presentation')).toBeInTheDocument()
expect(screen.getByText('Artist')).toBeInTheDocument()
})
})
+4
View File
@@ -2,6 +2,7 @@ import {
EVENT_REFRESH_RESOURCE,
EVENT_SCAN_STATUS,
EVENT_SERVER_START,
EVENT_NOW_PLAYING_COUNT,
} from '../actions'
import config from '../config'
@@ -14,6 +15,7 @@ const initialState = {
elapsedTime: 0,
},
serverStart: { version: config.version },
nowPlayingCount: 0,
}
export const activityReducer = (previousState = initialState, payload) => {
@@ -40,6 +42,8 @@ export const activityReducer = (previousState = initialState, payload) => {
resources: data,
},
}
case EVENT_NOW_PLAYING_COUNT:
return { ...previousState, nowPlayingCount: data.count }
default:
return previousState
}
+15 -1
View File
@@ -1,5 +1,9 @@
import { activityReducer } from './activityReducer'
import { EVENT_SCAN_STATUS, EVENT_SERVER_START } from '../actions'
import {
EVENT_SCAN_STATUS,
EVENT_SERVER_START,
EVENT_NOW_PLAYING_COUNT,
} from '../actions'
import config from '../config'
describe('activityReducer', () => {
@@ -12,6 +16,7 @@ describe('activityReducer', () => {
elapsedTime: 0,
},
serverStart: { version: config.version },
nowPlayingCount: 0,
}
it('returns the initial state when no action is specified', () => {
@@ -116,4 +121,13 @@ describe('activityReducer', () => {
startTime: Date.parse('2023-01-01T00:00:00Z'),
})
})
it('handles EVENT_NOW_PLAYING_COUNT', () => {
const action = {
type: EVENT_NOW_PLAYING_COUNT,
data: { count: 5 },
}
const newState = activityReducer(initialState, action)
expect(newState.nowPlayingCount).toEqual(5)
})
})
+12
View File
@@ -54,6 +54,16 @@ const startScan = (options) => httpClient(url('startScan', null, options))
const getScanStatus = () => httpClient(url('getScanStatus'))
const getNowPlaying = () => httpClient(url('getNowPlaying'))
const getAvatarUrl = (username, size) =>
baseUrl(
url('getAvatar', null, {
username,
...(size && { size }),
}),
)
const getCoverArtUrl = (record, size, square) => {
const options = {
...(record.updatedAt && { _: record.updatedAt }),
@@ -110,7 +120,9 @@ export default {
setRating,
startScan,
getScanStatus,
getNowPlaying,
getCoverArtUrl,
getAvatarUrl,
streamUrl,
getAlbumInfo,
getArtistInfo,
+23
View File
@@ -104,3 +104,26 @@ describe('getCoverArtUrl', () => {
expect(url).not.toContain('_=')
})
})
describe('getAvatarUrl', () => {
beforeEach(() => {
// Mock localStorage values required by subsonic
const localStorageMock = {
getItem: vi.fn((key) => {
const values = {
username: 'testuser',
'subsonic-token': 'testtoken',
'subsonic-salt': 'testsalt',
}
return values[key] || null
}),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
})
it('should include username parameter', () => {
const url = subsonic.getAvatarUrl('john')
expect(url).toContain('getAvatar')
expect(url).toContain('username=john')
})
})