new media player functionality
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
FROM python:3.12-alpine
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
RUN apk update && apk add git nodejs npm bash
|
RUN apk update && apk add git nodejs npm bash curl tmux
|
||||||
|
|
||||||
# Install Angular.js via NPM
|
# Install Angular.js via NPM
|
||||||
RUN npm install -g @angular/cli@21.2.14
|
RUN npm install -g @angular/cli@21.2.14
|
||||||
|
|
||||||
RUN /bin/bash
|
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||||
|
|
||||||
|
ENV PATH="$PATH:/root/.local/bin"
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"dockerfile": "Dockerfile"
|
"dockerfile": "Dockerfile"
|
||||||
},
|
},
|
||||||
|
"containerEnv": {
|
||||||
|
"ANTHROPIC_BASE_URL": "http://192.168.1.108:1234",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "lmstudio",
|
||||||
|
"ANTHROPIC_MODEL": "qwen/qwen3.6-27b"
|
||||||
|
},
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+41
-1
@@ -11,6 +11,45 @@ from app.user_models import User
|
|||||||
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters
|
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_add_missing_columns(sync_conn):
|
||||||
|
"""Add missing columns to existing tables (startup migration — no Alembic).
|
||||||
|
|
||||||
|
Handles both SQLite and PostgreSQL. Idempotent: skips columns that already exist.
|
||||||
|
Receives a sync SQLAlchemy Connection from engine.run_sync().
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# Column definitions: (table, name, type, default)
|
||||||
|
pending = [
|
||||||
|
("station_config", "stream_url", sa.String(500), ""),
|
||||||
|
("station_config", "stream_metadata_url", sa.String(500), ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Determine dialect
|
||||||
|
dialect = sync_conn.dialect.name # "sqlite" | "postgresql"
|
||||||
|
|
||||||
|
inspector = sa.inspect(sync_conn)
|
||||||
|
|
||||||
|
for table_name, col_name, col_type, default in pending:
|
||||||
|
# Check if column already exists
|
||||||
|
existing = inspector.get_columns(table_name)
|
||||||
|
if any(c["name"] == col_name for c in existing):
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" → Migrating: adding column {table_name}.{col_name}")
|
||||||
|
|
||||||
|
if dialect == "sqlite":
|
||||||
|
sync_conn.execute(
|
||||||
|
sa.text(f'ALTER TABLE {table_name} ADD COLUMN "{col_name}" TEXT NOT NULL DEFAULT "{default}"')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sync_conn.execute(
|
||||||
|
sa.text(f'ALTER TABLE {table_name} ADD COLUMN "{col_name}" VARCHAR(500) NOT NULL DEFAULT "{default}"')
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" ✓ Added {table_name}.{col_name}")
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_station_config(session):
|
async def _ensure_station_config(session):
|
||||||
"""Ensure the singleton station config row exists (idempotent)."""
|
"""Ensure the singleton station config row exists (idempotent)."""
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -28,9 +67,10 @@ async def _ensure_station_config(session):
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Create tables on startup
|
# Create tables on startup, then patch any missing columns
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await conn.run_sync(_migrate_add_missing_columns)
|
||||||
|
|
||||||
# Auto-seed if database is empty
|
# Auto-seed if database is empty
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ class StationConfig(Base):
|
|||||||
# Donation
|
# Donation
|
||||||
donation_url = Column(String(500), nullable=False, default="")
|
donation_url = Column(String(500), nullable=False, default="")
|
||||||
|
|
||||||
|
# Stream
|
||||||
|
stream_url = Column(String(500), nullable=False, default="")
|
||||||
|
stream_metadata_url = Column(String(500), nullable=False, default="")
|
||||||
|
|
||||||
|
|
||||||
class HistoryEntry(Base):
|
class HistoryEntry(Base):
|
||||||
__tablename__ = "history_entries"
|
__tablename__ = "history_entries"
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ class StationConfigResponse(BaseModel):
|
|||||||
email: str
|
email: str
|
||||||
website: str
|
website: str
|
||||||
donation_url: str
|
donation_url: str
|
||||||
|
stream_url: str
|
||||||
|
stream_metadata_url: str
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -95,6 +97,8 @@ class StationConfigUpdate(BaseModel):
|
|||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
website: Optional[str] = None
|
website: Optional[str] = None
|
||||||
donation_url: Optional[str] = None
|
donation_url: Optional[str] = None
|
||||||
|
stream_url: Optional[str] = None
|
||||||
|
stream_metadata_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ── HistoryEntry ──────────────────────────────────────────
|
# ── HistoryEntry ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ STATION_CONFIG: dict = {
|
|||||||
"email": "hello@kmountainflower.org",
|
"email": "hello@kmountainflower.org",
|
||||||
"website": "kmountainflower.org",
|
"website": "kmountainflower.org",
|
||||||
"donation_url": "",
|
"donation_url": "",
|
||||||
|
"stream_url": "",
|
||||||
|
"stream_metadata_url": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
HISTORY_ENTRIES: list[dict] = [
|
HISTORY_ENTRIES: list[dict] = [
|
||||||
|
|||||||
@@ -139,6 +139,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Stream -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h3>Stream</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="stream_url">Stream URL (MP3)</label>
|
||||||
|
<input id="stream_url" type="url" [(ngModel)]="stream_url" name="stream_url"
|
||||||
|
placeholder="https://example.com/stream.mp3">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="stream_metadata_url">Live Metadata API URL</label>
|
||||||
|
<input id="stream_metadata_url" type="url" [(ngModel)]="stream_metadata_url" name="stream_metadata_url"
|
||||||
|
placeholder="https://example.com/api/live-info">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
|
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
|
||||||
<button type="submit" class="btn btn-save" [disabled]="saving">
|
<button type="submit" class="btn btn-save" [disabled]="saving">
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
email = '';
|
email = '';
|
||||||
website = '';
|
website = '';
|
||||||
donation_url = '';
|
donation_url = '';
|
||||||
|
stream_url = '';
|
||||||
|
stream_metadata_url = '';
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
saving = false;
|
saving = false;
|
||||||
@@ -69,6 +71,8 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
this.email = config.email;
|
this.email = config.email;
|
||||||
this.website = config.website;
|
this.website = config.website;
|
||||||
this.donation_url = config.donation_url;
|
this.donation_url = config.donation_url;
|
||||||
|
this.stream_url = config.stream_url;
|
||||||
|
this.stream_metadata_url = config.stream_metadata_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset form with current service values. */
|
/** Reset form with current service values. */
|
||||||
@@ -155,6 +159,8 @@ export class AdminStationFormComponent implements OnChanges {
|
|||||||
email: this.email,
|
email: this.email,
|
||||||
website: this.website,
|
website: this.website,
|
||||||
donation_url: this.donation_url,
|
donation_url: this.donation_url,
|
||||||
|
stream_url: this.stream_url,
|
||||||
|
stream_metadata_url: this.stream_metadata_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<app-navbar></app-navbar>
|
<app-navbar></app-navbar>
|
||||||
|
<app-media-player></app-media-player>
|
||||||
<main>
|
<main>
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+20
-1
@@ -8,6 +8,25 @@ app-navbar {
|
|||||||
z-index: 200;
|
z-index: 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile: navbar is taller (stacked brand text), so give the player room
|
||||||
|
app-media-player {
|
||||||
|
position: fixed;
|
||||||
|
top: 64px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 199;
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
padding-top: 70px;
|
padding-top: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: #{$bp-md}) {
|
||||||
|
app-media-player {
|
||||||
|
top: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding-top: 96px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,12 +1,13 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { NavbarComponent } from './navbar/navbar.component';
|
import { NavbarComponent } from './navbar/navbar.component';
|
||||||
|
import { MediaPlayerComponent } from './media-player/media-player.component';
|
||||||
import { FooterComponent } from './footer/footer.component';
|
import { FooterComponent } from './footer/footer.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, NavbarComponent, FooterComponent],
|
imports: [RouterOutlet, NavbarComponent, MediaPlayerComponent, FooterComponent],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
@media (min-width: #{$bp-md}) {
|
||||||
|
min-height: 70vh;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-mountains {
|
.hero-mountains {
|
||||||
@@ -36,6 +41,10 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: $spacing-3xl $spacing-lg;
|
padding: $spacing-3xl $spacing-lg;
|
||||||
|
|
||||||
|
@media (min-width: #{$bp-md}) {
|
||||||
|
padding: $spacing-xl $spacing-lg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-inner {
|
.hero-inner {
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ export interface StationConfig {
|
|||||||
email: string;
|
email: string;
|
||||||
website: string;
|
website: string;
|
||||||
donation_url: string;
|
donation_url: string;
|
||||||
|
stream_url: string;
|
||||||
|
stream_metadata_url: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@if (hasStream()) {
|
||||||
|
<div class="media-player" role="region" aria-label="Live radio stream">
|
||||||
|
<div class="media-player__inner">
|
||||||
|
<button
|
||||||
|
class="media-player__btn"
|
||||||
|
type="button"
|
||||||
|
(click)="isPlaying() ? stop() : play()"
|
||||||
|
[attr.aria-label]="isPlaying() ? 'Stop stream' : 'Play stream'"
|
||||||
|
[attr.aria-pressed]="isPlaying()"
|
||||||
|
>
|
||||||
|
@if (isPlaying()) {
|
||||||
|
<span class="media-player__icon media-player__icon--stop">■</span>
|
||||||
|
} @else {
|
||||||
|
<span class="media-player__icon media-player__icon--play">►</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (isPlaying()) {
|
||||||
|
<span class="media-player__live-dot" aria-hidden="true"></span>
|
||||||
|
<span class="media-player__live-label">LIVE</span>
|
||||||
|
<span class="media-player__separator" aria-hidden="true">|</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="media-player__text" role="status" aria-live="polite">
|
||||||
|
<span class="media-player__marquee">{{ displayText() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
.media-player {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--font-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-light);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 $spacing-md;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player__btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-white);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity $transition-fast;
|
||||||
|
// minimum 44x44 touch target
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player__icon {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player__icon--play {
|
||||||
|
color: var(--color-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player__icon--stop {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player__live-dot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
background: var(--color-accent);
|
||||||
|
animation: livePulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player__live-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-accent-light);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player__separator {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player__text {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player__marquee {
|
||||||
|
display: inline-block;
|
||||||
|
padding-left: 100%;
|
||||||
|
animation: marqueeScroll 12s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: #{$bp-sm}) {
|
||||||
|
.media-player__marquee {
|
||||||
|
animation: none;
|
||||||
|
padding-left: 0;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes livePulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.4; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marqueeScroll {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(-100%); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Component, inject, effect, OnDestroy, signal, computed } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
|
||||||
|
/** 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 = data.currentShow?.[0]?.name ?? '';
|
||||||
|
const current = data.current;
|
||||||
|
const trackTitle = current?.metadata?.track_title ?? '';
|
||||||
|
const artistName = current?.metadata?.artist_name ?? '';
|
||||||
|
|
||||||
|
let track = '';
|
||||||
|
if (artistName && trackTitle) track = `${artistName} - ${trackTitle}`;
|
||||||
|
else if (current?.name) track = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ const DEFAULT_CONFIG: StationConfig = {
|
|||||||
email: 'hello@kmountainflower.org',
|
email: 'hello@kmountainflower.org',
|
||||||
website: 'kmountainflower.org',
|
website: 'kmountainflower.org',
|
||||||
donation_url: '',
|
donation_url: '',
|
||||||
|
stream_url: '',
|
||||||
|
stream_metadata_url: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
|||||||
Reference in New Issue
Block a user