mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-19 07:37:15 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -33,6 +33,7 @@ const defaultConfig = {
|
||||
enableExternalServices: true,
|
||||
enableCoverAnimation: true,
|
||||
enableNowPlaying: true,
|
||||
playbackReportIntervalMs: 60000,
|
||||
devShowArtistPage: true,
|
||||
devUIShowConfig: true,
|
||||
devNewEventStream: false,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
- {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}
|
||||
/>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user