fix(ui): read live radio title from redux in player title

The react-jinke-music-player library renders the title from its own
internal snapshot of the queue item, copied when playback starts, so
radioNowPlaying updates applied to the redux queue never became
visible. AudioTitle now subscribes to state.player.current.radioTitle
directly, guarded by isRadio and a trackId match, falling back to the
previous behaviour when no live title exists.

Fixes the visible half of #6; verified against real ICY stations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com>
This commit is contained in:
hiyukoim 2026-07-06 21:52:44 +02:00
parent b0675e0761
commit 733fab0fcb
2 changed files with 88 additions and 4 deletions

View File

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

View File

@ -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(<Provider store={store}>{ui}</Provider>)
}
describe('<AudioTitle />', () => {
const baseSong = {
id: 'song-1',
@ -41,7 +48,9 @@ describe('<AudioTitle />', () => {
it('links to playlist when playlistId is provided', () => {
const audioInfo = { trackId: 'track-1', song: baseSong }
render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />)
renderWithStore(
<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />,
)
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toBe('/playlist/playlist-1/show')
})
@ -51,7 +60,9 @@ describe('<AudioTitle />', () => {
trackId: 'track-1',
song: { ...baseSong, playlistId: undefined },
}
render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />)
renderWithStore(
<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />,
)
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toBe('/album/album-1/show')
})
@ -68,11 +79,72 @@ describe('<AudioTitle />', () => {
album: 'https://stream.example.test/radio',
},
}
render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />)
renderWithStore(
<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />,
)
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(
<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />,
{
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(
<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />,
{
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()
})
})