new media player functionality

This commit is contained in:
2026-06-25 06:09:28 +00:00
parent 69f848c260
commit d9b0964f07
20 changed files with 404 additions and 5 deletions
+4 -2
View File
@@ -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"
+5
View File
@@ -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
View File
@@ -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:
+4
View File
@@ -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"
+4
View File
@@ -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 ──────────────────────────────────────────
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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',
}) })
+9
View File
@@ -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 {
+2
View File
@@ -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">&#9632;</span>
} @else {
<span class="media-player__icon media-player__icon--play">&#9658;</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({