import React, { useState, useEffect, useCallback, useRef } 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, Avatar, Badge, Card, CardContent, Typography, LinearProgress, useTheme, useMediaQuery, } from '@material-ui/core' import { FaRegCirclePlay, FaPause } from 'react-icons/fa6' import subsonic from '../subsonic' import { useInterval } from '../common' import { nowPlayingCountSync } from '../actions' import { formatDuration } from '../utils' import config from '../config' const useStyles = makeStyles((theme) => ({ button: { color: 'inherit' }, list: { width: '26em', maxHeight: (props) => { const entryHeight = 120 const maxEntries = Math.min(props.entryCount || 0, 3) return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' }, overflowY: 'auto', padding: 0, }, card: { padding: 0, }, cardContent: { padding: `${theme.spacing(1)}px !important`, '&:last-child': { paddingBottom: `${theme.spacing(1)}px !important`, }, }, listItem: { display: 'flex', alignItems: 'flex-start', gap: theme.spacing(1.5), padding: theme.spacing(1), }, avatarContainer: { position: 'relative', flexShrink: 0, width: theme.spacing(8), height: theme.spacing(8), }, avatar: { width: '100%', height: '100%', cursor: 'pointer', borderRadius: theme.spacing(0.5), '&:hover': { opacity: 0.8, }, }, stateOverlay: { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.45)', borderRadius: theme.spacing(0.5), pointerEvents: 'none', }, stateIcon: { color: 'rgba(255, 255, 255, 0.85)', fontSize: 18, }, entryContent: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: theme.spacing(0.25), }, trackTitle: { fontWeight: 600, fontSize: '0.875rem', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, trackDetail: { fontSize: '0.75rem', color: theme.palette.text.secondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, artistLink: { cursor: 'pointer', color: theme.palette.text.secondary, fontSize: '0.75rem', '&:hover': { textDecoration: 'underline', }, }, progressRow: { display: 'flex', alignItems: 'center', gap: theme.spacing(0.75), marginTop: theme.spacing(0.5), }, progressTime: { fontSize: '0.65rem', color: theme.palette.text.secondary, fontVariantNumeric: 'tabular-nums', flexShrink: 0, }, progressBar: { flex: 1, height: 3, borderRadius: 2, backgroundColor: theme.palette.action.disabledBackground, '& .MuiLinearProgress-bar': { borderRadius: 2, }, }, userInfo: { fontSize: '0.65rem', color: theme.palette.text.disabled, marginTop: theme.spacing(0.25), }, badge: { '& .MuiBadge-badge': { backgroundColor: theme.palette.primary.main, color: theme.palette.primary.contrastText, }, }, })) // 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, } const NowPlayingItem = React.memo( ({ nowPlayingEntry, onLinkClick, getArtistLink, now }) => { const classes = useStyles() const isPaused = nowPlayingEntry.state === 'paused' const isPlaying = nowPlayingEntry.state === 'playing' || nowPlayingEntry.state === 'starting' const basePositionMs = nowPlayingEntry.positionMs || 0 const rate = nowPlayingEntry.playbackRate || 1 const elapsedSinceFetch = now - (nowPlayingEntry._fetchedAt || now) const interpolatedMs = isPlaying ? basePositionMs + elapsedSinceFetch * rate : basePositionMs const durationMs = (nowPlayingEntry.duration || 0) * 1000 const clampedMs = Math.max(0, interpolatedMs) const positionMs = durationMs > 0 ? Math.min(clampedMs, durationMs) : clampedMs const positionSec = positionMs / 1000 const durationSec = nowPlayingEntry.duration || 0 const progress = durationSec > 0 ? (positionSec / durationSec) * 100 : 0 const artistId = nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId const artistName = nowPlayingEntry.albumArtist || nowPlayingEntry.artist return (
{isPaused && (
)}
{nowPlayingEntry.title} {artistId ? ( {artistName} ) : ( {artistName} )} {nowPlayingEntry.album}
{formatDuration(positionSec)} {formatDuration(durationSec)}
{nowPlayingEntry.username} {nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''}
) }, ) 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, album: PropTypes.string, state: PropTypes.string, positionMs: PropTypes.number, duration: PropTypes.number, }).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, now: PropTypes.number.isRequired, } // NowPlayingList component - handles the popover content const NowPlayingList = React.memo( ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink, now }) => { 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, now: PropTypes.number.isRequired, } // Main NowPlayingPanel component const NowPlayingPanel = () => { const dispatch = useDispatch() const count = useSelector((state) => state.activity.nowPlayingCount) const lastUpdate = useSelector((state) => state.activity.nowPlayingLastUpdate) 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 [now, setNow] = useState(Date.now()) 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 fetchTimerRef = useRef(null) const doFetchRef = useRef() doFetchRef.current = () => subsonic .getNowPlaying() .then((resp) => resp.json['subsonic-response']) .then((data) => { if (data.status === 'ok') { const nowPlayingEntries = data.nowPlaying?.entry || [] const fetchTime = Date.now() setEntries( nowPlayingEntries.map((e) => ({ ...e, _fetchedAt: fetchTime })), ) dispatch(nowPlayingCountSync({ 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' }, }) }) const fetchList = useCallback(() => { if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) fetchTimerRef.current = setTimeout(() => { fetchTimerRef.current = null doFetchRef.current() }, 300) }, []) useEffect(() => { return () => { if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) } }, []) // Initialize count and entries on mount, and refresh on server/stream changes useEffect(() => { if (serverUp) fetchList() }, [fetchList, serverUp, streamReconnected]) // Refresh when NowPlaying updates from SSE events (if panel is open) useEffect(() => { if (open && serverUp) fetchList() }, [lastUpdate, open, fetchList, serverUp]) // Update current time every second when open to animate progress bars useInterval(() => setNow(Date.now()), open ? 1000 : null) // 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