diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1f3fc4f..4bee870 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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 \ No newline at end of file +RUN curl -fsSL https://claude.ai/install.sh | bash + +ENV PATH="$PATH:/root/.local/bin" \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 28b2c57..8794749 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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": [ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 025c914..905da32 100644 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index 99b089b..cc520cf 100644 Binary files a/backend/app/__pycache__/models.cpython-312.pyc and b/backend/app/__pycache__/models.cpython-312.pyc differ diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc index 7164094..9d19e86 100644 Binary files a/backend/app/__pycache__/schemas.cpython-312.pyc and b/backend/app/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/app/main.py b/backend/app/main.py index fa77168..1e63e80 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/backend/app/models.py b/backend/app/models.py index 1937e5e..0c4b842 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 8ca831b..379102a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 ────────────────────────────────────────── diff --git a/backend/seed.py b/backend/seed.py index 8973089..f2a6f54 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -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] = [ diff --git a/src/app/admin/admin-station-form.component.html b/src/app/admin/admin-station-form.component.html index f06d5c4..f7a85b7 100644 --- a/src/app/admin/admin-station-form.component.html +++ b/src/app/admin/admin-station-form.component.html @@ -139,6 +139,21 @@ + +
+

Stream

+
+ + +
+
+ + +
+
+
+ + @if (isPlaying()) { + + LIVE + + } + +
+ {{ displayText() }} +
+
+ +} diff --git a/src/app/media-player/media-player.component.scss b/src/app/media-player/media-player.component.scss new file mode 100644 index 0000000..263bcd6 --- /dev/null +++ b/src/app/media-player/media-player.component.scss @@ -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%); } +} diff --git a/src/app/media-player/media-player.component.ts b/src/app/media-player/media-player.component.ts new file mode 100644 index 0000000..69b3fc6 --- /dev/null +++ b/src/app/media-player/media-player.component.ts @@ -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 | 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 = 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; + } +} diff --git a/src/app/services/station-config.service.ts b/src/app/services/station-config.service.ts index 3bf0030..1fd612b 100644 --- a/src/app/services/station-config.service.ts +++ b/src/app/services/station-config.service.ts @@ -26,6 +26,8 @@ const DEFAULT_CONFIG: StationConfig = { email: 'hello@kmountainflower.org', website: 'kmountainflower.org', donation_url: '', + stream_url: '', + stream_metadata_url: '', }; @Injectable({