diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx
index 20eb0061c..575f5c534 100644
--- a/ui/src/audioplayer/AudioTitle.jsx
+++ b/ui/src/audioplayer/AudioTitle.jsx
@@ -1,6 +1,7 @@
import React from 'react'
import { useMediaQuery } from '@material-ui/core'
import { Link } from 'react-router-dom'
+import { useSelector } from 'react-redux'
import clsx from 'clsx'
import { QualityInfo } from '../common'
import { decisionService } from '../transcode'
@@ -23,6 +24,16 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
[song],
)
+ // The react-jinke-music-player library keeps its own internal snapshot of
+ // the queue item (audioInfo), copied when playback starts. Redux updates
+ // to state.player.current never reach that snapshot, so we read the live
+ // radio title from redux directly rather than relying on audioInfo alone.
+ const liveRadioTitle = useSelector((state) =>
+ audioInfo.isRadio && state.player?.current?.trackId === audioInfo.trackId
+ ? state.player.current.radioTitle
+ : undefined,
+ )
+
if (!song) {
return ''
}
@@ -45,7 +56,8 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
: {}
const subtitle = song.tags?.['subtitle']
- const displayTitle = audioInfo.radioTitle || song.radioTitle || song.title
+ const displayTitle =
+ liveRadioTitle || audioInfo.radioTitle || song.radioTitle || song.title
const title = displayTitle + (subtitle ? ` (${subtitle})` : '')
const linkTo = audioInfo.isRadio
diff --git a/ui/src/audioplayer/AudioTitle.test.jsx b/ui/src/audioplayer/AudioTitle.test.jsx
index 37a0918ab..cca732768 100644
--- a/ui/src/audioplayer/AudioTitle.test.jsx
+++ b/ui/src/audioplayer/AudioTitle.test.jsx
@@ -1,6 +1,8 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { Provider } from 'react-redux'
+import { createStore } from 'redux'
import AudioTitle from './AudioTitle'
vi.mock('@material-ui/core', async () => {
@@ -24,6 +26,11 @@ vi.mock('react-dnd', () => ({
useDrag: vi.fn(() => [null, () => {}]),
}))
+const renderWithStore = (ui, playerState = {}) => {
+ const store = createStore(() => ({ player: playerState }))
+ return render({ui})
+}
+
describe('', () => {
const baseSong = {
id: 'song-1',
@@ -41,7 +48,9 @@ describe('', () => {
it('links to playlist when playlistId is provided', () => {
const audioInfo = { trackId: 'track-1', song: baseSong }
- render()
+ renderWithStore(
+ ,
+ )
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toBe('/playlist/playlist-1/show')
})
@@ -51,7 +60,9 @@ describe('', () => {
trackId: 'track-1',
song: { ...baseSong, playlistId: undefined },
}
- render()
+ renderWithStore(
+ ,
+ )
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toBe('/album/album-1/show')
})
@@ -68,11 +79,72 @@ describe('', () => {
album: 'https://stream.example.test/radio',
},
}
- render()
+ renderWithStore(
+ ,
+ )
expect(screen.getByText('Artist - Title')).toBeInTheDocument()
expect(screen.getByRole('link').getAttribute('href')).toBe(
'/radio/rd-1/show',
)
})
+
+ it('prefers the live redux title over the stale player-library snapshot', () => {
+ const audioInfo = {
+ trackId: 'radio-1',
+ isRadio: true,
+ // Note: no radioTitle here — this simulates react-jinke-music-player's
+ // stale internal snapshot, which never receives redux updates.
+ song: {
+ id: 'radio-1',
+ title: 'Station Name',
+ artist: 'Station Name',
+ album: 'https://stream.example.test/radio',
+ },
+ }
+ renderWithStore(
+ ,
+ {
+ current: {
+ isRadio: true,
+ trackId: 'radio-1',
+ radioTitle: 'Live Song - Artist',
+ },
+ },
+ )
+
+ expect(screen.getByText('Live Song - Artist')).toBeInTheDocument()
+ expect(screen.getByRole('link').getAttribute('href')).toBe(
+ '/radio/radio-1/show',
+ )
+ })
+
+ it('falls back to the station name when redux current is a different radio', () => {
+ const audioInfo = {
+ trackId: 'radio-1',
+ isRadio: true,
+ song: {
+ id: 'radio-1',
+ title: 'Station Name',
+ // Artist deliberately differs from title so getByText stays unambiguous
+ artist: 'Station Artist',
+ album: 'https://stream.example.test/radio',
+ },
+ }
+ renderWithStore(
+ ,
+ {
+ current: {
+ isRadio: true,
+ trackId: 'radio-2',
+ radioTitle: 'Some Other Live Title',
+ },
+ },
+ )
+
+ expect(screen.getByText('Station Name')).toBeInTheDocument()
+ expect(
+ screen.queryByText('Some Other Live Title'),
+ ).not.toBeInTheDocument()
+ })
})