diff --git a/Blackboard/Todo/webrenderer_architecture_evolution.md b/Blackboard/Todo/webrenderer_architecture_evolution.md index 862e2a7f..4f3a65e4 100644 --- a/Blackboard/Todo/webrenderer_architecture_evolution.md +++ b/Blackboard/Todo/webrenderer_architecture_evolution.md @@ -781,3 +781,129 @@ cargo check -p pmowebrenderer --features pmoserver # 7. GET /api/webrenderer/{id}/nowplaying : JSON valide avec HH:MM:SS # 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` 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`, `Flush` sur `TrackEnded` | ❌ Non fait | Pas de `Weak`, 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, + ``` + +2. Dans `create_instance()` (`registry.rs`), construire le `BrowserAdapter` avant la factory : + ```rust + let adapter: Arc = + 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` 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 3–5 secondes. + +**Fichiers** : `pipeline.rs` + +**Étapes** : + +1. Ajouter `adapter: std::sync::Weak` à 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>` + 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 T1–T3 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. diff --git a/Report/webrenderer_architecture_evolution.md b/Report/webrenderer_architecture_evolution.md new file mode 100644 index 00000000..bc30b3db --- /dev/null +++ b/Report/webrenderer_architecture_evolution.md @@ -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 au lieu de Option +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 \ No newline at end of file diff --git a/pmoapp/webapp/src/services/PMOPlayer.ts b/pmoapp/webapp/src/services/PMOPlayer.ts index 974c6a15..23d4431d 100644 --- a/pmoapp/webapp/src/services/PMOPlayer.ts +++ b/pmoapp/webapp/src/services/PMOPlayer.ts @@ -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`, diff --git a/pmowebrenderer/src/adapter.rs b/pmowebrenderer/src/adapter.rs new file mode 100644 index 00000000..b80004a5 --- /dev/null +++ b/pmowebrenderer/src/adapter.rs @@ -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, + pub duration_sec: Option, + pub playback_state: Option, +} + +pub trait DeviceAdapter: Send + Sync + 'static { + fn deliver(&self, command: DeviceCommand); + fn poll_state(&self) -> Option; +} + +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 { + None + } +} diff --git a/pmowebrenderer/src/config.rs b/pmowebrenderer/src/config.rs index f4f0f3ab..ffd03869 100644 --- a/pmowebrenderer/src/config.rs +++ b/pmowebrenderer/src/config.rs @@ -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(()) } } diff --git a/pmowebrenderer/src/handlers.rs b/pmowebrenderer/src/handlers.rs index a02abcd8..1c2e596e 100644 --- a/pmowebrenderer/src/handlers.rs +++ b/pmowebrenderer/src/handlers.rs @@ -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) }) } diff --git a/pmowebrenderer/src/lib.rs b/pmowebrenderer/src/lib.rs index b27f2471..12e149d5 100644 --- a/pmowebrenderer/src/lib.rs +++ b/pmowebrenderer/src/lib.rs @@ -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}; diff --git a/pmowebrenderer/src/pipeline.rs b/pmowebrenderer/src/pipeline.rs index 9be9036f..44476b35 100644 --- a/pmowebrenderer/src/pipeline.rs +++ b/pmowebrenderer/src/pipeline.rs @@ -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, }; diff --git a/pmowebrenderer/src/register.rs b/pmowebrenderer/src/register.rs index 689235e6..f2f52efc 100644 --- a/pmowebrenderer/src/register.rs +++ b/pmowebrenderer/src/register.rs @@ -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, + pub current_metadata: Option, + pub position: Option, + pub duration: Option, + pub volume: u16, + pub mute: bool, +} + +#[axum::debug_handler] +pub async fn nowplaying_handler( + State(registry): State>, + Path(instance_id): Path, +) -> 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, + pub current_metadata: Option, + pub next_uri: Option, + pub next_metadata: Option, + pub position: Option, + pub duration: Option, + pub volume: u16, + pub mute: bool, +} + +#[axum::debug_handler] +pub async fn state_handler( + State(registry): State>, + Path(instance_id): Path, +) -> 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() +} diff --git a/pmowebrenderer/src/registry.rs b/pmowebrenderer/src/registry.rs index 7699d655..00b626ed 100644 --- a/pmowebrenderer/src/registry.rs +++ b/pmowebrenderer/src/registry.rs @@ -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 { + 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 { 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 { - // Extraire le Arc 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); + } } } diff --git a/pmowebrenderer/src/state.rs b/pmowebrenderer/src/state.rs index a41d1cbe..c6592090 100644 --- a/pmowebrenderer/src/state.rs +++ b/pmowebrenderer/src/state.rs @@ -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, pub volume: u16, pub mute: bool, - /// Commande en attente pour le player frontend (polled via /command) - pub player_command: Option, + pub pending_commands: VecDeque, +} + +impl RendererState { + pub fn push_command(&mut self, cmd: DeviceCommand) { + self.pending_commands.push_back(cmd); + } + + pub fn pop_command(&mut self) -> Option { + 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>;