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