diff --git a/CHANGELOG.md b/CHANGELOG.md index c6be3973..eb18d2c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -324,6 +324,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Fixed +### Radio — track info in OS media controls + +**By [@Psychotoxical](https://github.com/Psychotoxical), reported by agriffit79 on GitHub, PR [#924](https://github.com/Psychotoxical/psysonic/pull/924)** + +* The Linux media overlay (MPRIS) now shows the current **radio track and artist** instead of just "Psysonic", and updates as the stream changes songs. Internet radio plays through the WebView audio element, which exposes its own OS media player — that player is now fed the live ICY/AzuraCast metadata. Streams that send no metadata still fall back to the station name. + + + ### Analytics — Opus waveform and loudness analysis **By [@cucadmuh](https://github.com/cucadmuh), PR [#883](https://github.com/Psychotoxical/psysonic/pull/883)** diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 39f91673..34f32653 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -22,6 +22,7 @@ import { useLyricsStore } from '../store/lyricsStore'; import MarqueeText from './MarqueeText'; import LastfmIcon from './LastfmIcon'; import { useRadioMetadata } from '../hooks/useRadioMetadata'; +import { useRadioMprisSync } from '../hooks/useRadioMprisSync'; import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; import PlaybackDelayModal from './PlaybackDelayModal'; import PlaybackScheduleBadge from './PlaybackScheduleBadge'; @@ -125,6 +126,9 @@ export default function PlayerBar() { // Radio metadata (ICY or AzuraCast) — only active while a radio station is playing. const radioMeta = useRadioMetadata(currentRadio ?? null); + // Mirror resolved radio track metadata to the OS media controls (issue #816). + // PlayerBar is the single always-mounted consumer, so push from here only. + useRadioMprisSync(radioMeta, currentRadio); const isStarred = currentTrack diff --git a/src/hooks/useRadioMprisSync.test.ts b/src/hooks/useRadioMprisSync.test.ts new file mode 100644 index 00000000..9ca26570 --- /dev/null +++ b/src/hooks/useRadioMprisSync.test.ts @@ -0,0 +1,81 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { invoke } from '@tauri-apps/api/core'; +import { useRadioMprisSync } from './useRadioMprisSync'; +import type { RadioMetadata } from './useRadioMetadata'; +import type { InternetRadioStation } from '../api/subsonicTypes'; + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(async () => undefined) })); +const invokeMock = vi.mocked(invoke); + +// jsdom has no MediaSession / MediaMetadata — stub the standard W3C shapes. +class FakeMediaMetadata { + title?: string; + artist?: string; + album?: string; + artwork?: unknown; + constructor(init: Record) { Object.assign(this, init); } +} +const ms = { metadata: null as FakeMediaMetadata | null, playbackState: 'none' }; + +beforeEach(() => { + vi.clearAllMocks(); + ms.metadata = null; + ms.playbackState = 'none'; + (globalThis as unknown as { MediaMetadata: unknown }).MediaMetadata = FakeMediaMetadata; + Object.defineProperty(navigator, 'mediaSession', { value: ms, configurable: true }); +}); + +const STATION = { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/s' } as InternetRadioStation; +const NONE: RadioMetadata = { source: 'none', history: [] }; +const meta = (over: Partial): RadioMetadata => ({ source: 'icy', history: [], ...over }); + +describe('useRadioMprisSync', () => { + it('feeds resolved radio metadata to navigator.mediaSession', async () => { + renderHook(() => + useRadioMprisSync(meta({ currentTitle: 'Celebrity Skin', currentArtist: 'Hole' }), STATION)); + await waitFor(() => { + expect(ms.metadata?.title).toBe('Celebrity Skin'); + expect(ms.metadata?.artist).toBe('Hole'); + expect(ms.playbackState).toBe('playing'); + }); + }); + + it('mirrors the same metadata to the souvlaki controls', async () => { + renderHook(() => useRadioMprisSync(meta({ currentTitle: 'Mind', currentArtist: 'TWO LANES' }), STATION)); + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith('mpris_set_metadata', + expect.objectContaining({ title: 'Mind', artist: 'TWO LANES' }))); + }); + + it('falls back to the station name as artist when ICY has no artist', async () => { + renderHook(() => useRadioMprisSync(meta({ currentTitle: 'Some Title' }), STATION)); + await waitFor(() => expect(ms.metadata?.artist).toBe('Test FM')); + }); + + it('does not push when the station sends no track metadata (no regression)', () => { + renderHook(() => useRadioMprisSync(NONE, STATION)); + expect(ms.metadata).toBeNull(); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it('does not push when no radio station is active', () => { + renderHook(() => useRadioMprisSync(meta({ currentTitle: 'X' }), null)); + expect(ms.metadata).toBeNull(); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it('dedupes unchanged re-emits but updates again when the track changes', async () => { + const { rerender } = renderHook(({ m }) => useRadioMprisSync(m, STATION), { + initialProps: { m: meta({ currentTitle: 'Track A', currentArtist: 'A' }) }, + }); + await waitFor(() => expect(invokeMock).toHaveBeenCalledTimes(1)); + rerender({ m: meta({ currentTitle: 'Track A', currentArtist: 'A' }) }); + expect(invokeMock).toHaveBeenCalledTimes(1); + rerender({ m: meta({ currentTitle: 'Track B', currentArtist: 'B' }) }); + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledTimes(2); + expect(ms.metadata?.title).toBe('Track B'); + }); + }); +}); diff --git a/src/hooks/useRadioMprisSync.ts b/src/hooks/useRadioMprisSync.ts new file mode 100644 index 00000000..6ccb0728 --- /dev/null +++ b/src/hooks/useRadioMprisSync.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import type { InternetRadioStation } from '../api/subsonicTypes'; +import type { RadioMetadata } from './useRadioMetadata'; + +/** + * Internet radio → OS media controls (MPRIS / SMTC / Now Playing). + * + * Internet-radio playback runs through the WebView `