new media player functionality
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
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
|
||||
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": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"containerEnv": {
|
||||
"ANTHROPIC_BASE_URL": "http://192.168.1.108:1234",
|
||||
"ANTHROPIC_AUTH_TOKEN": "lmstudio",
|
||||
"ANTHROPIC_MODEL": "qwen/qwen3.6-27b"
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"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
|
||||
|
||||
|
||||
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):
|
||||
"""Ensure the singleton station config row exists (idempotent)."""
|
||||
result = await session.execute(
|
||||
@@ -28,9 +67,10 @@ async def _ensure_station_config(session):
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Create tables on startup
|
||||
# Create tables on startup, then patch any missing columns
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await conn.run_sync(_migrate_add_missing_columns)
|
||||
|
||||
# Auto-seed if database is empty
|
||||
async with async_session() as session:
|
||||
|
||||
@@ -94,6 +94,10 @@ class StationConfig(Base):
|
||||
# Donation
|
||||
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):
|
||||
__tablename__ = "history_entries"
|
||||
|
||||
@@ -73,6 +73,8 @@ class StationConfigResponse(BaseModel):
|
||||
email: str
|
||||
website: str
|
||||
donation_url: str
|
||||
stream_url: str
|
||||
stream_metadata_url: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -95,6 +97,8 @@ class StationConfigUpdate(BaseModel):
|
||||
email: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
donation_url: Optional[str] = None
|
||||
stream_url: Optional[str] = None
|
||||
stream_metadata_url: Optional[str] = None
|
||||
|
||||
|
||||
# ── HistoryEntry ──────────────────────────────────────────
|
||||
|
||||
@@ -212,6 +212,8 @@ STATION_CONFIG: dict = {
|
||||
"email": "hello@kmountainflower.org",
|
||||
"website": "kmountainflower.org",
|
||||
"donation_url": "",
|
||||
"stream_url": "",
|
||||
"stream_metadata_url": "",
|
||||
}
|
||||
|
||||
HISTORY_ENTRIES: list[dict] = [
|
||||
|
||||
@@ -139,6 +139,21 @@
|
||||
</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">
|
||||
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
|
||||
<button type="submit" class="btn btn-save" [disabled]="saving">
|
||||
|
||||
@@ -38,6 +38,8 @@ export class AdminStationFormComponent implements OnChanges {
|
||||
email = '';
|
||||
website = '';
|
||||
donation_url = '';
|
||||
stream_url = '';
|
||||
stream_metadata_url = '';
|
||||
|
||||
loading = false;
|
||||
saving = false;
|
||||
@@ -69,6 +71,8 @@ export class AdminStationFormComponent implements OnChanges {
|
||||
this.email = config.email;
|
||||
this.website = config.website;
|
||||
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. */
|
||||
@@ -155,6 +159,8 @@ export class AdminStationFormComponent implements OnChanges {
|
||||
email: this.email,
|
||||
website: this.website,
|
||||
donation_url: this.donation_url,
|
||||
stream_url: this.stream_url,
|
||||
stream_metadata_url: this.stream_metadata_url,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<app-navbar></app-navbar>
|
||||
<app-media-player></app-media-player>
|
||||
<main>
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
||||
+20
-1
@@ -8,6 +8,25 @@ app-navbar {
|
||||
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 {
|
||||
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 { RouterOutlet } from '@angular/router';
|
||||
import { NavbarComponent } from './navbar/navbar.component';
|
||||
import { MediaPlayerComponent } from './media-player/media-player.component';
|
||||
import { FooterComponent } from './footer/footer.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, NavbarComponent, FooterComponent],
|
||||
imports: [RouterOutlet, NavbarComponent, MediaPlayerComponent, FooterComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@media (min-width: #{$bp-md}) {
|
||||
min-height: 70vh;
|
||||
max-height: 80vh;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-mountains {
|
||||
@@ -36,6 +41,10 @@
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
padding: $spacing-3xl $spacing-lg;
|
||||
|
||||
@media (min-width: #{$bp-md}) {
|
||||
padding: $spacing-xl $spacing-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
|
||||
@@ -18,4 +18,6 @@ export interface StationConfig {
|
||||
email: string;
|
||||
website: 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',
|
||||
website: 'kmountainflower.org',
|
||||
donation_url: '',
|
||||
stream_url: '',
|
||||
stream_metadata_url: '',
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
|
||||
Reference in New Issue
Block a user