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

@@ -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;
#[cfg(feature = "pmoserver")]
use crate::register::{
pause_handler, play_handler, position_update_handler, register_handler,
report_handler, set_uri_handler, unregister_handler,
nowplaying_handler, pause_handler, play_handler, position_update_handler,
register_handler, report_handler, set_uri_handler, state_handler, unregister_handler,
};
#[cfg(feature = "pmoserver")]
use crate::registry::RendererRegistry;
@@ -67,6 +67,8 @@ impl WebRendererExt for pmoserver::Server {
.route("/{id}/report", post(report_handler))
.route("/{id}/command", get(crate::register::command_handler))
.route("/{id}/position", post(position_update_handler))
.route("/{id}/nowplaying", get(nowplaying_handler))
.route("/{id}/state", get(state_handler))
.with_state(registry.clone());
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!(" GET /api/webrenderer/{{id}}/stream");
tracing::info!(" DELETE /api/webrenderer/{{id}}");
tracing::info!(" GET /api/webrenderer/{{id}}/nowplaying");
tracing::info!(" GET /api/webrenderer/{{id}}/state");
Ok(())
}
}

View File

@@ -5,8 +5,8 @@
use pmodidl::DIDLLite;
use pmodidl::ToXmlElement;
use pmoupnp::{action_handler, get, set};
use pmoupnp::actions::{get_value, ActionHandler};
use pmoupnp::{action_handler, get, set};
use crate::messages::PlaybackState;
use crate::pipeline::{upnp_time_to_seconds, PipelineControl, PipelineHandle};
@@ -14,66 +14,83 @@ use crate::state::SharedState;
// ─── AVTransport : commandes de transport ─────────────────────────────────────
pub fn play_handler(pipeline: PipelineHandle, state: SharedState, instance_id: String) -> ActionHandler {
action_handler!(captures(pipeline, state, instance_id) |data| {
tracing::info!("[WebRenderer] UPnP Play action invoked");
let has_uri = {
let mut s = state.write();
let has = s.current_uri.is_some();
s.playback_state = PlaybackState::Transitioning;
if has {
s.player_command = Some(serde_json::json!({
"type": "stream",
"url": format!("/api/webrenderer/{}/stream", instance_id)
}));
pub fn play_handler(
pipeline: PipelineHandle,
state: SharedState,
instance_id: String,
) -> ActionHandler {
action_handler!(
captures(pipeline, state, instance_id) | data | {
tracing::info!("[WebRenderer] UPnP Play action invoked");
let has_uri = state.read().current_uri.is_some();
if !has_uri {
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");
}
has
};
if has_uri {
pipeline.flac_handle.resume();
pipeline.send(PipelineControl::Play).await;
Ok(data)
}
Ok(data)
})
)
}
pub fn stop_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
action_handler!(captures(pipeline, state) |data| {
pipeline.send(PipelineControl::Stop).await;
state.write().playback_state = PlaybackState::Stopped;
Ok(data)
})
action_handler!(
captures(pipeline, state) | data | {
pipeline.send(PipelineControl::Stop).await;
pipeline.flac_handle.pause();
state.write().playback_state = PlaybackState::Stopped;
Ok(data)
}
)
}
pub fn pause_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
action_handler!(captures(pipeline, state) |data| {
pipeline.send(PipelineControl::Pause).await;
state.write().playback_state = PlaybackState::Paused;
Ok(data)
})
action_handler!(
captures(pipeline, state) | data | {
pipeline.send(PipelineControl::Pause).await;
pipeline.flac_handle.pause();
state.write().playback_state = PlaybackState::Paused;
Ok(data)
}
)
}
pub fn next_handler(pipeline: PipelineHandle) -> ActionHandler {
action_handler!(captures(pipeline) |data| {
pipeline.send(PipelineControl::Play).await;
Ok(data)
})
action_handler!(
captures(pipeline) | data | {
pipeline.send(PipelineControl::Play).await;
Ok(data)
}
)
}
pub fn previous_handler(pipeline: PipelineHandle) -> ActionHandler {
action_handler!(captures(pipeline) |data| {
pipeline.send(PipelineControl::Play).await;
Ok(data)
})
action_handler!(
captures(pipeline) | data | {
pipeline.send(PipelineControl::Play).await;
Ok(data)
}
)
}
pub fn seek_handler(pipeline: PipelineHandle) -> ActionHandler {
action_handler!(captures(pipeline) |data| {
let target: String = get!(&data, "Target", String);
let pos_sec = upnp_time_to_seconds(&target);
pipeline.send(PipelineControl::Seek(pos_sec)).await;
Ok(data)
})
action_handler!(
captures(pipeline) | data | {
let target: String = get!(&data, "Target", String);
let pos_sec = upnp_time_to_seconds(&target);
pipeline.send(PipelineControl::Seek(pos_sec)).await;
Ok(data)
}
)
}
// ─── 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 {
action_handler!(|mut data| {
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)
})
}

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ use axum::{
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::messages::PlaybackState;
use crate::registry::RendererRegistry;
#[derive(Debug, Deserialize)]
@@ -191,3 +192,89 @@ pub async fn play_handler(
(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())
}
/// 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
pub fn get_state_by_udn(&self, udn: &str) -> Option<SharedState> {
self.by_udn
@@ -233,10 +249,10 @@ impl RendererRegistry {
if let Some(instance) = instances.get(instance_id) {
let mut state = instance.state.write();
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 {
state.duration = Some(dur.to_string());
state.duration = Some(crate::pipeline::seconds_to_upnp_time(dur));
}
if let Some(s) = &report.state {
state.playback_state = match s.as_str() {
@@ -255,16 +271,17 @@ impl RendererRegistry {
&self,
instance_id: &str,
) -> 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())?;
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)
pub fn set_player_command(&self, instance_id: &str, command: serde_json::Value) {
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)
use parking_lot::RwLock;
use serde_json::Value;
use std::collections::VecDeque;
use std::sync::Arc;
use crate::adapter::DeviceCommand;
use crate::messages::PlaybackState;
/// État temps-réel du renderer (partagé backend ↔ navigateur)
#[derive(Debug, Clone)]
pub struct RendererState {
pub playback_state: PlaybackState,
@@ -18,8 +18,17 @@ pub struct RendererState {
pub duration: Option<String>,
pub volume: u16,
pub mute: bool,
/// Commande en attente pour le player frontend (polled via /command)
pub player_command: Option<Value>,
pub pending_commands: VecDeque<DeviceCommand>,
}
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 {
@@ -34,10 +43,9 @@ impl Default for RendererState {
duration: None,
volume: 100,
mute: false,
player_command: None,
pending_commands: VecDeque::new(),
}
}
}
/// Alias pour l'état partagé
pub type SharedState = Arc<RwLock<RendererState>>;