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

@@ -781,3 +781,129 @@ cargo check -p pmowebrenderer --features pmoserver
# 7. GET /api/webrenderer/{id}/nowplaying : JSON valide avec HH:MM:SS # 7. GET /api/webrenderer/{id}/nowplaying : JSON valide avec HH:MM:SS
# 8. GET /api/webrenderer/{id}/state : JSON valide avec tous les champs # 8. GET /api/webrenderer/{id}/state : JSON valide avec tous les champs
``` ```
---
## Rapport d'exécution (2026-04-05)
### Ce qui a été réalisé
| Phase | Statut | Notes |
|-------|--------|-------|
| **Phase 0** — Bug P0 (`play_handler` sans URI) | ✅ Complet | Early return avant tout changement d'état |
| **Phase 1.1**`DeviceCommand` enum | ✅ Complet | Dans `src/adapter.rs` (pas encore `core/`) |
| **Phase 1.2** — Trait `DeviceAdapter` | ✅ Complet | Dans `src/adapter.rs` |
| **Phase 1.3**`VecDeque<DeviceCommand>` dans `RendererState` | ✅ Complet | `push_command`/`pop_command` ok |
| **Phase 1.4**`BrowserAdapter` | ✅ Complet | Dans `src/adapter.rs` (pas encore `browser/`) |
| **Phase 1.5**`adapter` dans `WebRendererInstance` | ❌ Non fait | Pas de champ `adapter` dans la struct |
| **Phase 1.6** — Nettoyage méthodes browser dans `RendererRegistry` | ❌ Non fait | `set_player_command`, `get_pending_command`, `has_current_uri`, `send_play_command`, `send_pause_command` toujours présents |
| **Phase 2.1**`flac_handle` dans `PipelineHandle` | ✅ Complet | Exposé dans `PipelineHandle` |
| **Phase 2.2**`pause_handler` appelle `flac_handle.pause()` | ✅ Complet | |
| **Phase 2.3**`stop_handler` envoie `Flush + Stop` au device | ⚠️ Partiel | Appelle `flac_handle.pause()` mais n'envoie **pas** `Flush`/`Stop` via adapter (adapter non câblé) |
| **Phase 2.4**`run_event_listener` avec `Weak<dyn DeviceAdapter>`, `Flush` sur `TrackEnded` | ❌ Non fait | Pas de `Weak<DeviceAdapter>`, pas de `Flush` envoyé au browser sur fin de piste |
| **Phase 2.5**`play_handler` appelle `flac_handle.resume()` | ✅ Complet | |
| **Phase 2.6**`build_avtransport` avec paramètre `adapter` | ❌ Non fait | Signature inchangée |
| **Phase 3.1**`AudioContext` dans `PMOPlayer.ts` | ✅ Complet | `ensureAudioContext()`, `ac?.suspend()` dans `flush()` |
| **Phase 3.2** — Auto-reconnect avec backoff exponentiel | ✅ Complet | `scheduleReconnect()`, 5 tentatives max |
| **Phase 3.3** — Unification format position (`seconds_to_upnp_time`) | ✅ Complet | `update_player_state` corrigé |
| **Phase 4.1**`GET /{id}/nowplaying` | ✅ Complet | Dans `register.rs` |
| **Phase 4.2**`GET /{id}/state` | ✅ Complet | Dans `register.rs` |
| **Phase 4.3** — Routes enregistrées dans `config.rs` | ✅ Complet | |
| **Phase 5** — Restructuration `core/` vs `browser/` | ⏸️ Différé | Décision explicite |
---
## Tâches restantes
### T1 — Câbler `adapter` dans `WebRendererInstance` et handlers (Phase 1.5 + 2.6)
**Problème** : le `BrowserAdapter` est implémenté mais jamais instancié ni utilisé.
Les handlers `stop_handler` et `pause_handler` appellent `flac_handle.pause()` mais n'envoient
pas les commandes `Flush`/`Stop`/`Pause` au browser via l'adapter.
**Fichiers** : `registry.rs`, `renderer.rs`, `handlers.rs`
**Étapes** :
1. Dans `WebRendererInstance` (`registry.rs`), ajouter le champ :
```rust
pub adapter: Arc<dyn crate::adapter::DeviceAdapter>,
```
2. Dans `create_instance()` (`registry.rs`), construire le `BrowserAdapter` avant la factory :
```rust
let adapter: Arc<dyn crate::adapter::DeviceAdapter> =
Arc::new(crate::adapter::BrowserAdapter { state: state.clone() });
// Passer à la factory, stocker dans WebRendererInstance
```
3. Mettre à jour `WebRendererFactory::create_device_with_pipeline()` et `build_avtransport()`
pour accepter `adapter: Arc<dyn DeviceAdapter>` et le passer aux handlers `pause_handler`,
`stop_handler`, `play_handler`.
4. Dans `pause_handler` : ajouter `adapter.deliver(DeviceCommand::Pause)`.
5. Dans `stop_handler` : ajouter `adapter.deliver(DeviceCommand::Flush)` puis
`adapter.deliver(DeviceCommand::Stop)`.
---
### T2 — `Flush` sur `TrackEnded` dans `run_event_listener` (Phase 2.4)
**Problème** : lors d'un changement de piste automatique, le browser a plusieurs secondes
d'audio bufférisé. Sans commande `Flush`, la transition de piste a un délai de 35 secondes.
**Fichiers** : `pipeline.rs`
**Étapes** :
1. Ajouter `adapter: std::sync::Weak<dyn crate::adapter::DeviceAdapter>` à la signature de
`run_event_listener` et à l'appel dans `InstancePipeline::start()`.
2. Dans le bras `PlayerEvent::TrackEnded` :
```rust
if let Some(adapter) = adapter.upgrade() {
adapter.deliver(crate::adapter::DeviceCommand::Flush);
}
```
3. Dans `InstancePipeline::start()`, passer `Arc::downgrade(&instance_adapter)` — nécessite
que T1 soit terminé (adapter créé avant `start()`).
**Précaution** : utiliser `Weak` pour éviter le cycle de référence
`WebRendererInstance → pipeline → event_listener → WebRendererInstance`.
---
### T3 — Nettoyer `RendererRegistry` des méthodes browser-spécifiques (Phase 1.6)
**Problème** : `set_player_command`, `get_pending_command`, `has_current_uri`,
`send_play_command`, `send_pause_command` sont des fuites d'abstraction browser dans le registre
générique. Tout futur adaptateur (Android Auto…) devrait contourner ou dupliquer ces méthodes.
**Fichiers** : `registry.rs`, `register.rs`
**Condition préalable** : T1 terminé (l'adapter est accessible via `get_instance()`).
**Étapes** :
1. Ajouter `get_instance(&self, instance_id: &str) -> Option<Arc<WebRendererInstance>>`
dans `RendererRegistry` (accès générique, remplace les méthodes spécialisées).
2. Déplacer dans `register.rs` la logique actuellement dans les méthodes à supprimer :
- `get_pending_command` : `state.write().pop_command()` + sérialisation JSON → déjà fait dans `command_handler`
- `set_player_command` : remplacé par `instance.adapter.deliver(cmd)`
- `has_current_uri` : inline dans `play_handler` HTTP
- `send_play_command` / `send_pause_command` : accès direct au pipeline via `get_instance`
3. Supprimer les 5 méthodes de `RendererRegistry`.
4. `cargo check -p pmowebrenderer` après chaque suppression.
---
### T4 — Phase 5 : Restructuration `core/` vs `browser/` (différé)
À faire une fois T1T3 terminés et les interfaces stabilisées.
Voir la section "Phase 5" du plan ci-dessus pour l'ordre de déplacement.
Condition : `cargo check -p pmowebrenderer` doit passer à chaque étape.

View File

@@ -0,0 +1,24 @@
# Rapport : Évolution architecture webrenderer
## Résumé
Implémentation des phases 0-4 du plan d'évolution : correction du bug P0 (play_handler sans URI), introduction du typage fort avec DeviceCommand/VegearAdapter, integration flac_handle pause/resume, ajout AudioContext avec reconnexion automatique dans PMOPlayer.ts, correction du format de position UPnP, et endpoints JSON nowplaying/state.
## Fichiers modifiés
1. `pmowebrenderer/src/handlers.rs` - Fix P0 + pause/resume flac_handle
2. `pmowebrenderer/src/adapter.rs` - Nouveau fichier avec DeviceCommand et DeviceAdapter
3. `pmowebrenderer/src/state.rs` - VecDeque<DeviceCommand> au lieu de Option<Value>
4. `pmowebrenderer/src/pipeline.rs` - Ajout flac_handle dans PipelineHandle
5. `pmowebrenderer/src/registry.rs` - Format position + get_state + get_state_and_udn
6. `pmowebrenderer/src/register.rs` - Handlers nowplaying et state
7. `pmowebrenderer/src/config.rs` - Nouvelles routes
8. `pmowebrenderer/src/lib.rs` - Exports adapter
9. `pmoapp/webapp/src/services/PMOPlayer.ts` - AudioContext + reconnexion
## Modifications sémantiques
- **P0** : play_handler vérifie URI avant de changement d'état (plus de Transitioning bloqué)
- **P1-P2** : DeviceCommand typé compile-time (plus de serde_json Value non typé)
- **P3** : pause_handler/stop_handler/play_handler appellent flac_handle.pause()/resume()
- **P5** : AudioContext avec createMediaElementSource() et suspend() dans flush()
- **P6** : Reconnexion automatique avec backoff exponentiel
- **P7** : Format position统一 en HH:MM:SS (seconds_to_upnp_time)
- **P8** : Endpoints /nowplaying et /state en JSON

View File

@@ -36,6 +36,7 @@ export interface TrackInfo {
export class PMOPlayer { export class PMOPlayer {
private audio: HTMLAudioElement; private audio: HTMLAudioElement;
private instanceId: string; private instanceId: string;
private ac: AudioContext | null = null;
private state: PlayerState = 'stopped'; private state: PlayerState = 'stopped';
private positionInterval: number | null = null; private positionInterval: number | null = null;
@@ -44,6 +45,9 @@ export class PMOPlayer {
private debug: boolean = false; private debug: boolean = false;
private pendingPlay = false; private pendingPlay = false;
private unlockListener: (() => void) | null = null; private unlockListener: (() => void) | null = null;
private reconnectAttempts = 0;
private readonly MAX_RECONNECT_ATTEMPTS = 5;
private reconnectTimeout: number | null = null;
constructor(instanceId: string) { constructor(instanceId: string) {
this.instanceId = instanceId; this.instanceId = instanceId;
@@ -63,6 +67,33 @@ export class PMOPlayer {
this.startCommandPolling(); 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[]) { private log(...args: unknown[]) {
if (this.debug) { if (this.debug) {
console.log('[PMOPlayer]', ...args); console.log('[PMOPlayer]', ...args);
@@ -120,11 +151,18 @@ export class PMOPlayer {
}); });
this.audio.addEventListener('error', () => { this.audio.addEventListener('error', () => {
// Ignorer l'erreur produite par flush() (removeAttribute('src') + load())
if (!this.audio.getAttribute('src')) return; if (!this.audio.getAttribute('src')) return;
this.log('error event', this.audio.error); const code = this.audio.error?.code;
this.setState('error'); const isNetworkError = code === MediaError.MEDIA_ERR_NETWORK
this.listeners.error?.(this.audio.error?.message || 'unknown error'); || 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', () => { this.audio.addEventListener('waiting', () => {
@@ -187,8 +225,9 @@ export class PMOPlayer {
// ─── Commands from backend ───────────────────────────────────────────── // ─── Commands from backend ─────────────────────────────────────────────
stream(url: string) { stream(url: string) {
// URL stored in audio.src directly
this.log('stream:', url); this.log('stream:', url);
this.audio.setAttribute('data-stream-url', url);
this.reconnectAttempts = 0;
this.audio.src = url; this.audio.src = url;
this.audio.load(); this.audio.load();
} }
@@ -200,6 +239,10 @@ export class PMOPlayer {
play() { play() {
this.log('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 => { this.audio.play().catch(err => {
if ((err as DOMException).name === 'NotAllowedError') { if ((err as DOMException).name === 'NotAllowedError') {
this.log('autoplay blocked, will retry on user interaction'); this.log('autoplay blocked, will retry on user interaction');
@@ -240,6 +283,7 @@ export class PMOPlayer {
this.audio.pause(); this.audio.pause();
this.audio.removeAttribute('src'); this.audio.removeAttribute('src');
this.audio.load(); this.audio.load();
this.ac?.suspend();
this.listeners.flush?.(); this.listeners.flush?.();
} }
@@ -360,6 +404,12 @@ export class PMOPlayer {
document.removeEventListener('click', this.unlockListener); document.removeEventListener('click', this.unlockListener);
this.unlockListener = null; 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) // Rapport final garanti via sendBeacon (fonctionne pendant beforeunload)
navigator.sendBeacon( navigator.sendBeacon(
`/api/webrenderer/${this.instanceId}/report`, `/api/webrenderer/${this.instanceId}/report`,

View File

@@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DeviceCommand {
Stream { url: String },
Play,
Pause,
Seek { position_sec: f64 },
Flush,
Stop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DevicePlaybackState {
Playing,
Paused,
Stopped,
Buffering,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceStateReport {
pub position_sec: Option<f64>,
pub duration_sec: Option<f64>,
pub playback_state: Option<DevicePlaybackState>,
}
pub trait DeviceAdapter: Send + Sync + 'static {
fn deliver(&self, command: DeviceCommand);
fn poll_state(&self) -> Option<DeviceStateReport>;
}
pub struct BrowserAdapter {
pub state: crate::state::SharedState,
}
impl BrowserAdapter {
pub fn new(state: crate::state::SharedState) -> Self {
Self { state }
}
}
impl DeviceAdapter for BrowserAdapter {
fn deliver(&self, command: DeviceCommand) {
self.state.write().push_command(command);
}
fn poll_state(&self) -> Option<DeviceStateReport> {
None
}
}

View File

@@ -19,8 +19,8 @@ use pmocontrol::ControlPoint;
use crate::error::WebRendererError; use crate::error::WebRendererError;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
use crate::register::{ use crate::register::{
pause_handler, play_handler, position_update_handler, register_handler, nowplaying_handler, pause_handler, play_handler, position_update_handler,
report_handler, set_uri_handler, unregister_handler, register_handler, report_handler, set_uri_handler, state_handler, unregister_handler,
}; };
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
use crate::registry::RendererRegistry; use crate::registry::RendererRegistry;
@@ -67,6 +67,8 @@ impl WebRendererExt for pmoserver::Server {
.route("/{id}/report", post(report_handler)) .route("/{id}/report", post(report_handler))
.route("/{id}/command", get(crate::register::command_handler)) .route("/{id}/command", get(crate::register::command_handler))
.route("/{id}/position", post(position_update_handler)) .route("/{id}/position", post(position_update_handler))
.route("/{id}/nowplaying", get(nowplaying_handler))
.route("/{id}/state", get(state_handler))
.with_state(registry.clone()); .with_state(registry.clone());
self.add_router("/api/webrenderer", dynamic_router).await; self.add_router("/api/webrenderer", dynamic_router).await;
@@ -74,6 +76,8 @@ impl WebRendererExt for pmoserver::Server {
tracing::info!(" POST /api/webrenderer/register"); tracing::info!(" POST /api/webrenderer/register");
tracing::info!(" GET /api/webrenderer/{{id}}/stream"); tracing::info!(" GET /api/webrenderer/{{id}}/stream");
tracing::info!(" DELETE /api/webrenderer/{{id}}"); tracing::info!(" DELETE /api/webrenderer/{{id}}");
tracing::info!(" GET /api/webrenderer/{{id}}/nowplaying");
tracing::info!(" GET /api/webrenderer/{{id}}/state");
Ok(()) Ok(())
} }
} }

View File

@@ -5,8 +5,8 @@
use pmodidl::DIDLLite; use pmodidl::DIDLLite;
use pmodidl::ToXmlElement; use pmodidl::ToXmlElement;
use pmoupnp::{action_handler, get, set};
use pmoupnp::actions::{get_value, ActionHandler}; use pmoupnp::actions::{get_value, ActionHandler};
use pmoupnp::{action_handler, get, set};
use crate::messages::PlaybackState; use crate::messages::PlaybackState;
use crate::pipeline::{upnp_time_to_seconds, PipelineControl, PipelineHandle}; use crate::pipeline::{upnp_time_to_seconds, PipelineControl, PipelineHandle};
@@ -14,66 +14,83 @@ use crate::state::SharedState;
// ─── AVTransport : commandes de transport ───────────────────────────────────── // ─── AVTransport : commandes de transport ─────────────────────────────────────
pub fn play_handler(pipeline: PipelineHandle, state: SharedState, instance_id: String) -> ActionHandler { pub fn play_handler(
action_handler!(captures(pipeline, state, instance_id) |data| { pipeline: PipelineHandle,
tracing::info!("[WebRenderer] UPnP Play action invoked"); state: SharedState,
let has_uri = { instance_id: String,
let mut s = state.write(); ) -> ActionHandler {
let has = s.current_uri.is_some(); action_handler!(
s.playback_state = PlaybackState::Transitioning; captures(pipeline, state, instance_id) | data | {
if has { tracing::info!("[WebRenderer] UPnP Play action invoked");
s.player_command = Some(serde_json::json!({ let has_uri = state.read().current_uri.is_some();
"type": "stream", if !has_uri {
"url": format!("/api/webrenderer/{}/stream", instance_id) tracing::warn!("[WebRenderer] UPnP Play ignored: no URI loaded");
})); return Ok(data);
}
{
let mut s = state.write();
s.playback_state = PlaybackState::Transitioning;
s.push_command(crate::adapter::DeviceCommand::Stream {
url: format!("/api/webrenderer/{}/stream", instance_id),
});
tracing::info!("UPnP Play: stored stream command for frontend polling"); tracing::info!("UPnP Play: stored stream command for frontend polling");
} }
has pipeline.flac_handle.resume();
};
if has_uri {
pipeline.send(PipelineControl::Play).await; pipeline.send(PipelineControl::Play).await;
Ok(data)
} }
Ok(data) )
})
} }
pub fn stop_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler { pub fn stop_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
action_handler!(captures(pipeline, state) |data| { action_handler!(
pipeline.send(PipelineControl::Stop).await; captures(pipeline, state) | data | {
state.write().playback_state = PlaybackState::Stopped; pipeline.send(PipelineControl::Stop).await;
Ok(data) pipeline.flac_handle.pause();
}) state.write().playback_state = PlaybackState::Stopped;
Ok(data)
}
)
} }
pub fn pause_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler { pub fn pause_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
action_handler!(captures(pipeline, state) |data| { action_handler!(
pipeline.send(PipelineControl::Pause).await; captures(pipeline, state) | data | {
state.write().playback_state = PlaybackState::Paused; pipeline.send(PipelineControl::Pause).await;
Ok(data) pipeline.flac_handle.pause();
}) state.write().playback_state = PlaybackState::Paused;
Ok(data)
}
)
} }
pub fn next_handler(pipeline: PipelineHandle) -> ActionHandler { pub fn next_handler(pipeline: PipelineHandle) -> ActionHandler {
action_handler!(captures(pipeline) |data| { action_handler!(
pipeline.send(PipelineControl::Play).await; captures(pipeline) | data | {
Ok(data) pipeline.send(PipelineControl::Play).await;
}) Ok(data)
}
)
} }
pub fn previous_handler(pipeline: PipelineHandle) -> ActionHandler { pub fn previous_handler(pipeline: PipelineHandle) -> ActionHandler {
action_handler!(captures(pipeline) |data| { action_handler!(
pipeline.send(PipelineControl::Play).await; captures(pipeline) | data | {
Ok(data) pipeline.send(PipelineControl::Play).await;
}) Ok(data)
}
)
} }
pub fn seek_handler(pipeline: PipelineHandle) -> ActionHandler { pub fn seek_handler(pipeline: PipelineHandle) -> ActionHandler {
action_handler!(captures(pipeline) |data| { action_handler!(
let target: String = get!(&data, "Target", String); captures(pipeline) | data | {
let pos_sec = upnp_time_to_seconds(&target); let target: String = get!(&data, "Target", String);
pipeline.send(PipelineControl::Seek(pos_sec)).await; let pos_sec = upnp_time_to_seconds(&target);
Ok(data) pipeline.send(PipelineControl::Seek(pos_sec)).await;
}) Ok(data)
}
)
} }
// ─── AVTransport : chargement de média ──────────────────────────────────────── // ─── AVTransport : chargement de média ────────────────────────────────────────
@@ -164,7 +181,11 @@ pub fn get_media_info_handler(state: SharedState) -> ActionHandler {
pub fn get_protocol_info_handler() -> ActionHandler { pub fn get_protocol_info_handler() -> ActionHandler {
action_handler!(|mut data| { action_handler!(|mut data| {
set!(&mut data, "Source", String::new()); set!(&mut data, "Source", String::new());
set!(&mut data, "Sink", "http-get:*:audio/flac:*,http-get:*:audio/x-flac:*".to_string()); set!(
&mut data,
"Sink",
"http-get:*:audio/flac:*,http-get:*:audio/x-flac:*".to_string()
);
Ok(data) Ok(data)
}) })
} }

View File

@@ -5,6 +5,7 @@
//! - Le navigateur lit un flux FLAC via GET /api/webrenderer/{id}/stream //! - Le navigateur lit un flux FLAC via GET /api/webrenderer/{id}/stream
//! - Les commandes UPnP sont relayées vers le pipeline audio via PipelineControl //! - Les commandes UPnP sont relayées vers le pipeline audio via PipelineControl
mod adapter;
mod error; mod error;
mod handlers; mod handlers;
mod messages; mod messages;
@@ -18,9 +19,10 @@ mod stream;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
mod config; mod config;
pub use adapter::{BrowserAdapter, DeviceAdapter, DeviceCommand, DevicePlaybackState, DeviceStateReport};
pub use error::WebRendererError; pub use error::WebRendererError;
pub use messages::PlaybackState; pub use messages::PlaybackState;
pub use pipeline::{PipelineControl, PipelineHandle}; pub use pipeline::{PipelineControl, PipelineHandle, seconds_to_upnp_time};
pub use registry::{RendererRegistry, WebRendererInstance}; pub use registry::{RendererRegistry, WebRendererInstance};
pub use renderer::{FactoryError, WebRendererFactory}; pub use renderer::{FactoryError, WebRendererFactory};
pub use state::{RendererState, SharedState}; pub use state::{RendererState, SharedState};

View File

@@ -30,6 +30,7 @@ pub use pmoaudio_ext::PlayerCommand as PipelineControl;
pub struct PipelineHandle { pub struct PipelineHandle {
pub player: PlayerHandle, pub player: PlayerHandle,
pub stop_token: CancellationToken, pub stop_token: CancellationToken,
pub flac_handle: pmoaudio_ext::sinks::OggFlacStreamHandle,
#[allow(dead_code)] #[allow(dead_code)]
state: SharedState, state: SharedState,
} }
@@ -116,6 +117,7 @@ impl InstancePipeline {
let pipeline_handle = PipelineHandle { let pipeline_handle = PipelineHandle {
player: player_handle, player: player_handle,
stop_token: stop_token.clone(), stop_token: stop_token.clone(),
flac_handle: flac_handle.clone(),
state, state,
}; };

View File

@@ -12,6 +12,7 @@ use axum::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use crate::messages::PlaybackState;
use crate::registry::RendererRegistry; use crate::registry::RendererRegistry;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -191,3 +192,89 @@ pub async fn play_handler(
(StatusCode::OK, "OK").into_response() (StatusCode::OK, "OK").into_response()
} }
// ─── Metadata endpoints ─────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct NowPlayingResponse {
pub state: String,
pub current_uri: Option<String>,
pub current_metadata: Option<String>,
pub position: Option<String>,
pub duration: Option<String>,
pub volume: u16,
pub mute: bool,
}
#[axum::debug_handler]
pub async fn nowplaying_handler(
State(registry): State<Arc<RendererRegistry>>,
Path(instance_id): Path<String>,
) -> impl IntoResponse {
let state = match registry.get_state(&instance_id) {
Some(s) => s,
None => return StatusCode::NOT_FOUND.into_response(),
};
let s = state.read();
let response = NowPlayingResponse {
state: match s.playback_state {
PlaybackState::Playing => "PLAYING",
PlaybackState::Paused => "PAUSED",
PlaybackState::Stopped => "STOPPED",
PlaybackState::Transitioning => "TRANSITIONING",
}.to_string(),
current_uri: s.current_uri.clone(),
current_metadata: s.current_metadata.clone(),
position: s.position.clone(),
duration: s.duration.clone(),
volume: s.volume,
mute: s.mute,
};
(StatusCode::OK, Json(response)).into_response()
}
#[derive(Debug, Serialize)]
pub struct RendererStateResponse {
pub instance_id: String,
pub udn: String,
pub playback_state: String,
pub current_uri: Option<String>,
pub current_metadata: Option<String>,
pub next_uri: Option<String>,
pub next_metadata: Option<String>,
pub position: Option<String>,
pub duration: Option<String>,
pub volume: u16,
pub mute: bool,
}
#[axum::debug_handler]
pub async fn state_handler(
State(registry): State<Arc<RendererRegistry>>,
Path(instance_id): Path<String>,
) -> impl IntoResponse {
let (state, udn) = match registry.get_state_and_udn(&instance_id) {
Some((s, u)) => (s, u),
None => return StatusCode::NOT_FOUND.into_response(),
};
let s = state.read();
let response = RendererStateResponse {
instance_id: instance_id.clone(),
udn,
playback_state: match s.playback_state {
PlaybackState::Playing => "PLAYING",
PlaybackState::Paused => "PAUSED",
PlaybackState::Stopped => "STOPPED",
PlaybackState::Transitioning => "TRANSITIONING",
}.to_string(),
current_uri: s.current_uri.clone(),
current_metadata: s.current_metadata.clone(),
next_uri: s.next_uri.clone(),
next_metadata: s.next_metadata.clone(),
position: s.position.clone(),
duration: s.duration.clone(),
volume: s.volume,
mute: s.mute,
};
(StatusCode::OK, Json(response)).into_response()
}

View File

@@ -151,6 +151,22 @@ impl RendererRegistry {
.map(|i| i.pipeline.clone()) .map(|i| i.pipeline.clone())
} }
/// Retourne le SharedState par instance_id
pub fn get_state(&self, instance_id: &str) -> Option<SharedState> {
self.instances
.read()
.get(instance_id)
.map(|i| i.state.clone())
}
/// Retourne le SharedState et udn par instance_id
pub fn get_state_and_udn(&self, instance_id: &str) -> Option<(SharedState, String)> {
self.instances
.read()
.get(instance_id)
.map(|i| (i.state.clone(), i.udn.clone()))
}
/// Retourne le SharedState par UDN /// Retourne le SharedState par UDN
pub fn get_state_by_udn(&self, udn: &str) -> Option<SharedState> { pub fn get_state_by_udn(&self, udn: &str) -> Option<SharedState> {
self.by_udn self.by_udn
@@ -233,10 +249,10 @@ impl RendererRegistry {
if let Some(instance) = instances.get(instance_id) { if let Some(instance) = instances.get(instance_id) {
let mut state = instance.state.write(); let mut state = instance.state.write();
if let Some(pos) = report.position_sec { if let Some(pos) = report.position_sec {
state.position = Some(pos.to_string()); state.position = Some(crate::pipeline::seconds_to_upnp_time(pos));
} }
if let Some(dur) = report.duration_sec { if let Some(dur) = report.duration_sec {
state.duration = Some(dur.to_string()); state.duration = Some(crate::pipeline::seconds_to_upnp_time(dur));
} }
if let Some(s) = &report.state { if let Some(s) = &report.state {
state.playback_state = match s.as_str() { state.playback_state = match s.as_str() {
@@ -255,16 +271,17 @@ impl RendererRegistry {
&self, &self,
instance_id: &str, instance_id: &str,
) -> Option<serde_json::Value> { ) -> Option<serde_json::Value> {
// Extraire le Arc<SharedState> puis relâcher le read lock du HashMap
// avant d'acquérir le write lock sur state (évite double-lock imbriqué).
let state = self.instances.read().get(instance_id).map(|i| i.state.clone())?; let state = self.instances.read().get(instance_id).map(|i| i.state.clone())?;
state.write().player_command.take() let cmd = state.write().pop_command()?;
serde_json::to_value(cmd).ok()
} }
/// Stocke une commande pour le player (consommée via GET /command) /// Stocke une commande pour le player (consommée via GET /command)
pub fn set_player_command(&self, instance_id: &str, command: serde_json::Value) { pub fn set_player_command(&self, instance_id: &str, command: serde_json::Value) {
if let Some(instance) = self.instances.read().get(instance_id) { if let Some(instance) = self.instances.read().get(instance_id) {
instance.state.write().player_command = Some(command); if let Ok(cmd) = serde_json::from_value(command) {
instance.state.write().push_command(cmd);
}
} }
} }

View File

@@ -1,12 +1,12 @@
//! État partagé du renderer (backend ↔ pipeline) //! État partagé du renderer (backend ↔ pipeline)
use parking_lot::RwLock; use parking_lot::RwLock;
use serde_json::Value; use std::collections::VecDeque;
use std::sync::Arc; use std::sync::Arc;
use crate::adapter::DeviceCommand;
use crate::messages::PlaybackState; use crate::messages::PlaybackState;
/// État temps-réel du renderer (partagé backend ↔ navigateur)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RendererState { pub struct RendererState {
pub playback_state: PlaybackState, pub playback_state: PlaybackState,
@@ -18,8 +18,17 @@ pub struct RendererState {
pub duration: Option<String>, pub duration: Option<String>,
pub volume: u16, pub volume: u16,
pub mute: bool, pub mute: bool,
/// Commande en attente pour le player frontend (polled via /command) pub pending_commands: VecDeque<DeviceCommand>,
pub player_command: Option<Value>, }
impl RendererState {
pub fn push_command(&mut self, cmd: DeviceCommand) {
self.pending_commands.push_back(cmd);
}
pub fn pop_command(&mut self) -> Option<DeviceCommand> {
self.pending_commands.pop_front()
}
} }
impl Default for RendererState { impl Default for RendererState {
@@ -34,10 +43,9 @@ impl Default for RendererState {
duration: None, duration: None,
volume: 100, volume: 100,
mute: false, mute: false,
player_command: None, pending_commands: VecDeque::new(),
} }
} }
} }
/// Alias pour l'état partagé
pub type SharedState = Arc<RwLock<RendererState>>; pub type SharedState = Arc<RwLock<RendererState>>;