mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
* fix(radio): show ICY track in OS media controls (#816) Internet radio streams through the WebView <audio> element, for which WebKitGTK registers its own MPRIS player — the one Linux desktops show. souvlaki metadata pushes were overridden by it, so the OS overlay only ever showed the app name. Feed the resolved ICY/AzuraCast metadata to that player via navigator.mediaSession (and mirror to souvlaki), so the overlay updates per track. Falls back to the station name when a stream sends no metadata. * docs(changelog): radio track info in OS media controls (#924)
This commit is contained in:
committed by
GitHub
parent
a0980379fa
commit
b8fee84cd5
@@ -324,6 +324,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Fixed
|
## 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
|
### Analytics — Opus waveform and loudness analysis
|
||||||
|
|
||||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#883](https://github.com/Psychotoxical/psysonic/pull/883)**
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#883](https://github.com/Psychotoxical/psysonic/pull/883)**
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useLyricsStore } from '../store/lyricsStore';
|
|||||||
import MarqueeText from './MarqueeText';
|
import MarqueeText from './MarqueeText';
|
||||||
import LastfmIcon from './LastfmIcon';
|
import LastfmIcon from './LastfmIcon';
|
||||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||||
|
import { useRadioMprisSync } from '../hooks/useRadioMprisSync';
|
||||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
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.
|
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
|
||||||
const radioMeta = useRadioMetadata(currentRadio ?? null);
|
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
|
const isStarred = currentTrack
|
||||||
|
|||||||
@@ -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<string, unknown>) { 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>): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 `<audio>` element
|
||||||
|
* (`radioPlayer.ts`). WebKitGTK auto-registers its **own** MPRIS player for that
|
||||||
|
* element, and on Linux desktops the OS overlay shows *that* player — not the
|
||||||
|
* souvlaki one we drive from Rust. With no metadata fed to it, it just shows the
|
||||||
|
* app name ("Psysonic") and never updates per track (issue #816, verified via
|
||||||
|
* D-Bus: the visible player is `org.mpris.MediaPlayer2.org.webkit.*`).
|
||||||
|
*
|
||||||
|
* The fix is to feed WebKit's player through the standard W3C
|
||||||
|
* `navigator.mediaSession` API — the same channel browsers use to surface
|
||||||
|
* `<audio>`/`<video>` metadata to the OS. We also mirror to the souvlaki
|
||||||
|
* controls (`mpris_set_metadata`) so desktops that surface *that* player instead
|
||||||
|
* stay correct too. `useRadioMetadata` already resolves per-track title/artist
|
||||||
|
* for the in-app UI, so we just forward the same values.
|
||||||
|
*
|
||||||
|
* Mount exactly once (in the always-present `PlayerBar`). When the station
|
||||||
|
* sends no track metadata, the station-name push from `mprisSync` is left in
|
||||||
|
* place — no regression for metadata-less stations.
|
||||||
|
*/
|
||||||
|
export function useRadioMprisSync(
|
||||||
|
radioMeta: RadioMetadata,
|
||||||
|
currentRadio: InternetRadioStation | null,
|
||||||
|
): void {
|
||||||
|
const lastPushedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ms: MediaSession | undefined =
|
||||||
|
typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||||
|
|
||||||
|
if (!currentRadio) {
|
||||||
|
lastPushedRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No resolved track yet → keep the station-name push from mprisSync.
|
||||||
|
if (radioMeta.source === 'none' || !radioMeta.currentTitle) return;
|
||||||
|
|
||||||
|
const title = radioMeta.currentTitle;
|
||||||
|
const artist = radioMeta.currentArtist || currentRadio.name;
|
||||||
|
const album = radioMeta.currentAlbum || undefined;
|
||||||
|
const artUrl = radioMeta.currentArt || undefined;
|
||||||
|
|
||||||
|
const key = `${currentRadio.id}|${title}|${artist}|${artUrl ?? ''}`;
|
||||||
|
if (lastPushedRef.current === key) return;
|
||||||
|
lastPushedRef.current = key;
|
||||||
|
|
||||||
|
// Primary path on Linux/WebKit: populate WebKit's own MPRIS player.
|
||||||
|
if (ms && typeof MediaMetadata !== 'undefined') {
|
||||||
|
ms.metadata = new MediaMetadata({
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
artwork: artUrl ? [{ src: artUrl }] : undefined,
|
||||||
|
});
|
||||||
|
ms.playbackState = 'playing';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror to the souvlaki-backed controls for desktops that surface those.
|
||||||
|
invoke('mpris_set_metadata', {
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
album: album ?? null,
|
||||||
|
coverUrl: artUrl ?? null,
|
||||||
|
durationSecs: radioMeta.duration ?? null,
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [
|
||||||
|
currentRadio,
|
||||||
|
radioMeta.source,
|
||||||
|
radioMeta.currentTitle,
|
||||||
|
radioMeta.currentArtist,
|
||||||
|
radioMeta.currentAlbum,
|
||||||
|
radioMeta.currentArt,
|
||||||
|
radioMeta.duration,
|
||||||
|
]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user