webrenderer architecture evolution (Phases P0–P4)

- Phase 1: Introduce DeviceCommand enum and BrowserAdapter in adapter.rs
- Fix P0 bug (play_handler now checks URI before state change)
- Phase 2: Wire flac_handle.pause/resume into pause_handler/stop/play
- Add VecDeque<DeviceCommand> to RendererState, replace Option<Value>
- Phase 3: Add AudioContext + exponential backoff reconnect in PMOPlayer.ts
- Fix position format (seconds_to_upnp_time) and add /nowplaying, /state endpoints
- Phase 4: Register new HTTP routes in config.rs and implement handlers
This commit is contained in:
2026-04-05 15:02:44 +02:00
parent 32e3a18895
commit c13de9f46b
11 changed files with 458 additions and 63 deletions

View File

@@ -36,6 +36,7 @@ export interface TrackInfo {
export class PMOPlayer {
private audio: HTMLAudioElement;
private instanceId: string;
private ac: AudioContext | null = null;
private state: PlayerState = 'stopped';
private positionInterval: number | null = null;
@@ -44,6 +45,9 @@ export class PMOPlayer {
private debug: boolean = false;
private pendingPlay = false;
private unlockListener: (() => void) | null = null;
private reconnectAttempts = 0;
private readonly MAX_RECONNECT_ATTEMPTS = 5;
private reconnectTimeout: number | null = null;
constructor(instanceId: string) {
this.instanceId = instanceId;
@@ -63,6 +67,33 @@ export class PMOPlayer {
this.startCommandPolling();
}
private ensureAudioContext(): AudioContext {
if (!this.ac) {
this.ac = new AudioContext();
const source = this.ac.createMediaElementSource(this.audio);
source.connect(this.ac.destination);
}
return this.ac;
}
private scheduleReconnect() {
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
this.log('max reconnect attempts reached, giving up');
this.setState('error');
return;
}
const delayMs = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 16000);
this.reconnectAttempts++;
this.log(`reconnect attempt ${this.reconnectAttempts} in ${delayMs}ms`);
this.reconnectTimeout = window.setTimeout(() => {
const url = this.audio.getAttribute('data-stream-url');
if (url) {
this.stream(url);
this.play();
}
}, delayMs);
}
private log(...args: unknown[]) {
if (this.debug) {
console.log('[PMOPlayer]', ...args);
@@ -120,11 +151,18 @@ export class PMOPlayer {
});
this.audio.addEventListener('error', () => {
// Ignorer l'erreur produite par flush() (removeAttribute('src') + load())
if (!this.audio.getAttribute('src')) return;
this.log('error event', this.audio.error);
this.setState('error');
this.listeners.error?.(this.audio.error?.message || 'unknown error');
const code = this.audio.error?.code;
const isNetworkError = code === MediaError.MEDIA_ERR_NETWORK
|| code === MediaError.MEDIA_ERR_DECODE;
if (isNetworkError && this.state !== 'stopped') {
this.log('network error, scheduling reconnect');
this.scheduleReconnect();
} else {
this.log('error event', this.audio.error);
this.setState('error');
this.listeners.error?.(this.audio.error?.message || 'unknown error');
}
});
this.audio.addEventListener('waiting', () => {
@@ -187,8 +225,9 @@ export class PMOPlayer {
// ─── Commands from backend ─────────────────────────────────────────────
stream(url: string) {
// URL stored in audio.src directly
this.log('stream:', url);
this.audio.setAttribute('data-stream-url', url);
this.reconnectAttempts = 0;
this.audio.src = url;
this.audio.load();
}
@@ -200,6 +239,10 @@ export class PMOPlayer {
play() {
this.log('play()');
const ac = this.ensureAudioContext();
if (ac.state === 'suspended') {
ac.resume().catch(err => this.log('AudioContext resume error', err));
}
this.audio.play().catch(err => {
if ((err as DOMException).name === 'NotAllowedError') {
this.log('autoplay blocked, will retry on user interaction');
@@ -240,6 +283,7 @@ export class PMOPlayer {
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.ac?.suspend();
this.listeners.flush?.();
}
@@ -360,6 +404,12 @@ export class PMOPlayer {
document.removeEventListener('click', this.unlockListener);
this.unlockListener = null;
}
if (this.reconnectTimeout !== null) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.ac?.close();
this.ac = null;
// Rapport final garanti via sendBeacon (fonctionne pendant beforeunload)
navigator.sendBeacon(
`/api/webrenderer/${this.instanceId}/report`,