import { Component, inject, effect, OnDestroy, signal, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; import { StationConfigService } from '../services/station-config.service'; /** Decode malformed HTML entities from stream metadata (e.g. `&039;` → `'`). */ function decodeMetadata(text: string): string { return text.replace(/'/g, "'") .replace(/&/g, "&"); } /** Shape of the Airtime live-info JSON response (subset of fields we use). */ interface LiveInfoResponse { current: { name: string; metadata?: { track_title?: string; artist_name?: string }; } | null; currentShow: Array<{ name: string }>; } @Component({ selector: 'app-media-player', standalone: true, imports: [CommonModule], templateUrl: './media-player.component.html', styleUrl: './media-player.component.scss', }) export class MediaPlayerComponent implements OnDestroy { private configService = inject(StationConfigService); // Playback private audio: HTMLAudioElement | null = null; private pollTimer: ReturnType | null = null; readonly isPlaying = signal(false); readonly showName = signal(''); readonly trackInfo = signal(''); readonly metadataError = signal(false); readonly displayText = computed(() => { const track = this.trackInfo(); if (track) return track; return this.metadataError() ? 'Stream Unavailable' : 'Live Stream'; }); readonly hasStream = computed(() => { const cfg = this.configService.config(); return !!cfg.stream_url && cfg.stream_url.trim().length > 0; }); constructor() { // Start/stop metadata polling when playing state changes. effect(() => { if (this.isPlaying()) { this.startPolling(); } else { this.stopPolling(); } }); } play(): void { const cfg = this.configService.config(); if (!cfg.stream_url) return; if (!this.audio) { this.audio = new Audio(); this.audio.preload = 'none'; } this.audio.src = cfg.stream_url; this.audio.load(); this.audio.play().catch(() => { this.isPlaying.set(false); }); this.isPlaying.set(true); } stop(): void { if (this.audio) { this.audio.pause(); this.audio.src = ''; this.audio.load(); } this.isPlaying.set(false); } private startPolling(): void { this.fetchMetadata(); this.pollTimer = setInterval(() => this.fetchMetadata(), 15000); } private stopPolling(): void { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } } private async fetchMetadata(): Promise { const cfg = this.configService.config(); if (!cfg.stream_metadata_url) { this.showName.set(''); this.trackInfo.set(''); return; } try { const resp = await fetch(cfg.stream_metadata_url); if (!resp.ok) throw new Error('Metadata fetch failed'); const data: LiveInfoResponse = await resp.json(); const showName = decodeMetadata(data.currentShow?.[0]?.name ?? ''); const current = data.current; const trackTitle = decodeMetadata(current?.metadata?.track_title ?? ''); const artistName = decodeMetadata(current?.metadata?.artist_name ?? ''); let track = ''; if (artistName && trackTitle) track = `${artistName} - ${trackTitle}`; else if (current?.name) track = decodeMetadata(current.name); else if (trackTitle) track = trackTitle; this.showName.set(showName); this.trackInfo.set(track); this.metadataError.set(false); } catch { this.metadataError.set(true); this.showName.set(''); this.trackInfo.set(''); } } ngOnDestroy(): void { this.stop(); this.stopPolling(); this.audio = null; } }