From d9b0964f07ba12ceab520afc9ad20678950fcc76 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Thu, 25 Jun 2026 06:09:28 +0000 Subject: [PATCH] new media player functionality --- .devcontainer/Dockerfile | 6 +- .devcontainer/devcontainer.json | 5 + backend/app/__pycache__/main.cpython-312.pyc | Bin 6500 -> 8373 bytes .../app/__pycache__/models.cpython-312.pyc | Bin 7420 -> 7560 bytes .../app/__pycache__/schemas.cpython-312.pyc | Bin 10100 -> 10253 bytes backend/app/main.py | 42 +++++- backend/app/models.py | 4 + backend/app/schemas.py | 4 + backend/seed.py | 2 + .../admin/admin-station-form.component.html | 15 ++ src/app/admin/admin-station-form.component.ts | 6 + src/app/app.html | 1 + src/app/app.scss | 21 ++- src/app/app.ts | 3 +- src/app/hero/hero.component.scss | 9 ++ src/app/interfaces/station-config.ts | 2 + .../media-player/media-player.component.html | 29 ++++ .../media-player/media-player.component.scss | 127 +++++++++++++++++ .../media-player/media-player.component.ts | 131 ++++++++++++++++++ src/app/services/station-config.service.ts | 2 + 20 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 src/app/media-player/media-player.component.html create mode 100644 src/app/media-player/media-player.component.scss create mode 100644 src/app/media-player/media-player.component.ts 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 025c914d78937cf95dcbde95dc5d8b640ba64180..905da32ff009704a44cdc3e0a3a7ba6cac4f1831 100644 GIT binary patch delta 3774 zcma)8e{2)i9lz(Z@6Nv+C(chNxtKWE2{8#FqzM7)h7^cRC=^(W)Lfy(4Q+~aj%kM|{`KaKO zF3Uj#aJFXgV#${(=Mj&6munyvz0V*DDRL3-cqjYaqZ$cvzT|P zGdsER=VF(u> z#A%|$7~)7yn?w;3rYtBpg|Zpos6H(y=$s<8SkbQ;(h!6k?x6)En{>`aZ-l`3$XkpZDb8Ct$0OXuS5s9SG6SacnFs zNnxR%?TtqhF+pPGILi-*B^i&&q1{nlVm(qy4vBJN2*~|nNDjvZ_V)NsSRu~(qkL?4 zxYz457+7c}{M&>t3f zpO_GOFx3N*;lMpNfe?+9*ZT^ZyWt#YwD{o6V=`nP$S8ydV%U?uQj z!?L%Jr4$xQlIK{j0kX6w8p_AvA$ZFe`*}IHG8r-? zjAV2}z?U;LKg{Tyi~RS+^7DU_jycmaR0Sdabb!>O#pxR^1hrLQI^ zn<{x^h==+4zEC16XPDd;5#3-Z;meoJwHL)2?9KVC{E6*Av0&3>P-T#=dnQ8XoJ%o; zp>(o+%Kk~o$&%Aerz+0b(@gtKeM!2k;k4^h?-}p8j*=v(>D8~L6q`JSoL ztNAPUGk-B!uHr%gan0CrGi56HnlYzs%TF7olII%Fy>_lEZP{>**?7xnKQ!=XW5rFA z`G`(qex;K*EJ(wPhri)`&ZNk4vjoC8C5az;X z7)8>9VmczFB{^MOo49Iw>pr%+&k%acA=Bip$>0>4f^gH8mDic(ZHcy2;ydWh+zDJVP zlMPo#AMA-PpfxLv%w5e|N^$FdQ3}WU{2vf#9yS?jSD7_cC_gqXL00AO`lTD5a3#q2 zLQtEGk07;03Ug?omy2^aQmP!+mnxFMjmnjOnMy5i>uEWp-a9MgO69zvgtWzM%2$Sd zb=qo;yhIr^cGlx_Ky49RhL5yENMz*&hDZ6zSd@)A_Ux!@bB=O%Rs?UEN1FVQ6~U+E zBW=;VHAlJNqKSX1@(-%~Sd}M$ zxugtDoO#Ga6Teb5AE@#G)|aD+4;SfUs)lYMCVmdjFfd%2iTz5MwF9kFdaMJ;sr=Ep z!idiU5&!jwE8e%&7#B*oOKG(ER(D`cHx{p8(Fp<$I*t=x2IQs103Oidb4}cU2^+E4 ziNz-6Lz}ZiJ&~AbxQ~~HLW1}vXx76cd6Yldnw&Q+_Jgqlv1`s3u3B1eFs*5(^*=ho zR&j9eeZyH}-XJR9jNtXbO{N*1)(3M~`QYH*gL~iG``|ibyZO3Yqc2rj6gZxin^+_=iBUnF95TRx?skp7i{YS zdh&BC>RLm6Zd(a>S_^kBHLcTOY{0hD4iq4@(}gySQ#B|+X{VQBN7Jn+P)SXY`bTaUM3l=tKdx_G0-;z?bq+U@djYj`^1yes_=|zi2~2M18T^Ujr{+ zR-vGl`qIS$p3z|I88Zq}+8K+A%Mg4{YNi5rGe8A(HPzKZ&(xx>declDjqwUS9$$kX z`dLDiBxcT1s?<{fCp~LL0lR6|Mq^xr`Liwr0nfTsj)w}Yre}R9&}f=nPGh_Zb1owU zVO@4<)lPMHnW2KE^koYQ=9w;AX^ac$V7aoV;2rel*?R@65FLT!cYBv94TWoTBHnDu zNZ}_owFvRKrxu79P@0O?c`#LUVX+2_Y$dhF;#?xiuN8-33^Gqhv&xa8#+quh>TWkO iq58QBLgSdzc#vU|zDq!NkIW5xv-9koqG6=P?*0dpVQSw1 delta 2078 zcma)7TTCNW7@of~o$38T3oXzoEu}4Ox!?B!g%}^UV^oaBxN0JDhTZKJSf?d|n}8Vg zVbcUQ=fO?X@Srb-b$uYlnDt@1ln~m)#5aQvCT0^Kd_m9{d@!Cfr3H5Dx+j^h-~a#L z_n*t0Ours`&|@i@%?5^yKl!ssY`tl5!@Zl~Z?cO+d6lYZ;@M)Qi-2LSt}co&z#JZII^RfOF*C7j&*G*2Mp=)Tt&N7VRA$A`ASeB z$cbu+y>6W?r5>_ZbaH)Fw+p!+?>?GeZmeprM-7j+m*$r}Ro#uq^S|y+(m@uh+C}7j z)Q(p*wM*5`sR{WWRacIuLgc2ZbNNy86S;lV{br@x5kUde@^pM+teQfQ^ z;j4oTql6HHT2t=n=*8%NzWiv_z7Z5X-FG3j5R0^J?6bYF5ixxU`dtCZQxkKk4F1WC zNhHt9lXDpvr!7&(uJ)R8WhSFEkxeQ-3VwXY5^!Nti;?}m2j0Kym z`PfZ24Kmp^^2`xMvnKli9;|x`y!d+EW%7C+)b;Rs zvQMQM`&}Vph*I^owBZ+A9p?fx?xN5`AxL1Coj^zl6BZGacADTOXJ=*SMI7OI?|g)7K_ulu;7Wqe_jGu>{-vcGs@cAx|@;++L$3fC^TTCC}TYVsT`K^Ac-x?YVO0Dd+ z2$G-Q_Kt-}u)~4W!tZE(ln1GMrwgPIx6`fi5g^+s0FZov;}b4nsbGMF*;FtJBq-GA zX+Z?C?t)LnfGQ2C7~vD=g#y`mkEzflP(DcYMGcUp7PVAU)bUcIP_%$#Hx;b{<#npY z4`e~b78TWQ#a3QAD->-VAa$FHodOL81Zf!GZCr-YjbYCK2=ssR*+>k(BMzH(4e7U1 z6RF9|^7I1!PW;M9KPc*(SK9G)Z#+oFUoZy*8KrbT&QH&xxs*JjyiRiTlEx5`*~B}Wkx_Q?Mk&q7NBOSF-r_1QDN0SujV~?Axg`u`=cbkkv6$OD48)GEb~_=n~^^UBm*J}fw(wxa;${r%%a|mTty%yAfk41gjhOb(dGkUeT-bCAW;y}F*!n_lCgAhA(!Ol*Anv?8Rtyy zmYU9036cj9Qzq+3=P_1Jo-N&E40Z?DO0c0|Gr$VPK&rv6mH@JDaoFVMrlAQ)cvHoZ#|>0YrcCnY>HJ2>{*qaC86w delta 242 zcmeCM{$t5^nwOW00SIhZtg>c{Zse=sVk%*pJW2AR=ipk8m<$>gEFB7DlctkSK`Ao%~OvoH1*1fw1J} zeo=2mt^$w@h^Un^8V_pNuSH+vGbkF?{lj&WsaWzA%93FD{ea GWSsy^1wqUJ diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc index 716409439bcdf4c51a1638ad9d2dcb4847fab5b7..9d19e86e0b196b06e8074860cc188b336d6dd349 100644 GIT binary patch delta 1443 zcmZ{kU2GIp6vumTy6sMPyWgFi{a*G125D>57qqBNAaqOJex$Uq#kN~_Ay8^`T1*Q_ zD^@~{G?0G`MNHa62@m=}qpuoy)0n7nF!o0zO7JeYXT?Sf5}efZ6}_x|ty zoH=vvma}KO>|fjMYL)o)zu$8Brxp8WHS`Ov#RG8Edok-&(fNoKw%T`Mh3bNm_qGV7 zjC>sB8}iyA8n8_J4ILlWu7+=_6O(2kH$HsSJaW@hiN_0*xqNOihXrevPU1)Fk-C!8 z^JZsB4;7UVGU~QXZxhZb23|>YVO9Ngd!7dT1*K4}b2dxfBXiM!zHUxdGEIgLx9Y}h z<}RT$cMBK+wArSO2V*nAD@jI*vFAQ|L@?nX+_wD@D=O(yEIHeLu6EIL-d zMO*YQCoYDsD~+G`6~SP`Au@+%<3M z5OanVOM0NLk2MOTAI)yR+RGv1G`ig_)GPN~aWDJ!@q(;SZt3S?2ljgcv=7f}{xze@ z?2FoFH2bWU1tFT~!-5$8?%9bqd;#q8o_UDX9dA}0WLL%o?C`bGpv->Bcc6wV&Kedj z8Be&qn9;0s8P7GOX{gf24@fo*&u9@5i3-#aHe-A&TDApOwPqTT;(OY>n+?saj7EXp ztl5Ka^bmcD75`H-DrMEWDc)^Lj|;!|bi-0$b%*RyojXdbQhD6;lxqi(ahLzn#XvKaT|X{dT31M9gXg^jq}3G0$KBUY>S5)Rz*Bv z7kI0xCfnHvZaCxhxN;)X3!~7S1xJ@KG{E3a)SNr&H2>cj9jJf%`i1e?RyP z+LKLP57kI>*=Bu1;|z+jxqjIn<}abOzhgIlo4Yigxwx0W zN1K1e=b?hkqQRQRIqSiy;U@hUw zL3D=G)&ES~O{A?u^bsd9AAVX6;j8fSe^X~7d7KJ|};5k0xmXW z+7DAFNE{(Lh>9cibBBRW+btKTk$p-R&ghB*|^sdyA90_a3da)_e>RYn9tT>FxeuE9bX0zaa*dX;aOOTyRkit)M}rgrRp7Mcba4kXWed` zbzJOgT*h=NTCT1pIE?)_VWaAD>W_?x~qkzbNjWh8wuY#=TT-? zya>;6j`$M!bRH*~A|G5%=R~G(eM`6e2s<-k?#yZ|i6_z_`37%h`ejm=y_s;AHEdEb5})wf>yBzv#L(RgWF= z7x2@zjz>0((VM1$o<9I=CVn9z^SleF}6&`V2i zt6r)h4)q2cqcut#C-lwGW=?XbXV%Ita=1jSF9AMmv*lG>Ya2_(r2Txp6flocXGC)6 Py;8tDN)O@j+>O5hjZihn 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({