138 lines
3.7 KiB
TypeScript
138 lines
3.7 KiB
TypeScript
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<typeof setInterval> | 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<void> {
|
|
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;
|
|
}
|
|
}
|