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