feat(ui): replace UI scrobble with reportPlayback and redesign NowPlaying panel (#5448)

* feat(config): add UIPlaybackReportInterval setting

* feat(server): expose playbackReportIntervalMs to UI config

* feat(ui): add playbackReportIntervalMs config default

* feat(ui): replace scrobble/nowPlaying with reportPlayback in subsonic API layer

* feat(ui): replace scrobble logic with reportPlayback state machine in Player

* refactor(ui): simplify Player heartbeat using useInterval hook

- Replace manual setInterval/clearInterval with existing useInterval hook
- Extract shared reportPlaybackUrl helper to deduplicate URL construction
- Use ref for currentTrackId in beforeunload to stabilize effect deps
- Have heartbeat read lastPositionMsRef instead of audioInstance.currentTime

* feat(ui): redesign NowPlaying panel with Discord-style layout

Show album art with play/pause overlay icon, track title, artist,
album name, progress bar with position/duration, and username.

* fix(ui): adjust NowPlaying panel height to fit 3 entries

* fix(ui): send stopped on player close and tab close while paused

- onBeforeDestroy now sends reportPlayback stopped before clearing queue
- beforeunload sends stopped beacon regardless of pause state

* feat(ui): animate NowPlaying progress bar with 1s client-side tick

* fix(ui): account for playbackRate in NowPlaying progress interpolation

* fix(ui): use timestamp-based interpolation for smooth NowPlaying progress

Replace tick counter with fetchedAt timestamp so the progress bar
advances smoothly without resetting on each server poll.

* fix(ui): fix NowPlaying progress bar not animating

Pass `now` (Date.now()) as a prop that changes every tick, so
React.memo'd components actually re-render each second.

* fix(ui): prevent progress bar reset on NowPlaying poll

Set fetchedAt and now atomically on fetch so the elapsed offset
starts at zero and the server's already-estimated positionMs
is used as the base without a visible jump.

* fix(ui): stamp entries with fetch time to prevent progress bar reset

Embed _fetchedAt timestamp directly into each entry object so the
position and its reference timestamp are always in the same state
update, eliminating the React 17 multi-setState batching race.

* fix(server): estimate position for starting state in GetNowPlaying

GetNowPlaying was only estimating elapsed position for the "playing"
state, returning raw positionMs=0 for "starting". Since the UI
player sends "starting" once and then doesn't update until the
60s heartbeat, NowPlaying polls returned 0 for up to a minute,
causing the progress bar to reset on every poll.

* fix(ui): send playing immediately after starting to enable position estimation

The server only estimates elapsed position for "playing" state in
GetNowPlaying. The Player was sending "starting" once and then not
updating until the 60s heartbeat, leaving the server state as
"starting" with positionMs=0 for up to a minute.

Now the Player follows up "starting" with an immediate "playing"
call, transitioning the server state so position estimation works
from the first poll.

* fix(subsonic): fix getNowPlaying returning same playerId for all entries

PlayerId was never incremented in the map callback, so every entry
got playerId=1. This caused the UI to use duplicate React keys,
mixing up rendered entries between players. Also use a stable
composite key in the UI instead of the sequential playerId.

* fix(ui): only send stopped beacon when tab is actually closing

Move the reportPlaybackBeacon call from beforeunload to pagehide.
beforeunload fires before the confirmation dialog, so cancelling
the close would still send stopped. pagehide only fires when the
page is actually being unloaded.

* fix(ui): revert to beforeunload for stopped beacon

pagehide does not fire reliably in Chrome when closing tabs.
Use beforeunload instead — if the user cancels the close dialog,
the heartbeat will re-register the NowPlaying entry on its next tick.

* fix(ui): use synchronous XHR for stopped report on tab close

Replace sendBeacon with synchronous XMLHttpRequest in beforeunload.
This blocks the page from closing until the server acknowledges
the stopped state, ensuring the NowPlaying entry is always removed.

* fix(ui): fix confirmation dialog and use fetch keepalive for tab close

- Move e.preventDefault() before the stopped report so the dialog
  always shows regardless of XHR errors
- Use fetch with keepalive:true instead of sync XHR (more reliable,
  non-blocking, survives page teardown)
- Fall back to sendBeacon if fetch throws

* fix(ui): prevent heartbeat from re-adding entry after stopped on tab close

Set a stoppedRef flag in beforeunload so the heartbeat interval
skips sending playing reports after stopped has been sent.
Without this, the heartbeat could re-register the NowPlaying
entry after the stopped event removed it.

* fix(ui): include client unique ID header in stopped report on tab close

Root cause: reportPlaybackSync (fetch keepalive) did not include the
X-ND-Client-Unique-Id header. Regular reportPlayback calls via
httpClient include this header, and the server uses it as the playMap
key. Without the header, the stopped call fell back to player.ID
as the key, which didn't match the entry added with the UUID key.
The playMap.Remove targeted the wrong key, so the entry persisted.

Fix: export clientUniqueId from httpClient and include it as a header
in the fetch keepalive request.

* fix(ui): use pagehide for stopped report to avoid premature send

beforeunload fires before the confirmation dialog, so the stopped
event was sent even when the user cancelled closing. Move the
stopped report to pagehide, which only fires when the page is
actually being unloaded (after confirmation).

* feat(server): broadcast NowPlaying SSE on every state change

Previously, the SSE broadcast only fired when the NowPlaying count
changed. Now it fires on every reportPlayback call (starting,
playing, paused, stopped), so the NowPlaying panel gets instant
updates for state transitions and position changes.

The UI reducer stores a nowPlayingLastUpdate timestamp alongside
the count, ensuring every SSE event triggers a re-fetch even when
the count is unchanged (e.g., pause/resume).

* fix(ui): clamp NowPlaying position to prevent negative time display

* fix(ui): debounce NowPlaying fetches to prevent progress bar trembling

During track changes, rapid SSE events (stopped, starting, playing)
triggered multiple refetches within milliseconds, each resetting the
interpolation base and causing the progress bar to oscillate. Skip
fetches within 1 second of the previous fetch.

* feat(ui): report playback position on seek

Send a reportPlayback(playing) call when the user seeks/scrubs in
the player, so the NowPlaying panel and server position stay in
sync immediately instead of waiting for the next 60s heartbeat.

* refactor: code review cleanup

- Export clientUniqueIdHeader from httpClient, use in subsonic layer
- Fix variable shadowing (now → fetchNow) in NowPlayingPanel fetchList
- Fix onBeforeDestroy nested dep (read isRadio from ref instead)
- Only broadcast SSE on state transitions, not heartbeat position updates
- Only enqueue NowPlaying to external scrobblers on state transitions

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

* fix(ui): use trailing-edge debounce for NowPlaying fetch

Replace the leading-edge throttle (which fetched on the first event
and blocked subsequent ones) with a trailing-edge debounce (300ms).
During track transitions, the burst of events (stopped → starting →
playing) now collapses into a single fetch after the burst settles,
showing the new track immediately instead of briefly showing empty.

* fix(ui): only show overlay on NowPlaying artwork when paused

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

* refactor(ui): remove unnecessary sendBeacon fallback from reportPlaybackSync

* refactor(ui): rename reportPlaybackSync to reportPlaybackKeepalive

The function was never synchronous — it uses fetch with keepalive:true,
which is fire-and-forget. The name now reflects the actual behavior.

* style: format code with prettier

* test: add tests for reportPlayback SSE broadcast and UI changes

- play_tracker: verify SSE broadcast on every state transition and
  that broadcasts are skipped when EnableNowPlaying is false
- activityReducer: verify nowPlayingLastUpdate timestamp is set
- subsonic/index: verify reportPlayback URL construction

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

* fix(ui): prevent NowPlaying from fetching every second when panel is open

fetchList had unstable identity because it depended on doFetch
(which depended on notify/dispatch). Each 1s setNow re-render
recreated the callback chain, re-triggering the useEffect that
calls fetchList. Use a ref for the fetch logic so fetchList has
a stable identity with empty deps.

* fix(ui): break fetch→dispatch→effect→fetch loop in NowPlaying panel

The fetch dispatched nowPlayingCountUpdate on every result, which
updated nowPlayingLastUpdate in Redux, which triggered the SSE
effect to call fetchList again — creating a fetch loop.

Fix: remove dispatch from fetch results. The badge count uses
entries.length (from local state) when entries are loaded, falling
back to Redux count (from SSE) when they aren't. SSE events remain
the only trigger for nowPlayingLastUpdate, breaking the loop.

* fix(ui): clear NowPlaying entries on panel close so badge uses SSE count

* style: format code with prettier

* fix: address code review feedback

- Fix currentTime truthiness check to handle position 0 correctly
- Report actual player state (playing/paused) on seek instead of
  always sending 'playing'
- Remove idx from React key to avoid reorder issues
- Add debounce timer cleanup on unmount
- Keep entries on panel close so badge stays accurate from polling
- Fix test description to match actual assertion

* fix(ui): keep NowPlaying badge count accurate from polling

Add a separate nowPlayingCountSync action that updates the Redux
count without setting nowPlayingLastUpdate (which would trigger
the SSE effect and cause a fetch loop). Polling results now sync
the badge count via this action, so the badge stays accurate even
when SSE is unavailable.

* style: format code with prettier

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-05-01 15:27:32 -04:00
committed by GitHub
parent 556f345a10
commit 7e16b6acb5
18 changed files with 517 additions and 196 deletions
+2
View File
@@ -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)
+6 -5
View File
@@ -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
+1 -2
View File
@@ -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()})
}
+63
View File
@@ -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{
+1
View File
@@ -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,
+1
View File
@@ -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() {
+2 -1
View File
@@ -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,
+6
View File
@@ -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: {},
+109 -61
View File
@@ -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}
+1
View File
@@ -33,6 +33,7 @@ const defaultConfig = {
enableExternalServices: true,
enableCoverAnimation: true,
enableNowPlaying: true,
playbackReportIntervalMs: 60000,
devShowArtistPage: true,
devUIShowConfig: true,
devNewEventStream: false,
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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
+222 -84
View File
@@ -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 (
<ListItem key={nowPlayingEntry.playerId} className={classes.listItem}>
<ListItemAvatar>
<ListItem className={classes.listItem}>
<div className={classes.avatarContainer}>
<Link
to={`/album/${nowPlayingEntry.albumId}/show`}
onClick={onLinkClick}
@@ -134,30 +219,58 @@ const NowPlayingItem = React.memo(
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}
{isPaused && (
<div className={classes.stateOverlay}>
<FaPause className={classes.stateIcon} />
</div>
}
secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''}${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`}
/>
)}
</div>
<div className={classes.entryContent}>
<Typography
className={classes.trackTitle}
title={nowPlayingEntry.title}
>
{nowPlayingEntry.title}
</Typography>
{artistId ? (
<Link
to={getArtistLink(artistId)}
className={classes.artistLink}
onClick={onLinkClick}
>
{artistName}
</Link>
) : (
<Typography className={classes.trackDetail}>
{artistName}
</Typography>
)}
<Typography
className={classes.trackDetail}
title={nowPlayingEntry.album}
>
{nowPlayingEntry.album}
</Typography>
<div className={classes.progressRow}>
<span className={classes.progressTime}>
{formatDuration(positionSec)}
</span>
<LinearProgress
className={classes.progressBar}
variant="determinate"
value={Math.min(progress, 100)}
/>
<span className={classes.progressTime}>
{formatDuration(durationSec)}
</span>
</div>
<Typography className={classes.userInfo}>
{nowPlayingEntry.username}
{nowPlayingEntry.playerName
? ` (${nowPlayingEntry.playerName})`
: ''}
</Typography>
</div>
</ListItem>
)
},
@@ -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) => (
<NowPlayingItem
key={nowPlayingEntry.playerId}
key={`${nowPlayingEntry.username}-${nowPlayingEntry.playerName}`}
nowPlayingEntry={nowPlayingEntry}
onLinkClick={onLinkClick}
getArtistLink={getArtistLink}
now={now}
/>
))}
</List>
@@ -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}
/>
+23 -27
View File
@@ -70,7 +70,12 @@ describe('<NowPlayingPanel />', () => {
)
}
afterEach(() => {
vi.useRealTimers()
})
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockUseMediaQuery.mockReturnValue(false) // Default to large screen
@@ -105,10 +110,8 @@ describe('<NowPlayingPanel />', () => {
</Provider>,
)
// 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('<NowPlayingPanel />', () => {
</Provider>,
)
// 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('<NowPlayingPanel />', () => {
</Provider>,
)
// 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('<NowPlayingPanel />', () => {
</Provider>,
)
// 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('<NowPlayingPanel />', () => {
</Provider>,
)
// 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('<NowPlayingPanel />', () => {
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('<NowPlayingPanel />', () => {
</Provider>,
)
// 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', () => {
+8
View File
@@ -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() }
+17
View File
@@ -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,
+21 -12
View File
@@ -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,
+30
View File
@@ -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')
})
})