diff --git a/conf/configuration.go b/conf/configuration.go index 916efe70b..1c4829d82 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -95,6 +95,7 @@ type configOptions struct { EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool + UIPlaybackReportInterval time.Duration GATrackingID string EnableLogRedacting bool AuthRequestLimit int @@ -776,6 +777,7 @@ func setViperDefaults() { viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) + viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) viper.SetDefault("enableartworkupload", true) viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) viper.SetDefault("enablesharing", false) diff --git a/consts/consts.go b/consts/consts.go index 3db0b831a..bf32006d6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -67,11 +67,12 @@ const ( ScanIgnoreFile = ".ndignore" ArtworkFolder = "artwork" - PlaceholderArtistArt = "artist-placeholder.webp" - PlaceholderAlbumArt = "album-placeholder.webp" - PlaceholderAvatar = "logo-192x192.png" - DefaultUIVolume = 100 - DefaultUISearchDebounceMs = 200 + PlaceholderArtistArt = "artist-placeholder.webp" + PlaceholderAlbumArt = "album-placeholder.webp" + PlaceholderAvatar = "logo-192x192.png" + DefaultUIVolume = 100 + DefaultUISearchDebounceMs = 200 + DefaultUIPlaybackReportInterval = time.Minute DefaultHttpClientTimeOut = 10 * time.Second diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 462fc85ba..161d04d7d 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -240,7 +240,6 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP client := params.ClientName now := time.Now() - prevCount := p.playMap.Len() switch params.State { case StateStarting: @@ -311,7 +310,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.playMap.Remove(clientId) } - if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount { + if conf.Server.EnableNowPlaying { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 64b3d3ee0..734f8f66f 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -371,6 +371,69 @@ var _ = Describe("PlayTracker", func() { Expect(playing).To(HaveLen(2)) }) + Describe("SSE broadcast on state change", func() { + BeforeEach(func() { + eventBroker = &fakeEventBroker{} + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = fake + }) + + It("broadcasts NowPlayingCount on every state change", func() { + // starting -> count should be 1 + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts := eventBroker.getEvents() + Expect(evts).To(HaveLen(1)) + Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1)) + + // playing -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(2)) + Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1)) + + // paused -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(3)) + Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1)) + + // stopped -> count should be 0 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(4)) + Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0)) + }) + + It("does NOT broadcast when EnableNowPlaying is false", func() { + conf.Server.EnableNowPlaying = false + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = fake + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.getEvents()).To(BeEmpty()) + }) + }) + Describe("auto-scrobble", func() { It("scrobbles on stopped when positionMs >= 50% of track", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ diff --git a/server/serve_index.go b/server/serve_index.go index 734aabc70..13fa4a9ce 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -58,6 +58,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableNowPlaying": conf.Server.EnableNowPlaying, + "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 31bca02cf..78f3873b8 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -106,6 +106,7 @@ var _ = Describe("serveIndex", func() { Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true), Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true), Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), + Entry("playbackReportIntervalMs", func() { conf.Server.UIPlaybackReportInterval = 30 * time.Second }, "playbackReportIntervalMs", float64(30000)), ) It("sanitizes entity-encoded welcomeMessage as html", func() { diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 275665b02..2a2338d89 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -213,11 +213,12 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { response.NowPlaying = &responses.NowPlaying{} var i int32 response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { + i++ return responses.NowPlayingEntry{ Child: childFromMediaFile(ctx, np.MediaFile), UserName: np.Username, MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything + PlayerId: i, PlayerName: np.PlayerName, State: np.State, PositionMs: np.PositionMs, diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index 995534550..892f2e27e 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -2,6 +2,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 EVENT_NOW_PLAYING_COUNT_SYNC = 'nowPlayingCountSync' export const EVENT_STREAM_RECONNECTED = 'streamReconnected' export const processEvent = (type, data) => ({ @@ -18,6 +19,11 @@ export const nowPlayingCountUpdate = (data) => ({ data: data, }) +export const nowPlayingCountSync = (data) => ({ + type: EVENT_NOW_PLAYING_COUNT_SYNC, + data: data, +}) + export const serverDown = () => ({ type: EVENT_SERVER_START, data: {}, diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 5599b9e1d..e2070deea 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useInterval } from '../common' import { useDispatch, useSelector } from 'react-redux' import { useMediaQuery } from '@material-ui/core' import { ThemeProvider } from '@material-ui/core/styles' @@ -41,9 +42,11 @@ const Player = () => { const dataProvider = useDataProvider() const playerState = useSelector((state) => state.player) const dispatch = useDispatch() - const [startTime, setStartTime] = useState(null) - const [scrobbled, setScrobbled] = useState(false) - const [preloaded, setPreload] = useState(false) + const [currentTrackId, setCurrentTrackId] = useState(null) + const [heartbeatTrackId, setHeartbeatTrackId] = useState(null) + const lastPositionMsRef = useRef(0) + const currentTrackIdRef = useRef(null) + const stoppedRef = useRef(false) const [audioInstance, setAudioInstance] = useState(null) const isDesktop = useMediaQuery('(min-width:810px)') const isMobilePlayer = @@ -58,6 +61,21 @@ const Player = () => { const playerStateRef = useRef(playerState) playerStateRef.current = playerState + currentTrackIdRef.current = currentTrackId + + useInterval( + () => { + if (heartbeatTrackId && !stoppedRef.current) { + subsonic.reportPlayback( + heartbeatTrackId, + lastPositionMsRef.current, + 'playing', + ) + } + }, + heartbeatTrackId ? config.playbackReportIntervalMs : null, + ) + // Detect browser codec profile and eagerly resolve transcode URLs for the // persisted queue once on mount (e.g. after a browser refresh) useEffect(() => { @@ -155,15 +173,33 @@ const Player = () => { useEffect(() => { const handleBeforeUnload = (e) => { - // Check there's a current track and is actually playing/not paused if (playerState.current?.uuid && audioInstance && !audioInstance.paused) { e.preventDefault() - e.returnValue = '' // Chrome requires returnValue to be set + e.returnValue = '' + } + } + + const handlePageHide = () => { + if (currentTrackIdRef.current && !playerState.current?.isRadio) { + stoppedRef.current = true + try { + subsonic.reportPlaybackKeepalive( + currentTrackIdRef.current, + lastPositionMsRef.current, + 'stopped', + ) + } catch { + // fetch/sendBeacon may throw; ignore + } } } window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) + window.addEventListener('pagehide', handlePageHide) + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + window.removeEventListener('pagehide', handlePageHide) + } }, [playerState, audioInstance]) const defaultOptions = useMemo( @@ -227,44 +263,25 @@ const Player = () => { [dispatch], ) - const nextSong = useCallback(() => { - const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid, - ) - return idx !== null ? playerState.queue[idx + 1] : null - }, [playerState]) + const onAudioProgress = useCallback((info) => { + if (info.ended) { + document.title = 'Navidrome' + } + if (!info.isRadio && info.currentTime != null) { + lastPositionMsRef.current = Math.floor(info.currentTime * 1000) + } + }, []) - const onAudioProgress = useCallback( + const onAudioSeeked = useCallback( (info) => { - if (info.ended) { - document.title = 'Navidrome' - } - - const progress = (info.currentTime / info.duration) * 100 - if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) { - return - } - - if (info.isRadio) { - return - } - - if (!preloaded) { - const next = nextSong() - if (next != null && !next.isRadio) { - // Trigger decision pre-fetch (this also warms the cache) - decisionService.prefetchDecisions([next.trackId]) - } - setPreload(true) - return - } - - if (!scrobbled) { - info.trackId && subsonic.scrobble(info.trackId, startTime) - setScrobbled(true) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const state = audioInstance?.paused ? 'paused' : 'playing' + subsonic.reportPlayback(currentTrackId, posMs, state) } }, - [startTime, scrobbled, nextSong, preloaded], + [currentTrackId, audioInstance], ) const onAudioVolumeChange = useCallback( @@ -275,24 +292,30 @@ const Player = () => { const onAudioPlay = useCallback( (info) => { - // Do this to start the context; on chrome-based browsers, the context - // will start paused since it is created prior to user interaction if (context && context.state !== 'running') { context.resume() } dispatch(currentPlaying(info)) - if (startTime === null) { - setStartTime(Date.now()) - } if (info.duration) { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` if (!info.isRadio) { - const pos = startTime === null ? null : Math.floor(info.currentTime) - subsonic.nowPlaying(info.trackId, pos) + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const isNewTrack = info.trackId !== currentTrackId + if (isNewTrack) { + subsonic + .reportPlayback(info.trackId, posMs, 'starting') + .then(() => + subsonic.reportPlayback(info.trackId, posMs, 'playing'), + ) + setCurrentTrackId(info.trackId) + } else { + subsonic.reportPlayback(info.trackId, posMs, 'playing') + } + setHeartbeatTrackId(info.trackId) } - setPreload(false) if (config.gaTrackingId) { ReactGA.event({ category: 'Player', @@ -309,34 +332,49 @@ const Player = () => { } } }, - [context, dispatch, showNotifications, startTime], + [context, dispatch, showNotifications, currentTrackId], ) const onAudioPlayTrackChange = useCallback(() => { - if (scrobbled) { - setScrobbled(false) + if (currentTrackId) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) } - if (startTime !== null) { - setStartTime(null) - } - }, [scrobbled, startTime]) + setHeartbeatTrackId(null) + setCurrentTrackId(null) + }, [currentTrackId]) const onAudioPause = useCallback( - (info) => dispatch(currentPlaying(info)), - [dispatch], + (info) => { + dispatch(currentPlaying(info)) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + subsonic.reportPlayback(currentTrackId, posMs, 'paused') + } + setHeartbeatTrackId(null) + }, + [dispatch, currentTrackId], ) const onAudioEnded = useCallback( (currentPlayId, audioLists, info) => { - setScrobbled(false) - setStartTime(null) + if (currentTrackId && !info.isRadio) { + const posMs = Math.floor((info.duration || 0) * 1000) + subsonic.reportPlayback(currentTrackId, posMs, 'stopped') + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(currentPlaying(info)) dataProvider .getOne('keepalive', { id: info.trackId }) // eslint-disable-next-line no-console .catch((e) => console.log('Keepalive error:', e)) }, - [dispatch, dataProvider], + [dispatch, dataProvider, currentTrackId], ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { @@ -369,10 +407,19 @@ const Player = () => { const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { + if (currentTrackId && !playerStateRef.current?.current?.isRadio) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(clearQueue()) reject() }) - }, [dispatch]) + }, [dispatch, currentTrackId]) if (!visible) { document.title = 'Navidrome' @@ -397,6 +444,7 @@ const Player = () => { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} + onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} diff --git a/ui/src/config.js b/ui/src/config.js index f4d60dfba..39f0cd467 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -33,6 +33,7 @@ const defaultConfig = { enableExternalServices: true, enableCoverAnimation: true, enableNowPlaying: true, + playbackReportIntervalMs: 60000, devShowArtistPage: true, devUIShowConfig: true, devNewEventStream: false, diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index a8897aef8..3820f658d 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -6,8 +6,8 @@ import { jwtDecode } from 'jwt-decode' import { removeHomeCache } from '../utils/removeHomeCache' const customAuthorizationHeader = 'X-ND-Authorization' -const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' -const clientUniqueId = uuidv4() +export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' +export const clientUniqueId = uuidv4() const httpClient = (url, options = {}) => { url = baseUrl(url) diff --git a/ui/src/dataProvider/index.js b/ui/src/dataProvider/index.js index e9ceea761..059023378 100644 --- a/ui/src/dataProvider/index.js +++ b/ui/src/dataProvider/index.js @@ -1,6 +1,6 @@ -import httpClient from './httpClient' +import httpClient, { clientUniqueId, clientUniqueIdHeader } from './httpClient' import wrapperDataProvider from './wrapperDataProvider' -export { httpClient } +export { httpClient, clientUniqueId, clientUniqueIdHeader } export default wrapperDataProvider diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx index 4aaee1bee..509263b42 100644 --- a/ui/src/layout/NowPlayingPanel.jsx +++ b/ui/src/layout/NowPlayingPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react' +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' @@ -9,30 +9,29 @@ import { Tooltip, List, ListItem, - ListItemText, - ListItemAvatar, Avatar, Badge, Card, CardContent, Typography, + LinearProgress, useTheme, useMediaQuery, } from '@material-ui/core' -import { FaRegCirclePlay } from 'react-icons/fa6' +import { FaRegCirclePlay, FaPause } from 'react-icons/fa6' import subsonic from '../subsonic' import { useInterval } from '../common' -import { nowPlayingCountUpdate } from '../actions' +import { nowPlayingCountSync } from '../actions' +import { formatDuration } from '../utils' import config from '../config' const useStyles = makeStyles((theme) => ({ button: { color: 'inherit' }, list: { - width: '30em', + width: '26em', maxHeight: (props) => { - // Calculate height for up to 4 entries before scrolling - const entryHeight = 80 - const maxEntries = Math.min(props.entryCount || 0, 4) + const entryHeight = 120 + const maxEntries = Math.min(props.entryCount || 0, 3) return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' }, overflowY: 'auto', @@ -42,42 +41,111 @@ const useStyles = makeStyles((theme) => ({ padding: 0, }, cardContent: { - padding: `${theme.spacing(1)}px !important`, // Minimal padding, override default + padding: `${theme.spacing(1)}px !important`, '&:last-child': { - paddingBottom: `${theme.spacing(1)}px !important`, // Override Material-UI's last-child padding + paddingBottom: `${theme.spacing(1)}px !important`, }, }, listItem: { - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), + 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: theme.spacing(6), - height: theme.spacing(6), + 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, }, }, - artistLink: { - cursor: 'pointer', - '&:hover': { - textDecoration: 'underline', - }, - }, - primaryText: { - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - }, })) // NowPlayingButton component - handles the button with badge @@ -113,15 +181,32 @@ NowPlayingButton.propTypes = { onClick: PropTypes.func.isRequired, } -// NowPlayingItem component - individual list item const NowPlayingItem = React.memo( - ({ nowPlayingEntry, onLinkClick, getArtistLink }) => { + ({ nowPlayingEntry, onLinkClick, getArtistLink, now }) => { const classes = useStyles() - const translate = useTranslate() + 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 ( - - + +
- - - {nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - ) : ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - )} -  - {nowPlayingEntry.title} + {isPaused && ( +
+
- } - secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`} - /> + )} +
+
+ + {nowPlayingEntry.title} + + {artistId ? ( + + {artistName} + + ) : ( + + {artistName} + + )} + + {nowPlayingEntry.album} + +
+ + {formatDuration(positionSec)} + + + + {formatDuration(durationSec)} + +
+ + {nowPlayingEntry.username} + {nowPlayingEntry.playerName + ? ` (${nowPlayingEntry.playerName})` + : ''} + +
) }, @@ -178,16 +291,19 @@ NowPlayingItem.propTypes = { title: PropTypes.string.isRequired, username: PropTypes.string.isRequired, playerName: PropTypes.string, - minutesAgo: PropTypes.number.isRequired, 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 }) => { + ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink, now }) => { const classes = useStyles({ entryCount: entries.length }) const translate = useTranslate() @@ -215,10 +331,11 @@ const NowPlayingList = React.memo( > {entries.map((nowPlayingEntry) => ( ))} @@ -239,12 +356,14 @@ NowPlayingList.propTypes = { 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, ) @@ -258,6 +377,7 @@ const NowPlayingPanel = () => { const [anchorEl, setAnchorEl] = useState(null) const [entries, setEntries] = useState([]) + const [now, setNow] = useState(Date.now()) const open = Boolean(anchorEl) const handleMenuOpen = useCallback((event) => { @@ -282,40 +402,57 @@ const NowPlayingPanel = () => { : `/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', - ) - } + 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' }, }) - .catch((error) => { - notify('ra.page.error', 'warning', { - messageArgs: { error: error.message || 'Unknown error' }, - }) - }), - [dispatch, notify], - ) + }) + 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 count changes from WebSocket events (if panel is open) + // Refresh when NowPlaying updates from SSE events (if panel is open) useEffect(() => { if (open && serverUp) fetchList() - }, [count, open, fetchList, serverUp]) + }, [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( @@ -341,6 +478,7 @@ const NowPlayingPanel = () => { open={open} onClose={handleMenuClose} entries={entries} + now={now} onLinkClick={handleLinkClick} getArtistLink={getArtistLink} /> diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx index 4dd5dac8b..ea4a3568b 100644 --- a/ui/src/layout/NowPlayingPanel.test.jsx +++ b/ui/src/layout/NowPlayingPanel.test.jsx @@ -70,7 +70,12 @@ describe('', () => { ) } + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { + vi.useFakeTimers() vi.clearAllMocks() mockUseMediaQuery.mockReturnValue(false) // Default to large screen @@ -105,10 +110,8 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + // Advance past debounce and flush promises + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -128,21 +131,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect( - screen.getByText('u1 (Chrome Browser) • nowPlaying.minutesAgo'), - ).toBeInTheDocument() + expect(screen.getByText('u1 (Chrome Browser)')).toBeInTheDocument() }) }) it('handles entries without player name', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', @@ -170,19 +168,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect(screen.getByText('u1 • nowPlaying.minutesAgo')).toBeInTheDocument() + expect(screen.getByText('u1')).toBeInTheDocument() }) }) it('shows empty message when no entries', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } }, }, @@ -194,10 +189,7 @@ describe('', () => { , ) - // Wait for initial fetch - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -215,10 +207,7 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) // Open the panel fireEvent.click(screen.getByRole('button')) @@ -268,7 +257,9 @@ describe('', () => { expect(subsonic.getNowPlaying).not.toHaveBeenCalled() }) - it('does not double-fetch on server reconnection', () => { + it('does not double-fetch on server reconnection', async () => { + vi.useFakeTimers() + const initialStore = createMockStore({ nowPlayingCount: 1, serverStart: { startTime: null }, // Server initially down @@ -295,8 +286,13 @@ describe('', () => { , ) + // Advance past the debounce window + vi.advanceTimersByTime(500) + // Should only make one call despite both serverUp and streamReconnected changing expect(subsonic.getNowPlaying).toHaveBeenCalledTimes(1) + + vi.useRealTimers() }) it('skips polling when server is down', () => { diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js index 8238e395a..d61ed6018 100644 --- a/ui/src/reducers/activityReducer.js +++ b/ui/src/reducers/activityReducer.js @@ -3,6 +3,7 @@ import { EVENT_SCAN_STATUS, EVENT_SERVER_START, EVENT_NOW_PLAYING_COUNT, + EVENT_NOW_PLAYING_COUNT_SYNC, EVENT_STREAM_RECONNECTED, } from '../actions' import config from '../config' @@ -17,6 +18,7 @@ const initialState = { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, // Timestamp of last reconnection } @@ -45,6 +47,12 @@ export const activityReducer = (previousState = initialState, payload) => { }, } case EVENT_NOW_PLAYING_COUNT: + return { + ...previousState, + nowPlayingCount: data.count, + nowPlayingLastUpdate: Date.now(), + } + case EVENT_NOW_PLAYING_COUNT_SYNC: return { ...previousState, nowPlayingCount: data.count } case EVENT_STREAM_RECONNECTED: return { ...previousState, streamReconnected: Date.now() } diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js index c9db38dbb..e52a262c1 100644 --- a/ui/src/reducers/activityReducer.test.js +++ b/ui/src/reducers/activityReducer.test.js @@ -18,6 +18,7 @@ describe('activityReducer', () => { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, } @@ -133,6 +134,22 @@ describe('activityReducer', () => { expect(newState.nowPlayingCount).toEqual(5) }) + it('handles EVENT_NOW_PLAYING_COUNT with nowPlayingLastUpdate', () => { + const action = { + type: EVENT_NOW_PLAYING_COUNT, + data: { count: 3 }, + } + const beforeTimestamp = Date.now() + const newState = activityReducer(initialState, action) + const afterTimestamp = Date.now() + + expect(newState.nowPlayingCount).toEqual(3) + expect(newState.nowPlayingLastUpdate).toBeGreaterThanOrEqual( + beforeTimestamp, + ) + expect(newState.nowPlayingLastUpdate).toBeLessThanOrEqual(afterTimestamp) + }) + it('handles EVENT_STREAM_RECONNECTED', () => { const action = { type: EVENT_STREAM_RECONNECTED, diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 3579619aa..7d93972e0 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -1,5 +1,9 @@ import { baseUrl } from '../utils' -import { httpClient } from '../dataProvider' +import { + httpClient, + clientUniqueId, + clientUniqueIdHeader, +} from '../dataProvider' const url = (command, id, options) => { const username = localStorage.getItem('username') @@ -37,16 +41,21 @@ const url = (command, id, options) => { const ping = () => httpClient(url('ping')) -const scrobble = (id, time, submission = true, position = null) => - httpClient( - url('scrobble', id, { - ...(submission && time && { time }), - submission, - ...(!submission && position !== null && { position }), - }), - ) +const reportPlaybackUrl = (mediaId, positionMs, state) => + url('reportPlayback', null, { mediaId, mediaType: 'song', positionMs, state }) -const nowPlaying = (id, position = null) => scrobble(id, null, false, position) +const reportPlayback = (mediaId, positionMs, state) => + httpClient(reportPlaybackUrl(mediaId, positionMs, state)) + +const reportPlaybackKeepalive = (mediaId, positionMs, state) => { + const u = reportPlaybackUrl(mediaId, positionMs, state) + if (u) { + fetch(baseUrl(u), { + keepalive: true, + headers: { [clientUniqueIdHeader]: clientUniqueId }, + }) + } +} const star = (id) => httpClient(url('star', id)) @@ -132,8 +141,8 @@ const streamUrl = (id, options) => { export default { url, ping, - scrobble, - nowPlaying, + reportPlayback, + reportPlaybackKeepalive, download, star, unstar, diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index a750694f4..ad4764c24 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -194,3 +194,33 @@ describe('getAvatarUrl', () => { expect(url).toContain('username=john') }) }) + +describe('reportPlayback', () => { + beforeEach(() => { + 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 construct reportPlayback URL with correct parameters', () => { + const url = subsonic.url('reportPlayback', null, { + mediaId: 'song-123', + mediaType: 'song', + positionMs: 5000, + state: 'playing', + }) + expect(url).toContain('reportPlayback') + expect(url).toContain('mediaId=song-123') + expect(url).toContain('mediaType=song') + expect(url).toContain('positionMs=5000') + expect(url).toContain('state=playing') + }) +})