✨ 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:
@@ -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<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 3–5 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 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.
|
||||
|
||||
24
Report/webrenderer_architecture_evolution.md
Normal file
24
Report/webrenderer_architecture_evolution.md
Normal 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
|
||||
@@ -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`,
|
||||
|
||||
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