import { useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import type { InternetRadioStation } from '../api/subsonic'; 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 [metadata, setMetadata] = useState(EMPTY_METADATA); // Keep elapsed in sync while AzuraCast is active: advance 1 s/tick while playing. const elapsedRef = useRef(null); const elapsedIntervalRef = useRef | null>(null); const stationRef = useRef(null); // Store resolved AzuraCast API URL for the current station (or null). const azuraCastUrlRef = useRef(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 (!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 | 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//…) 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]); return metadata; }