✨ 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:
54
pmowebrenderer/src/adapter.rs
Normal file
54
pmowebrenderer/src/adapter.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
Reference in New Issue
Block a user