🔧 Refactor PMOPlayer and WebRenderer command handling

- Extract beforeunload handler in useWebrenderer.ts to ensure consistent unregister() calls
- Remove unused AudioContext from PMOPlayer and clean up destroy()
  - Add guaranteed final report via sendBeacon during destruction
- Simplify playStream() in PMOPlayer by removing URL rewriting logic (now handled server-side)
- Update play_handler to pass instance_id and store stream command atomically
  - Avoid race conditions when setting current_uri before sending Play control event
- Remove obsolete WebSocket message types from messages.rs (now using HTTP polling)
  - Keep only core PlaybackState enum
- Fix registry::get_player_command to avoid nested locks on HashMap + Sharedstate Arcs  
- Add missing PlaybackState::Transitioning in pipeline.rs TrackEnded event
- Refactor renderer service variable registration using helper add_var() function
This commit is contained in:
2026-04-05 14:32:19 +02:00
parent fe9dd9aba6
commit 76a1c89137
8 changed files with 111 additions and 231 deletions

View File

@@ -139,14 +139,16 @@ export function useWebRenderer() {
// PMOPlayer doesn't control mute directly - handled by backend
}
const beforeUnloadHandler = () => void unregister();
onMounted(() => {
void register();
window.addEventListener("beforeunload", () => void unregister());
window.addEventListener("beforeunload", beforeUnloadHandler);
});
onUnmounted(() => {
void unregister();
window.removeEventListener("beforeunload", () => void unregister());
window.removeEventListener("beforeunload", beforeUnloadHandler);
});
return {

View File

@@ -1,9 +1,9 @@
/**
* PMOPlayer - Invisible remote-controlled audio player for browser
*
*
* Receives commands from Web Media Renderer (backend)
* Reports position/state back to backend via HTTP
*
*
* Usage:
* const player = new PMOPlayer('my-instance-id');
* player.on('play', () => console.log('playing'));
@@ -35,9 +35,8 @@ export interface TrackInfo {
export class PMOPlayer {
private audio: HTMLAudioElement;
private ac: AudioContext | null = null;
private instanceId: string;
private state: PlayerState = 'stopped';
private positionInterval: number | null = null;
private commandInterval: number | null = null;
@@ -45,7 +44,7 @@ export class PMOPlayer {
private debug: boolean = false;
private pendingPlay = false;
private unlockListener: (() => void) | null = null;
constructor(instanceId: string) {
this.instanceId = instanceId;
console.log('[PMOPlayer] constructor called for:', instanceId);
@@ -59,17 +58,17 @@ export class PMOPlayer {
this.audio.style.height = '0';
this.audio.style.overflow = 'hidden';
document.body.appendChild(this.audio);
this.setupAudioListeners();
this.startCommandPolling();
}
private log(...args: unknown[]) {
if (this.debug) {
console.log('[PMOPlayer]', ...args);
}
}
private async fetchCommand() {
try {
const resp = await fetch(`/api/webrenderer/${this.instanceId}/command`);
@@ -91,31 +90,35 @@ export class PMOPlayer {
this.log('fetch command error', err);
}
}
private startCommandPolling() {
this.commandInterval = window.setInterval(() => {
// ATTENTION : ne pas conditionner ce poll à l'état courant.
// L'état initial est 'stopped', et c'est dans cet état que le backend
// envoie la première commande 'stream' pour démarrer la lecture.
// Filtrer sur state !== 'stopped' casse le chemin nominal de démarrage.
this.fetchCommand();
}, 500);
}
private setupAudioListeners() {
this.audio.addEventListener('play', () => {
this.log('play event');
this.setState('playing');
this.startPositionReporting();
});
this.audio.addEventListener('pause', () => {
this.log('pause event');
this.setState('paused');
});
this.audio.addEventListener('ended', () => {
this.log('ended event');
this.setState('stopped');
this.stopPositionReporting();
});
this.audio.addEventListener('error', () => {
// Ignorer l'erreur produite par flush() (removeAttribute('src') + load())
if (!this.audio.getAttribute('src')) return;
@@ -123,17 +126,17 @@ export class PMOPlayer {
this.setState('error');
this.listeners.error?.(this.audio.error?.message || 'unknown error');
});
this.audio.addEventListener('waiting', () => {
this.log('waiting event');
this.setState('buffering');
});
this.audio.addEventListener('canplay', () => {
this.log('canplay event');
this.reportReadyState('can_play');
});
this.audio.addEventListener('durationchange', () => {
const dur = this.audio.duration;
if (isFinite(dur)) {
@@ -141,28 +144,24 @@ export class PMOPlayer {
this.reportDuration(dur);
}
});
this.audio.addEventListener('loadedmetadata', () => {
this.log('loadedmetadata', this.audio.duration);
this.reportReadyState('have_metadata');
});
this.audio.addEventListener('loadeddata', () => {
this.log('loadeddata');
this.reportReadyState('have_current_data');
});
}
private handleCommand(msg: Record<string, unknown>) {
const type = msg.type as string;
switch (type) {
case 'stream': {
let url = msg.url as string;
if (url.startsWith('/api/webrenderer/stream')) {
url = `/api/webrenderer/${this.instanceId}/stream`;
}
this.playStream(url);
this.playStream(msg.url as string);
break;
}
case 'play': {
@@ -184,21 +183,21 @@ export class PMOPlayer {
break;
}
}
// ─── Commands from backend ─────────────────────────────────────────────
stream(url: string) {
// URL stored in audio.src directly
this.log('stream:', url);
this.audio.src = url;
this.audio.load();
}
playStream(url: string) {
this.stream(url);
this.play();
}
play() {
this.log('play()');
this.audio.play().catch(err => {
@@ -225,72 +224,71 @@ export class PMOPlayer {
};
document.addEventListener('click', this.unlockListener);
}
pause() {
this.log('pause()');
this.audio.pause();
}
seek(timestamp: number) {
this.log('seek:', timestamp);
this.audio.currentTime = timestamp;
}
flush() {
this.log('flush()');
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.ac?.suspend();
this.listeners.flush?.();
}
stop() {
this.log('stop()');
this.flush();
this.setState('stopped');
}
// ─── Reports to backend ──────────────────────────────────────────
private setState(state: PlayerState) {
if (this.state !== state) {
this.state = state;
this.log('state:', state);
this.listeners.statechange?.(state);
this.httpReport('report', {
state: state,
});
}
}
private reportPosition() {
const pos = this.audio.currentTime;
const dur = isFinite(this.audio.duration) ? this.audio.duration : null;
this.listeners.positionchange?.(pos);
this.httpReport('report', {
position_sec: pos,
duration_sec: dur,
state: this.state,
});
this.httpReport('report', {
position_sec: pos,
duration_sec: dur,
state: this.state,
});
}
private reportDuration(dur: number) {
this.listeners.durationchange?.(dur);
}
private reportReadyState(ready: ReadyState) {
this.log('ready_state:', ready);
this.listeners.readychange?.(ready);
this.httpReport('report', {
ready_state: ready,
});
this.httpReport('report', {
ready_state: ready,
});
}
private async httpReport(endpoint: string, data: Record<string, unknown>) {
try {
await fetch(`/api/webrenderer/${this.instanceId}/${endpoint}`, {
@@ -302,7 +300,7 @@ this.httpReport('report', {
this.log('http report error', err);
}
}
private startPositionReporting() {
this.stopPositionReporting();
this.positionInterval = window.setInterval(() => {
@@ -311,49 +309,48 @@ this.httpReport('report', {
}
}, 1000);
}
private stopPositionReporting() {
if (this.positionInterval !== null) {
clearInterval(this.positionInterval);
this.positionInterval = null;
}
}
// ─── Public API ───────────────────────────────────────────────────────────
on<K extends keyof PlayerEvents>(event: K, fn: PlayerEvents[K]) {
this.listeners[event] = fn;
}
off<K extends keyof PlayerEvents>(event: K) {
delete this.listeners[event];
}
getState(): PlayerState {
return this.state;
}
getPosition(): number {
return this.audio.currentTime;
}
getDuration(): number | null {
const dur = this.audio.duration;
return isFinite(dur) ? dur : null;
}
// Track info from backend - not implemented yet
getTrack(): null {
return null;
}
setDebug(enabled: boolean) {
this.debug = enabled;
}
destroy() {
this.log('destroy()');
this.stop();
this.stopPositionReporting();
if (this.commandInterval !== null) {
clearInterval(this.commandInterval);
@@ -363,7 +360,14 @@ this.httpReport('report', {
document.removeEventListener('click', this.unlockListener);
this.unlockListener = null;
}
// Rapport final garanti via sendBeacon (fonctionne pendant beforeunload)
navigator.sendBeacon(
`/api/webrenderer/${this.instanceId}/report`,
JSON.stringify({ state: 'stopped' })
);
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.audio.remove();
this.ac?.close();
}
}
}