diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js
index d1e55283..99553455 100644
--- a/ui/src/actions/serverEvents.js
+++ b/ui/src/actions/serverEvents.js
@@ -2,6 +2,7 @@ export const EVENT_SCAN_STATUS = 'scanStatus'
export const EVENT_SERVER_START = 'serverStart'
export const EVENT_REFRESH_RESOURCE = 'refreshResource'
export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount'
+export const EVENT_STREAM_RECONNECTED = 'streamReconnected'
export const processEvent = (type, data) => ({
type,
@@ -21,3 +22,8 @@ export const serverDown = () => ({
type: EVENT_SERVER_START,
data: {},
})
+
+export const streamReconnected = () => ({
+ type: EVENT_STREAM_RECONNECTED,
+ data: {},
+})
diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js
index 3d8ddcd1..c91dae87 100644
--- a/ui/src/eventStream.js
+++ b/ui/src/eventStream.js
@@ -1,6 +1,6 @@
import { baseUrl } from './utils'
import throttle from 'lodash.throttle'
-import { processEvent, serverDown } from './actions'
+import { processEvent, serverDown, streamReconnected } from './actions'
import { REST_URL } from './consts'
import config from './config'
@@ -47,6 +47,8 @@ const connect = async (dispatchFn) => {
const stream = await newEventStream()
eventStream = stream
setupHandlers(stream, dispatchFn)
+ // Dispatch reconnection event to refresh critical data
+ dispatchFn(streamReconnected())
return stream
} catch (e) {
// eslint-disable-next-line no-console
diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx
index 7797c773..4aaee1be 100644
--- a/ui/src/layout/NowPlayingPanel.jsx
+++ b/ui/src/layout/NowPlayingPanel.jsx
@@ -245,6 +245,12 @@ NowPlayingList.propTypes = {
const NowPlayingPanel = () => {
const dispatch = useDispatch()
const count = useSelector((state) => state.activity.nowPlayingCount)
+ const streamReconnected = useSelector(
+ (state) => state.activity.streamReconnected,
+ )
+ const serverUp = useSelector(
+ (state) => !!state.activity.serverStart.startTime,
+ )
const translate = useTranslate()
const notify = useNotify()
const theme = useTheme()
@@ -301,23 +307,32 @@ const NowPlayingPanel = () => {
[dispatch, notify],
)
- // Initialize count and entries on mount
+ // Initialize count and entries on mount, and refresh on server/stream changes
useEffect(() => {
- fetchList()
- }, [fetchList])
+ if (serverUp) fetchList()
+ }, [fetchList, serverUp, streamReconnected])
// Refresh when count changes from WebSocket events (if panel is open)
useEffect(() => {
- if (open) fetchList()
- }, [count, open, fetchList])
+ if (open && serverUp) fetchList()
+ }, [count, open, fetchList, serverUp])
+ // Periodic refresh when panel is open (10 seconds)
useInterval(
() => {
- if (open) fetchList()
+ if (open && serverUp) fetchList()
},
open ? 10000 : null,
)
+ // Periodic refresh when panel is closed (60 seconds) to keep badge accurate
+ useInterval(
+ () => {
+ if (!open && serverUp) fetchList()
+ },
+ !open ? 60000 : null,
+ )
+
return (
diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx
index 6cc332fc..4dd5dac8 100644
--- a/ui/src/layout/NowPlayingPanel.test.jsx
+++ b/ui/src/layout/NowPlayingPanel.test.jsx
@@ -55,6 +55,21 @@ vi.mock('@material-ui/core/styles/useTheme', () => ({
}))
describe('
', () => {
+ const createMockStore = (overrides = {}) => {
+ const defaultState = {
+ activity: {
+ nowPlayingCount: 1,
+ serverStart: { startTime: Date.now() }, // Server is up by default
+ streamReconnected: 0,
+ ...overrides,
+ },
+ }
+ return createStore(
+ combineReducers({ activity: activityReducer }),
+ defaultState,
+ )
+ }
+
beforeEach(() => {
vi.clearAllMocks()
mockUseMediaQuery.mockReturnValue(false) // Default to large screen
@@ -83,9 +98,7 @@ describe('
', () => {
})
it('fetches and displays entries when opened', async () => {
- const store = createStore(combineReducers({ activity: activityReducer }), {
- activity: { nowPlayingCount: 1 },
- })
+ const store = createMockStore()
render(
@@ -108,9 +121,7 @@ describe('', () => {
})
it('displays player name after username', async () => {
- const store = createStore(combineReducers({ activity: activityReducer }), {
- activity: { nowPlayingCount: 1 },
- })
+ const store = createMockStore()
render(
@@ -152,9 +163,7 @@ describe('', () => {
},
})
- const store = createStore(combineReducers({ activity: activityReducer }), {
- activity: { nowPlayingCount: 1 },
- })
+ const store = createMockStore()
render(
@@ -178,9 +187,7 @@ describe('', () => {
'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } },
},
})
- const store = createStore(combineReducers({ activity: activityReducer }), {
- activity: { nowPlayingCount: 0 },
- })
+ const store = createMockStore({ nowPlayingCount: 0 })
render(
@@ -201,9 +208,7 @@ describe('', () => {
it('does not close panel when artist link is clicked on large screens', async () => {
mockUseMediaQuery.mockReturnValue(false) // Simulate large screen
- const store = createStore(combineReducers({ activity: activityReducer }), {
- activity: { nowPlayingCount: 1 },
- })
+ const store = createMockStore()
render(
@@ -231,4 +236,132 @@ describe('', () => {
expect(screen.getByRole('presentation')).toBeInTheDocument()
expect(screen.getByText('Artist')).toBeInTheDocument()
})
+
+ it('does not fetch on mount when server is down', () => {
+ const store = createMockStore({
+ nowPlayingCount: 1,
+ serverStart: { startTime: null }, // Server is down
+ })
+ render(
+
+
+ ,
+ )
+
+ // Should not have made initial fetch request due to server being down
+ expect(subsonic.getNowPlaying).not.toHaveBeenCalled()
+ })
+
+ it('does not fetch on stream reconnection when server is down', () => {
+ const store = createMockStore({
+ nowPlayingCount: 1,
+ serverStart: { startTime: null }, // Server is down
+ streamReconnected: Date.now(), // Stream reconnected
+ })
+ render(
+
+
+ ,
+ )
+
+ // Should not have made fetch request due to server being down
+ expect(subsonic.getNowPlaying).not.toHaveBeenCalled()
+ })
+
+ it('does not double-fetch on server reconnection', () => {
+ const initialStore = createMockStore({
+ nowPlayingCount: 1,
+ serverStart: { startTime: null }, // Server initially down
+ streamReconnected: 0,
+ })
+ const { rerender } = render(
+
+
+ ,
+ )
+
+ // Clear initial (empty) calls
+ vi.clearAllMocks()
+
+ // Simulate server coming back up with stream reconnection (both state changes happen)
+ const reconnectedStore = createMockStore({
+ nowPlayingCount: 1,
+ serverStart: { startTime: Date.now() }, // Server back up
+ streamReconnected: Date.now(), // Stream reconnected
+ })
+ rerender(
+
+
+ ,
+ )
+
+ // Should only make one call despite both serverUp and streamReconnected changing
+ expect(subsonic.getNowPlaying).toHaveBeenCalledTimes(1)
+ })
+
+ it('skips polling when server is down', () => {
+ vi.useFakeTimers()
+
+ const store = createMockStore({
+ nowPlayingCount: 1,
+ serverStart: { startTime: null }, // Server is down
+ })
+ render(
+
+
+ ,
+ )
+
+ // Clear initial mount fetch
+ vi.clearAllMocks()
+
+ // Advance time by 70 seconds to trigger polling interval
+ vi.advanceTimersByTime(70000)
+
+ // Should not have made any additional requests due to server being down
+ expect(subsonic.getNowPlaying).not.toHaveBeenCalled()
+
+ vi.useRealTimers()
+ })
+
+ it('resumes polling when server comes back up', () => {
+ vi.useFakeTimers()
+
+ const store = createMockStore({
+ nowPlayingCount: 1,
+ serverStart: { startTime: null }, // Server is down
+ })
+ const { rerender } = render(
+
+
+ ,
+ )
+
+ // Clear initial mount fetch
+ vi.clearAllMocks()
+
+ // Advance time - should not poll when server is down
+ vi.advanceTimersByTime(70000)
+ expect(subsonic.getNowPlaying).not.toHaveBeenCalled()
+
+ // Update state to indicate server is back up
+ const updatedStore = createMockStore({
+ nowPlayingCount: 1,
+ serverStart: { startTime: Date.now() }, // Server is back up
+ })
+ rerender(
+
+
+ ,
+ )
+
+ // Clear the fetch that happens due to initial mount of rerender
+ vi.clearAllMocks()
+
+ // Advance time again - should now poll since server is up
+ vi.advanceTimersByTime(70000)
+ expect(subsonic.getNowPlaying).toHaveBeenCalled()
+
+ vi.useRealTimers()
+ })
})
diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js
index 874ebb53..8238e395 100644
--- a/ui/src/reducers/activityReducer.js
+++ b/ui/src/reducers/activityReducer.js
@@ -3,6 +3,7 @@ import {
EVENT_SCAN_STATUS,
EVENT_SERVER_START,
EVENT_NOW_PLAYING_COUNT,
+ EVENT_STREAM_RECONNECTED,
} from '../actions'
import config from '../config'
@@ -16,6 +17,7 @@ const initialState = {
},
serverStart: { version: config.version },
nowPlayingCount: 0,
+ streamReconnected: 0, // Timestamp of last reconnection
}
export const activityReducer = (previousState = initialState, payload) => {
@@ -44,6 +46,8 @@ export const activityReducer = (previousState = initialState, payload) => {
}
case EVENT_NOW_PLAYING_COUNT:
return { ...previousState, nowPlayingCount: data.count }
+ case EVENT_STREAM_RECONNECTED:
+ return { ...previousState, streamReconnected: Date.now() }
default:
return previousState
}
diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js
index 7c1d8b08..c9db38db 100644
--- a/ui/src/reducers/activityReducer.test.js
+++ b/ui/src/reducers/activityReducer.test.js
@@ -3,6 +3,7 @@ import {
EVENT_SCAN_STATUS,
EVENT_SERVER_START,
EVENT_NOW_PLAYING_COUNT,
+ EVENT_STREAM_RECONNECTED,
} from '../actions'
import config from '../config'
@@ -17,6 +18,7 @@ describe('activityReducer', () => {
},
serverStart: { version: config.version },
nowPlayingCount: 0,
+ streamReconnected: 0,
}
it('returns the initial state when no action is specified', () => {
@@ -130,4 +132,17 @@ describe('activityReducer', () => {
const newState = activityReducer(initialState, action)
expect(newState.nowPlayingCount).toEqual(5)
})
+
+ it('handles EVENT_STREAM_RECONNECTED', () => {
+ const action = {
+ type: EVENT_STREAM_RECONNECTED,
+ data: {},
+ }
+ const beforeTimestamp = Date.now()
+ const newState = activityReducer(initialState, action)
+ const afterTimestamp = Date.now()
+
+ expect(newState.streamReconnected).toBeGreaterThanOrEqual(beforeTimestamp)
+ expect(newState.streamReconnected).toBeLessThanOrEqual(afterTimestamp)
+ })
})