From 2f82b7a75207e931d7f5224e06152cf0957e954f Mon Sep 17 00:00:00 2001
From: Yuko <11803292+hiyukoim@users.noreply.github.com>
Date: Tue, 30 Jun 2026 23:40:10 +0200
Subject: [PATCH] feat(ui): show live radio titles in player
Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com>
---
ui/src/actions/serverEvents.js | 1 +
ui/src/audioplayer/AudioTitle.jsx | 3 +-
ui/src/audioplayer/AudioTitle.test.jsx | 20 +++++
ui/src/audioplayer/Player.jsx | 83 ++++++++++++++++++--
ui/src/eventStream.js | 12 ++-
ui/src/eventStream.test.js | 22 +++++-
ui/src/reducers/playerReducer.js | 52 +++++++++++++
ui/src/reducers/playerReducer.test.js | 100 +++++++++++++++++++++++++
8 files changed, 284 insertions(+), 9 deletions(-)
diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js
index 892f2e27e..32751bbbc 100644
--- a/ui/src/actions/serverEvents.js
+++ b/ui/src/actions/serverEvents.js
@@ -3,6 +3,7 @@ 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_RADIO_NOW_PLAYING = 'radioNowPlaying'
export const EVENT_STREAM_RECONNECTED = 'streamReconnected'
export const processEvent = (type, data) => ({
diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx
index df37edfbb..20eb0061c 100644
--- a/ui/src/audioplayer/AudioTitle.jsx
+++ b/ui/src/audioplayer/AudioTitle.jsx
@@ -45,7 +45,8 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
: {}
const subtitle = song.tags?.['subtitle']
- const title = song.title + (subtitle ? ` (${subtitle})` : '')
+ const displayTitle = audioInfo.radioTitle || song.radioTitle || song.title
+ const title = displayTitle + (subtitle ? ` (${subtitle})` : '')
const linkTo = audioInfo.isRadio
? `/radio/${audioInfo.trackId}/show`
diff --git a/ui/src/audioplayer/AudioTitle.test.jsx b/ui/src/audioplayer/AudioTitle.test.jsx
index 7b297c07e..37a0918ab 100644
--- a/ui/src/audioplayer/AudioTitle.test.jsx
+++ b/ui/src/audioplayer/AudioTitle.test.jsx
@@ -55,4 +55,24 @@ describe('', () => {
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toBe('/album/album-1/show')
})
+
+ it('shows live radio title without changing the station link', () => {
+ const audioInfo = {
+ trackId: 'rd-1',
+ isRadio: true,
+ radioTitle: 'Artist - Title',
+ song: {
+ id: 'rd-1',
+ title: 'Test Station',
+ artist: 'Test Station',
+ album: 'https://stream.example.test/radio',
+ },
+ }
+ render()
+
+ expect(screen.getByText('Artist - Title')).toBeInTheDocument()
+ expect(screen.getByRole('link').getAttribute('href')).toBe(
+ '/radio/rd-1/show',
+ )
+ })
})
diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx
index c3d795b26..91657d5ed 100644
--- a/ui/src/audioplayer/Player.jsx
+++ b/ui/src/audioplayer/Player.jsx
@@ -34,6 +34,18 @@ import { keyMap } from '../hotkeys'
import keyHandlers from './keyHandlers'
import { calculateGain } from '../utils/calculateReplayGain'
import { detectBrowserProfile, decisionService } from '../transcode'
+import { httpClient } from '../dataProvider'
+import { REST_URL } from '../consts'
+
+const radioNowPlayingURL = (radioId) =>
+ `${REST_URL}/radio/${encodeURIComponent(radioId)}/nowplaying`
+
+const requestRadioNowPlaying = (radioId, method) => {
+ if (!radioId) {
+ return Promise.resolve()
+ }
+ return httpClient(radioNowPlayingURL(radioId), { method }).catch(() => {})
+}
const Player = () => {
const theme = useCurrentTheme()
@@ -46,6 +58,7 @@ const Player = () => {
const [heartbeatTrackId, setHeartbeatTrackId] = useState(null)
const lastPositionMsRef = useRef(0)
const currentTrackIdRef = useRef(null)
+ const currentRadioIdRef = useRef(null)
const stoppedRef = useRef(false)
const [audioInstance, setAudioInstance] = useState(null)
const isDesktop = useMediaQuery('(min-width:810px)')
@@ -63,6 +76,30 @@ const Player = () => {
currentTrackIdRef.current = currentTrackId
+ const stopRadioNowPlaying = useCallback(
+ (radioId = currentRadioIdRef.current) => {
+ if (!radioId) {
+ return
+ }
+ requestRadioNowPlaying(radioId, 'DELETE')
+ if (currentRadioIdRef.current === radioId) {
+ currentRadioIdRef.current = null
+ }
+ },
+ [],
+ )
+
+ const startRadioNowPlaying = useCallback((radioId) => {
+ if (!radioId || currentRadioIdRef.current === radioId) {
+ return
+ }
+ if (currentRadioIdRef.current) {
+ requestRadioNowPlaying(currentRadioIdRef.current, 'DELETE')
+ }
+ currentRadioIdRef.current = radioId
+ requestRadioNowPlaying(radioId, 'POST')
+ }, [])
+
useInterval(
() => {
if (heartbeatTrackId && !stoppedRef.current) {
@@ -180,6 +217,10 @@ const Player = () => {
}
const handlePageHide = () => {
+ if (playerState.current?.isRadio) {
+ stopRadioNowPlaying(playerState.current.trackId)
+ return
+ }
if (currentTrackIdRef.current && !playerState.current?.isRadio) {
stoppedRef.current = true
try {
@@ -200,7 +241,7 @@ const Player = () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('pagehide', handlePageHide)
}
- }, [playerState, audioInstance])
+ }, [playerState, audioInstance, stopRadioNowPlaying])
const defaultOptions = useMemo(
() => ({
@@ -285,6 +326,15 @@ const Player = () => {
}
dispatch(currentPlaying(info))
+ if (info.isRadio) {
+ startRadioNowPlaying(info.trackId)
+ setHeartbeatTrackId(null)
+ setCurrentTrackId(null)
+ return
+ }
+ if (currentRadioIdRef.current) {
+ stopRadioNowPlaying()
+ }
if (info.duration) {
const song = info.song
document.title = `${song.title} - ${song.artist} - Navidrome`
@@ -320,10 +370,20 @@ const Player = () => {
}
}
},
- [context, dispatch, showNotifications, currentTrackId],
+ [
+ context,
+ dispatch,
+ showNotifications,
+ currentTrackId,
+ startRadioNowPlaying,
+ stopRadioNowPlaying,
+ ],
)
const onAudioPlayTrackChange = useCallback(() => {
+ if (currentRadioIdRef.current) {
+ stopRadioNowPlaying()
+ }
if (currentTrackId) {
subsonic.reportPlayback(
currentTrackId,
@@ -333,11 +393,16 @@ const Player = () => {
}
setHeartbeatTrackId(null)
setCurrentTrackId(null)
- }, [currentTrackId])
+ }, [currentTrackId, stopRadioNowPlaying])
const onAudioPause = useCallback(
(info) => {
dispatch(currentPlaying(info))
+ if (info.isRadio) {
+ stopRadioNowPlaying(info.trackId)
+ setHeartbeatTrackId(null)
+ return
+ }
if (!info.isRadio && currentTrackId) {
const posMs = Math.floor(info.currentTime * 1000)
lastPositionMsRef.current = posMs
@@ -345,11 +410,14 @@ const Player = () => {
}
setHeartbeatTrackId(null)
},
- [dispatch, currentTrackId],
+ [dispatch, currentTrackId, stopRadioNowPlaying],
)
const onAudioEnded = useCallback(
(currentPlayId, audioLists, info) => {
+ if (info.isRadio) {
+ stopRadioNowPlaying(info.trackId)
+ }
if (currentTrackId && !info.isRadio) {
const posMs = Math.floor((info.duration || 0) * 1000)
subsonic.reportPlayback(currentTrackId, posMs, 'stopped')
@@ -362,7 +430,7 @@ const Player = () => {
// eslint-disable-next-line no-console
.catch((e) => console.log('Keepalive error:', e))
},
- [dispatch, dataProvider, currentTrackId],
+ [dispatch, dataProvider, currentTrackId, stopRadioNowPlaying],
)
const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
@@ -395,6 +463,9 @@ const Player = () => {
const onBeforeDestroy = useCallback(() => {
return new Promise((resolve, reject) => {
+ if (currentRadioIdRef.current) {
+ stopRadioNowPlaying()
+ }
if (currentTrackId && !playerStateRef.current?.current?.isRadio) {
subsonic.reportPlayback(
currentTrackId,
@@ -407,7 +478,7 @@ const Player = () => {
dispatch(clearQueue())
reject()
})
- }, [dispatch, currentTrackId])
+ }, [dispatch, currentTrackId, stopRadioNowPlaying])
if (!visible) {
document.title = 'Navidrome'
diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js
index c91dae875..21a3c1f02 100644
--- a/ui/src/eventStream.js
+++ b/ui/src/eventStream.js
@@ -1,6 +1,11 @@
import { baseUrl } from './utils'
import throttle from 'lodash.throttle'
-import { processEvent, serverDown, streamReconnected } from './actions'
+import {
+ EVENT_RADIO_NOW_PLAYING,
+ processEvent,
+ serverDown,
+ streamReconnected,
+} from './actions'
import { REST_URL } from './consts'
import config from './config'
@@ -20,6 +25,7 @@ const setupHandlers = (stream, dispatchFn) => {
stream.addEventListener('serverStart', eventHandler(dispatchFn))
stream.addEventListener('scanStatus', throttledEventHandler(dispatchFn))
stream.addEventListener('refreshResource', eventHandler(dispatchFn))
+ stream.addEventListener(EVENT_RADIO_NOW_PLAYING, eventHandler(dispatchFn))
if (config.enableNowPlaying) {
stream.addEventListener('nowPlayingCount', eventHandler(dispatchFn))
}
@@ -76,6 +82,10 @@ const startEventStreamLegacy = async (dispatchFn) => {
throttledEventHandler(dispatchFn),
)
newStream.addEventListener('refreshResource', eventHandler(dispatchFn))
+ newStream.addEventListener(
+ EVENT_RADIO_NOW_PLAYING,
+ eventHandler(dispatchFn),
+ )
if (config.enableNowPlaying) {
newStream.addEventListener('nowPlayingCount', eventHandler(dispatchFn))
}
diff --git a/ui/src/eventStream.test.js b/ui/src/eventStream.test.js
index 27f53c872..7df399a20 100644
--- a/ui/src/eventStream.test.js
+++ b/ui/src/eventStream.test.js
@@ -1,6 +1,6 @@
import { describe, it, beforeEach, vi, expect } from 'vitest'
import { startEventStream } from './eventStream'
-import { serverDown } from './actions'
+import { processEvent, serverDown } from './actions'
import config from './config'
class MockEventSource {
@@ -48,4 +48,24 @@ describe('startEventStream', () => {
vi.advanceTimersByTime(5000)
expect(global.EventSource).toHaveBeenCalledTimes(2)
})
+
+ it('dispatches radio now-playing events', async () => {
+ await startEventStream(dispatch)
+ instance.listeners.radioNowPlaying({
+ type: 'radioNowPlaying',
+ data: JSON.stringify({
+ radioId: 'rd-1',
+ title: 'Artist - Title',
+ updatedAt: '2026-06-30T12:34:56Z',
+ }),
+ })
+
+ expect(dispatch).toHaveBeenCalledWith(
+ processEvent('radioNowPlaying', {
+ radioId: 'rd-1',
+ title: 'Artist - Title',
+ updatedAt: '2026-06-30T12:34:56Z',
+ }),
+ )
+ })
})
diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js
index d6ab7484b..ad8662bc6 100644
--- a/ui/src/reducers/playerReducer.js
+++ b/ui/src/reducers/playerReducer.js
@@ -12,6 +12,7 @@ import {
PLAYER_SYNC_QUEUE,
PLAYER_SET_MODE,
PLAYER_REFRESH_QUEUE,
+ EVENT_RADIO_NOW_PLAYING,
} from '../actions'
import config from '../config'
@@ -208,6 +209,55 @@ const reduceMode = (state, { data: { mode } }) => {
}
}
+const activeQueueItem = (state) => {
+ if (state.current?.isRadio) {
+ return state.current
+ }
+ const index = state.playIndex ?? state.savedPlayIndex
+ return state.queue[index]
+}
+
+const applyRadioTitle = (item, title) => {
+ if (!item) {
+ return item
+ }
+ const song = { ...(item.song || {}) }
+ if (title) {
+ return {
+ ...item,
+ radioTitle: title,
+ song: { ...song, radioTitle: title },
+ }
+ }
+ delete song.radioTitle
+ const next = { ...item, song }
+ delete next.radioTitle
+ return next
+}
+
+const reduceRadioNowPlaying = (state, { data }) => {
+ const radioId = data?.radioId
+ const active = activeQueueItem(state)
+ if (!radioId || !active?.isRadio || active.trackId !== radioId) {
+ return state
+ }
+
+ const title = (data.title || '').trim()
+ const matchesActive = (item) =>
+ active.uuid ? item.uuid === active.uuid : item.trackId === radioId
+
+ return {
+ ...state,
+ queue: state.queue.map((item) =>
+ matchesActive(item) ? applyRadioTitle(item, title) : item,
+ ),
+ current:
+ state.current?.isRadio && state.current.trackId === radioId
+ ? applyRadioTitle(state.current, title)
+ : state.current,
+ }
+}
+
export const playerReducer = (previousState = initialState, payload) => {
const { type } = payload
switch (type) {
@@ -229,6 +279,8 @@ export const playerReducer = (previousState = initialState, payload) => {
return reduceCurrent(previousState, payload)
case PLAYER_SET_MODE:
return reduceMode(previousState, payload)
+ case EVENT_RADIO_NOW_PLAYING:
+ return reduceRadioNowPlaying(previousState, payload)
case PLAYER_REFRESH_QUEUE: {
const resolvedUrls = payload.data || {}
return {
diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js
index 110ce8c53..12a2c1a86 100644
--- a/ui/src/reducers/playerReducer.test.js
+++ b/ui/src/reducers/playerReducer.test.js
@@ -4,6 +4,7 @@ import {
PLAYER_SYNC_QUEUE,
PLAYER_CURRENT,
PLAYER_REFRESH_QUEUE,
+ EVENT_RADIO_NOW_PLAYING,
} from '../actions'
describe('playerReducer', () => {
@@ -224,4 +225,103 @@ describe('playerReducer', () => {
expect(result.playIndex).toBe(0)
})
})
+
+ describe('radioNowPlaying event', () => {
+ const radioItem = {
+ trackId: 'rd-1',
+ uuid: 'radio-uuid',
+ name: 'Test Station',
+ musicSrc: 'https://stream.example.test/radio',
+ isRadio: true,
+ song: {
+ id: 'rd-1',
+ title: 'Test Station',
+ artist: 'Test Station',
+ album: 'https://stream.example.test/radio',
+ streamUrl: 'https://stream.example.test/radio',
+ },
+ }
+
+ it('updates the active radio with live title metadata', () => {
+ const state = {
+ queue: [radioItem],
+ current: radioItem,
+ savedPlayIndex: 0,
+ clear: false,
+ volume: 1,
+ }
+ const result = playerReducer(state, {
+ type: EVENT_RADIO_NOW_PLAYING,
+ data: {
+ radioId: 'rd-1',
+ title: 'Artist - Title',
+ updatedAt: '2026-06-30T12:34:56Z',
+ },
+ })
+
+ expect(result.current.radioTitle).toBe('Artist - Title')
+ expect(result.current.song.radioTitle).toBe('Artist - Title')
+ expect(result.current.name).toBe('Test Station')
+ expect(result.current.song.title).toBe('Test Station')
+ expect(result.current.musicSrc).toBe('https://stream.example.test/radio')
+ expect(result.queue[0].radioTitle).toBe('Artist - Title')
+ })
+
+ it('ignores events for non-active radios', () => {
+ const state = {
+ queue: [radioItem],
+ current: radioItem,
+ savedPlayIndex: 0,
+ clear: false,
+ volume: 1,
+ }
+ const result = playerReducer(state, {
+ type: EVENT_RADIO_NOW_PLAYING,
+ data: { radioId: 'rd-2', title: 'Other Station - Title' },
+ })
+
+ expect(result).toBe(state)
+ })
+
+ it('clears live title metadata when the title is empty', () => {
+ const liveRadio = {
+ ...radioItem,
+ radioTitle: 'Old Title',
+ song: { ...radioItem.song, radioTitle: 'Old Title' },
+ }
+ const state = {
+ queue: [liveRadio],
+ current: liveRadio,
+ savedPlayIndex: 0,
+ clear: false,
+ volume: 1,
+ }
+ const result = playerReducer(state, {
+ type: EVENT_RADIO_NOW_PLAYING,
+ data: { radioId: 'rd-1', title: '' },
+ })
+
+ expect(result.current.radioTitle).toBeUndefined()
+ expect(result.current.song.radioTitle).toBeUndefined()
+ expect(result.current.song.title).toBe('Test Station')
+ expect(result.current.musicSrc).toBe('https://stream.example.test/radio')
+ })
+
+ it('updates the queued active radio before current is set', () => {
+ const state = {
+ queue: [radioItem],
+ current: {},
+ savedPlayIndex: 0,
+ clear: false,
+ volume: 1,
+ }
+ const result = playerReducer(state, {
+ type: EVENT_RADIO_NOW_PLAYING,
+ data: { radioId: 'rd-1', title: 'Artist - Title' },
+ })
+
+ expect(result.current).toEqual({})
+ expect(result.queue[0].radioTitle).toBe('Artist - Title')
+ })
+ })
})