From 733fab0fcb3f9c30522bf9534926a385edecd30d Mon Sep 17 00:00:00 2001 From: hiyukoim <11803292+hiyukoim@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:52:44 +0200 Subject: [PATCH] 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 Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com> --- ui/src/audioplayer/AudioTitle.jsx | 14 ++++- ui/src/audioplayer/AudioTitle.test.jsx | 78 +++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 4 deletions(-) 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() + }) })