mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
7a7a9f5e6b
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.
217 lines
7.4 KiB
TypeScript
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;
|
|
}
|