mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
refactor(radio): co-locate internet radio feature into features/radio
This commit is contained in:
@@ -1,218 +0,0 @@
|
||||
import type { InternetRadioStation } from '../api/subsonicTypes';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import {
|
||||
guessAzuraCastApiUrl,
|
||||
normaliseAzuraCastHomepageUrl,
|
||||
fetchAzuraCastNowPlaying,
|
||||
type AzuraCastNowPlaying,
|
||||
type AzuraCastSong,
|
||||
} from '../api/azuracast';
|
||||
|
||||
// ─── Public types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type RadioMetadataSource = 'azuracast' | 'icy' | 'none';
|
||||
|
||||
export interface RadioHistoryItem {
|
||||
song: AzuraCastSong;
|
||||
playedAt?: number; // unix timestamp
|
||||
}
|
||||
|
||||
export interface RadioMetadata {
|
||||
/** Metadata source that is currently active. */
|
||||
source: RadioMetadataSource;
|
||||
/** Station name (from ICY icy-name or AzuraCast station.name). */
|
||||
stationName?: string;
|
||||
/** Current track title (combined or individual fields). */
|
||||
currentTitle?: string;
|
||||
currentArtist?: string;
|
||||
currentAlbum?: string;
|
||||
currentArt?: string;
|
||||
/** AzuraCast-only: seconds elapsed in current track. */
|
||||
elapsed?: number;
|
||||
/** AzuraCast-only: total duration of current track in seconds. */
|
||||
duration?: number;
|
||||
/** AzuraCast-only: number of current listeners. */
|
||||
listeners?: number;
|
||||
/** AzuraCast-only: last N played tracks. */
|
||||
history: RadioHistoryItem[];
|
||||
/** AzuraCast-only: next track queued. */
|
||||
nextSong?: AzuraCastSong;
|
||||
}
|
||||
|
||||
// ─── ICY metadata interface (matches Rust IcyMetadata struct) ─────────────────
|
||||
|
||||
interface IcyMetadataResult {
|
||||
stream_title?: string;
|
||||
icy_name?: string;
|
||||
icy_genre?: string;
|
||||
icy_url?: string;
|
||||
icy_description?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseIcyStreamTitle(streamTitle: string): { artist?: string; title: string } {
|
||||
const sep = streamTitle.indexOf(' - ');
|
||||
if (sep !== -1) {
|
||||
return { artist: streamTitle.slice(0, sep).trim(), title: streamTitle.slice(sep + 3).trim() };
|
||||
}
|
||||
return { title: streamTitle };
|
||||
}
|
||||
|
||||
function nowPlayingToMetadata(np: AzuraCastNowPlaying): RadioMetadata {
|
||||
const nowPlaying = np.now_playing;
|
||||
const song = nowPlaying?.song;
|
||||
return {
|
||||
source: 'azuracast',
|
||||
stationName: np.station?.name,
|
||||
currentTitle: song?.title,
|
||||
currentArtist: song?.artist,
|
||||
currentAlbum: song?.album,
|
||||
currentArt: song?.art,
|
||||
elapsed: nowPlaying?.elapsed,
|
||||
duration: nowPlaying?.duration,
|
||||
listeners: np.listeners?.current,
|
||||
history: (np.song_history ?? []).slice(0, 5).map(h => ({
|
||||
song: h.song,
|
||||
playedAt: h.played_at,
|
||||
})),
|
||||
nextSong: np.playing_next?.song ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const AZURACAST_POLL_MS = 15_000;
|
||||
const ICY_POLL_MS = 30_000;
|
||||
const EMPTY_METADATA: RadioMetadata = { source: 'none', history: [] };
|
||||
|
||||
export function useRadioMetadata(station: InternetRadioStation | null): RadioMetadata {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const [metadata, setMetadata] = useState<RadioMetadata>(EMPTY_METADATA);
|
||||
|
||||
// Keep elapsed in sync while AzuraCast is active: advance 1 s/tick while playing.
|
||||
const elapsedRef = useRef<number | null>(null);
|
||||
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const stationRef = useRef<InternetRadioStation | null>(null);
|
||||
|
||||
// Store resolved AzuraCast API URL for the current station (or null).
|
||||
const azuraCastUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Stop the elapsed ticker.
|
||||
function stopElapsedTick() {
|
||||
if (elapsedIntervalRef.current) {
|
||||
clearInterval(elapsedIntervalRef.current);
|
||||
elapsedIntervalRef.current = null;
|
||||
}
|
||||
elapsedRef.current = null;
|
||||
}
|
||||
|
||||
// Start a 1-second elapsed ticker that advances the stored elapsed value and
|
||||
// updates the metadata state so the progress bar moves smoothly between polls.
|
||||
function startElapsedTick(initial: number) {
|
||||
stopElapsedTick();
|
||||
elapsedRef.current = initial;
|
||||
elapsedIntervalRef.current = setInterval(() => {
|
||||
if (elapsedRef.current === null) return;
|
||||
elapsedRef.current += 1;
|
||||
setMetadata(prev =>
|
||||
prev.source === 'azuracast'
|
||||
? { ...prev, elapsed: elapsedRef.current! }
|
||||
: prev
|
||||
);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (perfFlags.disableBackgroundPolling) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMetadata(EMPTY_METADATA);
|
||||
azuraCastUrlRef.current = null;
|
||||
stopElapsedTick();
|
||||
return;
|
||||
}
|
||||
if (!station) {
|
||||
setMetadata(EMPTY_METADATA);
|
||||
azuraCastUrlRef.current = null;
|
||||
stopElapsedTick();
|
||||
return;
|
||||
}
|
||||
|
||||
stationRef.current = station;
|
||||
setMetadata(EMPTY_METADATA);
|
||||
azuraCastUrlRef.current = null;
|
||||
stopElapsedTick();
|
||||
|
||||
let cancelled = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Determine which AzuraCast API URL to try, in priority order:
|
||||
// 1. Homepage URL if it matches the /api/nowplaying[/shortcode] pattern
|
||||
// 2. Guessed URL from stream URL path (/listen/<shortcode>/…)
|
||||
const candidateApiUrl =
|
||||
(station.homepageUrl ? normaliseAzuraCastHomepageUrl(station.homepageUrl) : null) ??
|
||||
guessAzuraCastApiUrl(station.streamUrl);
|
||||
|
||||
async function pollAzuraCast(apiUrl: string) {
|
||||
if (cancelled) return;
|
||||
const np = await fetchAzuraCastNowPlaying(apiUrl);
|
||||
if (cancelled) return;
|
||||
if (np) {
|
||||
const m = nowPlayingToMetadata(np);
|
||||
setMetadata(m);
|
||||
startElapsedTick(m.elapsed ?? 0);
|
||||
pollTimer = setTimeout(() => pollAzuraCast(apiUrl), AZURACAST_POLL_MS);
|
||||
} else {
|
||||
// AzuraCast check failed — fall back to ICY
|
||||
azuraCastUrlRef.current = null;
|
||||
pollIcy();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollIcy() {
|
||||
if (cancelled) return;
|
||||
const currentStation = stationRef.current;
|
||||
if (!currentStation) return;
|
||||
try {
|
||||
const result: IcyMetadataResult = await invoke('fetch_icy_metadata', { url: currentStation.streamUrl });
|
||||
if (cancelled) return;
|
||||
if (result.stream_title || result.icy_name) {
|
||||
const parsed = result.stream_title ? parseIcyStreamTitle(result.stream_title) : null;
|
||||
setMetadata({
|
||||
source: 'icy',
|
||||
stationName: result.icy_name,
|
||||
currentTitle: parsed?.title,
|
||||
currentArtist: parsed?.artist,
|
||||
history: [],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ICY metadata not available — leave empty metadata
|
||||
}
|
||||
if (!cancelled) {
|
||||
pollTimer = setTimeout(pollIcy, ICY_POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off detection and polling.
|
||||
if (candidateApiUrl) {
|
||||
// Try AzuraCast first; fall back to ICY inside pollAzuraCast if it fails.
|
||||
azuraCastUrlRef.current = candidateApiUrl;
|
||||
pollAzuraCast(candidateApiUrl);
|
||||
} else {
|
||||
pollIcy();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
stopElapsedTick();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [station?.id, station?.streamUrl, station?.homepageUrl, perfFlags.disableBackgroundPolling]);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
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