mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
b8fee84cd5
* 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)
82 lines
3.5 KiB
TypeScript
82 lines
3.5 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|