Files
psysonic/src/hooks/useRadioMetadata.ts
T
Frank Stellmacher 7a7a9f5e6b refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
2026-05-14 14:27:44 +02:00

217 lines
7.4 KiB
TypeScript

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) {
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;
}