new media player functionality
This commit is contained in:
@@ -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