mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
feat(ui): show live radio titles in player
Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com>
This commit is contained in:
parent
2109175304
commit
2f82b7a752
@ -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) => ({
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user