Add player functionality with HLS support and API integration

- Implemented a new Player class in player.js to handle audio playback, including HLS support using hls.js.
- Created a shared API module in api.js for making HTTP requests with proper error handling.
- Added DOM utility functions in dom.js for creating and clearing elements.
- Introduced WebSocket connection handling in ws.js for real-time updates.
- Developed a comprehensive CSS stylesheet for styling the application, including a high-contrast theme.
This commit is contained in:
Marco Mooren
2026-05-10 14:43:00 +02:00
commit e0a60f7b64
51 changed files with 9022 additions and 0 deletions

83
web/player.js Normal file
View File

@@ -0,0 +1,83 @@
import Hls from 'hls.js';
import { api } from './shared/api.js';
export class Player {
constructor({ onState }) {
this.audio = new Audio();
this.audio.preload = 'none';
// Note: do NOT set crossOrigin — most Icecast/SHOUTcast servers don't send
// CORS headers and the browser will then refuse to play the stream.
this.hls = null;
this.station = null;
this.onState = onState || (() => { });
this.audio.addEventListener('playing', () => this.emit({ playing: true, loading: false, error: null }));
this.audio.addEventListener('pause', () => this.emit({ playing: false, loading: false }));
this.audio.addEventListener('waiting', () => this.emit({ loading: true }));
this.audio.addEventListener('error', () => {
const code = this.audio.error?.code;
const map = { 1: 'aborted', 2: 'network', 3: 'decode', 4: 'src not supported' };
const reason = map[code] || `code ${code}`;
console.warn('[player] audio error', reason, this.audio.currentSrc);
this.emit({ playing: false, loading: false, error: `stream error: ${reason}` });
});
}
emit(extra) {
this.onState({
stationId: this.station?.id ?? null,
stationName: this.station?.name ?? null,
genres: this.station?.genres || [],
volume: this.audio.volume,
...extra
});
}
setVolume(v) {
this.audio.volume = Math.max(0, Math.min(1, v));
this.emit({});
}
stop() {
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
if (this.hls) { this.hls.destroy(); this.hls = null; }
}
togglePause() {
if (!this.station) return;
if (this.audio.paused) this.audio.play().catch(() => { });
else this.audio.pause();
}
async play(station) {
this.stop();
this.station = station;
this.emit({ playing: false, loading: true });
let resolved;
try {
const r = await api.post(`/api/stations/${station.id}/resolve`);
resolved = r.resolved;
} catch (err) {
this.emit({ playing: false, loading: false, error: err.message });
return;
}
const url = resolved.url;
if (resolved.format === 'hls') {
if (Hls.isSupported()) {
this.hls = new Hls({ enableWorker: true });
this.hls.loadSource(url);
this.hls.attachMedia(this.audio);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => this.audio.play().catch(() => { }));
} else if (this.audio.canPlayType('application/vnd.apple.mpegurl')) {
this.audio.src = url;
this.audio.play().catch(() => { });
} else {
this.emit({ playing: false, loading: false, error: 'HLS not supported' });
}
} else {
this.audio.src = url;
this.audio.play().catch(() => { });
}
}
}