feat(ui): show live radio titles in player

Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com>
This commit is contained in:
Yuko 2026-06-30 23:40:10 +02:00 committed by hiyukoim
parent 2109175304
commit 2f82b7a752
8 changed files with 284 additions and 9 deletions

View File

@ -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) => ({

View File

@ -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`

View File

@ -55,4 +55,24 @@ describe('<AudioTitle />', () => {
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(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />)
expect(screen.getByText('Artist - Title')).toBeInTheDocument()
expect(screen.getByRole('link').getAttribute('href')).toBe(
'/radio/rd-1/show',
)
})
})

View File

@ -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'

View File

@ -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))
}

View File

@ -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',
}),
)
})
})

View File

@ -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 {

View File

@ -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')
})
})
})