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 ( ) }) 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 ( {nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? ( {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} ) : ( {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} )}  - {nowPlayingEntry.title} } secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`} /> ) }, ) 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 ( {entries.length === 0 ? ( {translate('nowPlaying.empty')} ) : ( {entries.map((nowPlayingEntry) => ( ))} )} ) }, ) 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 streamReconnected = useSelector( (state) => state.activity.streamReconnected, ) const serverUp = useSelector( (state) => !!state.activity.serverStart.startTime, ) 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, and refresh on server/stream changes useEffect(() => { if (serverUp) fetchList() }, [fetchList, serverUp, streamReconnected]) // Refresh when count changes from WebSocket events (if panel is open) useEffect(() => { if (open && serverUp) fetchList() }, [count, open, fetchList, serverUp]) // Periodic refresh when panel is open (10 seconds) useInterval( () => { if (open && serverUp) fetchList() }, open ? 10000 : null, ) // Periodic refresh when panel is closed (60 seconds) to keep badge accurate useInterval( () => { if (!open && serverUp) fetchList() }, !open ? 60000 : null, ) return (
) } NowPlayingPanel.propTypes = {} export default NowPlayingPanel