push-nvqvwnmvprww #87

Merged
eric merged 14 commits from push-nvqvwnmvprww into main 2026-04-05 21:48:10 +02:00
76 changed files with 4102 additions and 1930 deletions

View File

@@ -0,0 +1,151 @@
# Plan: pmowebrenderer - Améliorations et Multi-client avec DSP
## Objectifs
1. **Améliorer l'intégration UPnP Control** - Meilleur fonctionnement des commandes Play/Pause/Seek
- Contrôle piloté près de la sortie (streaming) plutôt qu'au début du pipeline
- Pour Pause: latence actuelle trop importante
- Play/Pause/Seek doivent fonctionner simultanément sur tous les clients
2. **Améliorer la performance - Latence** - Réduire le délai entre l'envoi et la lecture
3. **Ajouter le support multi-client avec DSP** - Chaque client peut avoir son propre pipeline DSP
## Comportement UPnP Control
### Mode Radio (flux infini)
- Pas de pause possible, hanya next ou stop
- Seek n'a pas de sens
### Architecture
```
PlayerSource → ResamplingNode → ToI24Node
├──→ [DSP Client 1] → StreamingOggFlacSink 1
├──→ [DSP Client 2] → StreamingOggFlacSink 2
└──→ ... (dynamique)
```
Le control point UPnP voit UN seul Media Renderer. Les commandes Play/Pause/Seek affectent TOUTES les sorties client simultanément.
## État Actuel
Le pipeline actuel est linéaire pour un seul client:
```
PlayerSource → ResamplingNode (96kHz) → ToI24Node → StreamingOggFlacSink
```
**Note importante:** Utiliser les crates pmoaudio et pmoaudio-ext existantes. Il est possible d'avoir plusieurs `StreamingOggFlacSink` consommant le même flux. Après ToI24Node, brancher en étoiles les différents DSP pour les différents clients.
## Plan d'Implémentation
### Phase 1: Amélioration UPnP Control
1. **Analyser les handlers existants** dans `handlers.rs`
2. **Identifier les problèmes** avec Play/Pause/Seek:
- Timing des transitions d'état
- Gestion des erreurs
- Synchronisation entre clients HTTP et état UPnP
3. **Améliorer la fiabilité** des commandes
- Piloter le contrôle près de la sortie (streaming)
- Différerencier le comportement radio vs piste finie
### Phase 2: Amélioration Latence
1. **Réduire le buffer** dans `StreamingOggFlacSink`
2. **Optimiser le pacing** (actuellement max 0.5s ahead)
3. **Améliorer la directité** du chemin audio
### Phase 3: Architecture Multi-client avec DSP
1. **Refactorer le pipeline** pour supporter plusieurs clients comme décrit ci-dessus
2. **Créer un système de DSP** dans pmoaudio ou pmoaudio-ext:
- Interface commune pour les effets audio
- Config des DSP via PMOconfig
- Room correction: equalizer, FIR filter, delay, gain
3. **Gérer le cycle de vie**:
- Création du pipeline par client
- Nettoyage lors de la déconnexion
- Partage de la source commune entre clients
## Fichiers à Modifier
- `pipeline.rs` - Refactoring pour multi-client
- `handlers.rs` - Amélioration UPnP control
- `stream.rs` - Gestion multi-client
- `state.rs` - État par client
- pmoaudio ou pmoaudio-ext pour les mécanismes DSP
## Défis Potentiels
- Performance CPU avec plusieurs clients
- Synchronisation des clients avec le même contenu
- Gestion du gapless entre les pistes avec multi-client
### Gestion de la Pause
**Option recommandée: Silence (zéros)**
- Pendant la pause, continuer à envoyer des zéros encodés en FLAC
- Le client HTTP maintient sa connexion TCP alive
- Pas de reconnexion nécessaire quand on reprend la lecture
- Avantage: Seamless pour le client
**Pourquoi pas réduction du sample rate:**
- Le header FLAC définit le sample rate en固定entête
- Changer le sample rate en cours de flux invalidate le flux entier
- Rebuild du flux serait plus complexe que le gain obtenu
- FLAC compresse très bien les zéros de toute façon (beaucoup de répétitions)
**Autre option envisagée mais non recommandée:**
- Suspendre l'envoi: Le client HTTP va timeout et se déconnecter
- Segment OGG avec metadata: Complexe à implémenter, nécessite modification du client
### Phase 0: StreamingOggFlacSink avec contrôle Pause
**Distinction Radio vs Pistes finies:**
| Mode | Comportement pendant Pause |
|------|---------------------------|
| **Radio (flux infini)** | Les chunks qui arrivent sont ignorés/perdus. On envoie du silence. La source continue à produire mais on n'en tient pas compte. |
| **Pistes finies** | On bloque la consommation des chunks. Par backpressure, le pipeline en amont s'arrête (TimerBufferNode arrête d'envoyer). La lecture est truly arrêtée. |
**Architecture actuelle analysée:**
```
AudioSegment → StreamingOggFlacSink → FLAC encoder → OGG wrapper → timed_broadcast → clients
```
**Implémentation suggérée:**
1. **État de lecture distingué:**
- `PlaybackMode::Radio` - ignore les chunks entrants pendant pause
- `PlaybackMode::Track` - bloque la consommation (backpressure)
2. **Dans SharedSinkContext:**
```rust
pub enum PlaybackMode {
Radio, // Flux infini - ignore chunks pendant pause
Track, // Piste finie - block par backpressure
}
pub playback_mode: PlaybackMode,
pub is_paused: Arc<AtomicBool>,
```
3. **Traitement différent selon le mode:**
- **Radio**: Si `is_paused`, envoyer silence (zéros) mais perdre les chunks entrants
- **Track**: Si `is_paused`, ne pas consommer les chunks → backpressure → arrêt du pipeline en amont
4. **Transition automatique:**
- Détecter le type de contenu via les métadonnées du TrackBoundary
- **Enrichir TrackBoundary** avec un champ `stream_type`:
```rust
pub enum StreamType {
Continuous, // Radio/webcast - flux infini
Finite, // Piste/album - flux avec fin définie
}
pub stream_type: StreamType,
```
- Si durée inconnue = Radio (Continuous), si durée connue = Track (Finite)

View File

@@ -0,0 +1,58 @@
# Refonte de `pmowebrenderer` Élimination des redondances
## Objectif
Réduire la duplication de code entre les fonctions de construction de services UPnP (`build_avtransport`, `build_renderingcontrol`, `build_connectionmanager`) et les macros dajout darguments (`add_arg_in!`, `add_arg_out!`).
Cela améliore la maintenabilité, la lisibilité et diminue le risque dincohérences.
## Étapes détaillées
1. **Création dune fonction générique `build_service`**
- Signature proposée:
```rust
fn build_service(
name: &str,
variables: Vec<Arc<Variable>>,
actions: Vec<Action>,
handlers: Vec<Handler>,
) -> Result<Service, FactoryError>
```
- Implémentation unique de lajout de variables, dactions et de handlers.
- Chaque fonction existante (`build_avtransport`, `build_renderingcontrol`, `build_connectionmanager`) appelle `build_service` avec les paramètres spécifiques.
2. **Refactorisation des macros**
- Remplacer `macro_rules! add_arg_in!` et `add_arg_out!` par des fonctions如此一来 :
```rust
fn add_arg_in(action: &mut Action, name: &str, var: Arc<Argument>) -> Result<(), FactoryError>
fn add_arg_out(action: &mut Action, name: &str, var: Arc<Argument>) -> Result<(), FactoryError>
```
- Ces fonctions encapsulent la logique dajout darguments et centralisent la gestion derreur.
3. **Mise à jour des implémentations**
- Modifier `build_avtransport`, `build_renderingcontrol`, `build_connectionmanager` pour déléguer à `build_service` et aux nouvelles fonctions dargument.
- Vérifier que les imports restent cohérents (ajouter `use` nécessaires pour `Variable`, `Handler`, etc.).
4. **Suppression des ancrés macros**
- Retirer les declarations `macro_rules! add_arg_in!` et `macro_rules! add_arg_out!` du fichier `renderer.rs`.
- Adapter le code appelant pour utiliser les fonctions concrètes.
5. **Tests et CI**
- Ajouter des tests unitaires couvrant les nouvelles fonctions `build_service`, `add_arg_in`, `add_arg_out`.
- Configurer le pipeline CI pour exécuter `cargo test` et `cargo clippy` afin de détecter déventuelles regressions.
6. **Documentation**
- Mettre à jour les commentaires pour refléter les nouvelles abstractions.
- Ajouter une section « Refactorisation » dans le `README` décrivant les changements.
## Impact attendu
- **Réduction** : ~12 lignes de code redondantes éliminées.
- **Maintenabilité** : modification centralisée de la logique de construction de services.
- **Robustesse** : baisse du risque dincohérences et de bugs liés à la duplication.
- **Lisibilité** : code plus explicite et plus proche du modèle de domaine.
## Prochaines actions
1. Implémenter les changements proposés dans les fichiers concernés.
2. Exécuter la suite de tests pour valider la refonte.
3. Commiter les modifications après revue.
---
Plan finalisé.

View File

@@ -0,0 +1,108 @@
# Plan: Suppression des duplications dans `@pmowebrenderer`
## Objectif
Éliminer les redondances de code identifiées lors de l'audit.
---
## Duplication 1: Helper functions dupliquées dans `renderer.rs`
**Fichiers affectés**: `src/renderer.rs`
**Problème**:
- Lignes 17-54: `add_arg_in`, `add_arg_out`, `add_var`, `add_action` définies
- Lignes 150-181: Dans `build_avtransport()`, réimplémentation locale avec closures `|svc, var| { ... }`
- Répétition de 20+ appels `add_var(&mut svc, &VAR)?` et `add_action(&mut svc, Arc::new(action))?`
**Solution**:
1. Supprimer les closures locales redéclarées (lignes 150-181)
2. Utiliser directement les fonctions helpers du haut du fichier
3. Créer une macro ou fonction utilitaire pour les appels répétés:
```rust
macro_rules! add_vars {
($svc:expr, $($var:expr),*) => { $({ add_var($svc, &$var)?; })* };
}
```
---
## Duplication 2: Pattern handlers boilerplate dans `handlers.rs`
**Fichiers affectés**: `src/handlers.rs`
**Problème**:
- `play_handler`, `stop_handler`, `pause_handler` (lignes 23-71): structure identique
- `next_handler`, `previous_handler` (lignes 74-95): clones
- Handlers GET (lignes 166-317): pattern `state.clone()` + `Box::pin(async move { ... set!() ... })` dupliqué
**Solution**:
1. Créer un helper générique:
```rust
fn make_state_handler<F>(state: SharedState, f: F) -> ActionHandler
where F: FnOnce(&mut ActionData, &RendererState) -> Result<ActionData, ActionError> + Send + 'static
```
2. Factoriser les closures `let state = state.clone()` dans chaque handler
---
## Duplication 3: Extraction metadata dupliquée
**Fichiers affectés**: `src/handlers.rs`
**Problème**:
- Lignes 118-123: `set_uri_handler` extraction metadata
- Lignes 146-151: `set_next_uri_handler` extraction metadata (identique)
**Solution**:
1. Extraire en fonction utilitaire:
```rust
fn extract_metadata(data: &ActionData, key: &str) -> String { ... }
```
---
## Duplication 4: Méthodes pipeline dans `registry.rs`
**Fichiers affectés**: `src/registry.rs`
**Problème**:
- `send_pipeline_command` (lignes 272-279) appelle `get_pipeline` (lignes 281-283)
- `load_uri` (lignes 286-290), `send_play_command` (lignes 293-296), `send_pause_command` (lignes 299-301) sont des wrappers quasi-identiques
**Solution**:
Consolider en méthodes génériques:
```rust
pub async fn send_command(&self, instance_id: &str, cmd: PipelineControl) {
if let Some(pipeline) = self.get_pipeline(instance_id) {
pipeline.send(cmd).await;
}
}
```
---
## Duplication 5: Feature flags avec code dupliqué
**Fichiers affectés**: `src/registry.rs`
**Problème**:
- Lignes 51-68 et 334-395: double impl de `create_instance` selon feature
**Solution**:
- Extraire la logique commune dans une fonction privée
- Utiliser `#[cfg]` seulement pour les différences (appel à pmoserver)
---
## Ordre de traitement suggéré
1. **Phase 1**: Helpers dans `renderer.rs` (les plus simples)
2. **Phase 2**: Handlers dans `handlers.rs` (plus complexe, nécessite macro)
3. **Phase 3**: Méthodes pipeline dans `registry.rs`
4. **Phase 4**: Feature flags
## Vérification
Après chaque phase, exécuter:
```bash
cargo check --package pmowebrenderer
```

View File

@@ -1,138 +1,443 @@
Parfait. Voici un **schéma fonctionnel minimal** pour un **MediaRenderer UPnP privé par navigateur** avec **token**. Lidée est de rester fidèle à ton backend Rust existant et à la webapp Vue.js. En s'appuyant sur l'architecture de PMOMusic, j'aimerais que tu proposes un plan détaillé pour implémenter un tel système de Média Renderer.
# Web Media Renderer - Architecture
- L'application web se trouve dans: [@webapp](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoapp/webapp)
- Tu as un prototype de Média Renderer dans: [@pmomediarenderer](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmomediarenderer)
- Le contrôle point est dans : [@pmocontrol](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol)
- Tu implémenteras ce nouveau système de Média Renderer dans la CRATe pmowebrenderer
## Vision
Tu mettras une version du plan en Markdown dans le répertoire [@Architecture](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Architecture) .
Système de Media Renderer pilotable à distance via UPnP, exposant un flux audio vers différents types de lecteurs physiques.
---
## Architecture globale en 4 parties
## 1. Flow général
```
Browser (Vue.js Control Point)
┌───────────────┐
UI / audio │
WebSocket │
└───────▲───────┘
│ token
Rust backend (UPnP MediaRenderer)
┌───────────────────────────┐
│ Token → Renderer mapping │
│ Device XML / SOAP endpoints│
│ Play/Pause/Stop → WS → Browser │
└───────────────────────────┘
```mermaid
flowchart LR
A[Media Server] -->|flux audio| B[Control Point]
B -->|commandes| C[Web Media Renderer]
C -->|flux + contrôles| D[Device physique]
subgraph Devices physiques
D1[Browser]
D2[Android Auto]
D3[Apple CarPlay]
D4[Sonos multipoint]
D5[Chromecast]
end
D --> D1
D --> D2
D --> D3
D --> D4
D --> D5
```
---
### Rôles
## 2. Étapes détaillées
1. **Media Server** - Source audio (le flux OGG-FLAC existant)
2. **Control Point** - Interface UI qui envoie les commandes (pause, play, seek, next, prev)
3. **Web Media Renderer** - Hub qui expose le flux et traduit les commandes selon le device
4. **Physical Device** - Lecteur final (browser, voiture, Sonos, Chromecast...)
### a) Création du renderer
## Web Media Renderer - Rôle central
1. Le navigateur se connecte via WebSocket ou HTTP.
2. Rust génère un token unique pour ce client :
```rust
use uuid::Uuid;
let token = Uuid::new_v4().to_string();
```
3. Rust crée une instance MediaRenderer **privée**, associée à ce token :
* Device description XML : `/renderer/<token>/desc.xml`
* AVTransport SOAP : `/renderer/<token>/avtransport`
* RenderingControl SOAP : `/renderer/<token>/renderingcontrol`
---
### b) Control Point
* La webapp Vue.js reçoit le token et la “déclare” au Control Point :
```js
const renderer = {
token: "abcd-1234-efgh",
name: "Browser Renderer"
};
// Ajout au control point local
controlPoint.addRenderer(renderer);
```
* Toutes les commandes Play/Pause/Stop incluent ce token :
```js
ws.send(JSON.stringify({
token: renderer.token,
action: "play",
uri: "http://localhost:8080/media.mp3"
}));
```
---
### c) Backend Rust : dispatcher les commandes
* Rust reçoit le JSON avec le token.
* Vérifie que le token correspond à un renderer actif.
* Transmet la commande au navigateur via WebSocket (ou HTTP push) :
```rust
match msg.action.as_str() {
"play" => send_ws_to_browser(&token, format!("play:{}", msg.uri)),
"pause" => send_ws_to_browser(&token, "pause".to_string()),
"stop" => send_ws_to_browser(&token, "stop".to_string()),
_ => (),
```mermaid
blockdiag
{
block = Commandes UPnP
block -> "Web Media Renderer" -> Adaptation selon device
"Web Media Renderer" -> Device-specific protocols
}
```
* Rust met à jour létat du renderer (AVTransport/RenderingControl) pour le Control Point.
### Rôle central: Adaptateur
---
Le Web Media Renderer est un **adaptateur** qui:
- **Reçoit le flux** du Media Server (OGG-FLAC)
- **Reçoit les commandes** du Control Point (UPnP)
- **Les traduit** vers les devices physiques
- **Expose une API de contrôle** commune
### d) Lecture côté navigateur
### Ce qui est COMMUN (factorisé)
* Le navigateur reçoit la commande via WebSocket et pilote `<audio>` :
| Layer | Description |
|-------|-------------|
| **API contrôle** | pause, resume, seek, next, prev, flush, stop |
| **Métadonnées** | /nowplaying, /metadata, /state |
| **Flux audio** | OGG-FLAC (identique pour tous) |
| **StreamType** | Continuous vs Finite |
```js
ws.onmessage = (evt) => {
const msg = evt.data;
if(msg.startsWith("play:")) {
audio.src = msg.split(":")[1];
audio.play();
} else if(msg === "pause") {
audio.pause();
} else if(msg === "stop") {
audio.pause();
audio.currentTime = 0;
}
};
### Ce qui est SPÉCIFIQUE (par device)
| Device | Transport | Buffer Management | Sync |
|--------|-----------|-------------------|------|
| Browser | HTTP/WebSocket | JS flush | N/A |
| Android Auto | AA API | native | varies |
| CarPlay | CP API | native | varies |
| Sonos | UPnP | none | UPnP |
| Chromecast | Cast API | none | Cast |
### Problème du buffer (Browser)
Le browser buffer cause des delais de reaction:
- **Pause**: delai de 5 secondes
- **Seek**: cherche dans le buffer, pas dans le nouveau flux
- **Next/Prev**: changement reporte
**Solutions**:
1. Web Audio API - `audioContext.suspend()/resume()` - plus petit buffer (~50ms)
2. Frontend flush buffer
3. Chaque client manage son propre buffer, Web Media Renderer juste expose API
## Implémentation actuelle
### Faits
- Flux audio OGG-FLAC ✓
- Pause/Resume ✓ (via `OggFlacStreamHandle`)
- TrackBoundary pour OGG segments
- StreamType (Continuous vs Finite)
### À faire
- seek/next/prev API
- WebSocket pour temps réel
- Metadata endpoint (/nowplaying JSON)
- MPV integration (multi-point/multi-room)
## Code actuel - Pause/Resume
```rust
// OggFlacStreamHandle - méthodes de contrôle
pub fn pause(&self) {
self.inner.is_paused.store(true, Ordering::SeqCst);
}
pub fn resume(&self) {
self.inner.is_paused.store(false, Ordering::SeqCst);
}
pub fn is_paused(&self) -> bool {
self.inner.is_paused.load(Ordering::SeqCst)
}
```
---
### Différences Continuous vs Finite
### e) Fermeture / cleanup
- **Continuous** (radio): pause -> sends silence, drops incoming chunks
- **Finite** (tracks): pause -> don't receive chunks (backpressure), loops sending silence
* Quand le navigateur se déconnecte :
## Schéma d'intégration
* Rust supprime le renderer associé au token
* Émet un **byebye virtuel** pour le Control Point (si nécessaire)
* Libère toutes les ressources
```mermaid
sequenceDiagram
participant CP as Control Point
participant WMR as Web Media Renderer
participant FS as Flux Server
participant D as Device
CP->>WMR: pause()
WMR->>FS: commande pause
FS->>FS: pause state change
FS->>WMR: silence (continuous) / blocked (finite)
WMR->>D: flux avec silence
D-->>CP: audio joué (avec delay si buffer)
```
---
## Le Web Media Renderer - Adaptateur
## 3. Points clés
Le rôle central du Web Media Renderer est de **convertir des ordres UPnP en actions spécifiques** selon le device cible:
1. **Token unique** = session privée + sécurité
2. **Pas besoin de SSDP / annonce** : le renderer est dédié à un navigateur connu
3. **Control Point Vue.js** sait exactement quel renderer utiliser
4. **Rust backend** reste seul responsable de limplémentation UPnP
5. **Lecture réelle** = navigateur via `<audio>` ou `<video>`
```
UPnP orders → [Web Media Renderer] → Device-specific actions
```
---
## Browser Player - Composant web invisible
💡 Ce modèle est très proche de ce que font **Mopidy avec Iris**, **Kodi Remote**, ou **Chromecast / local cast** : le device est connu et dédié, pas besoin de découverte réseau.
Pour s'entraîner, on peut se focaliser sur un composant web qui:
- Est **complètement invisible** (pas de UI)
- Est **télécommandable** par le Web Media Renderer
- Joue la musique dans le navigateur
### Specifications
| Requirement | Description |
|-------------|-------------|
| Invisible | Pas de UI, pas de controls, pas de visuel |
| Remote control | Reçoit commandes via WebSocket/HTTP |
| Auto-reconnect | Reconnection si stream coupé |
| Buffer management | Flush commandée |
| Audio format | OGG-FLAC stream |
### Architecture en 2 parties
| Partie | Langage | Rôle |
|--------|---------|------|
| Backend | Rust (pmoaudio-ext) | Contrôle, flux OGG-FLAC |
| Frontend | JavaScript | Player invisible dans le browser |
#### Backend (Rust)
- Expose le flux audio (`/stream`)
- API contrôle (`/pause`, `/resume`, `/seek`, `/flush`, `/stop`)
- WebSocket pour temps réel (`/ws`)
- **Reçoit les rapports de position/state**
```rust
// Endpoints existants
POST /pause
POST /resume
POST /seek?t={timestamp}
POST /flush
POST /stop
// Stream
GET /stream
// WebSocket messages REÇUS du player:
{
"type": "position",
"position_sec": 125.5,
"duration_sec": 240.0,
"state": "playing"
}
{
"type": "track",
"id": "...",
"title": "...",
"artist": "..."
}
{
"type": "ready_state",
"ready_state": "canplay"
}
```
#### Frontend (JavaScript)
Composant minimal (~100 lignes):
```javascript
class RemotePlayer {
constructor(wsUrl) {
this.ws = new WebSocket(wsUrl);
this.audio = new Audio();
this.ac = new AudioContext();
this.ws.onmessage = (e) => this.handle(e.data);
}
handle(msg) {
switch(msg.type) {
case 'stream': this.load(msg.url); break;
case 'play': this.play(); break;
case 'pause': this.pause(); break;
case 'seek': this.seek(msg.timestamp); break;
case 'flush': this.flush(); break;
case 'stop': this.stop(); break;
}
}
load(url) {
this.audio.src = url;
}
play() {
this.audio.play();
}
pause() {
this.audio.pause();
}
seek(ts) {
this.audio.currentTime = ts;
}
flush() {
// Flush buffer immediatement
this.audio.pause();
this.audio.currentTime = 0;
this.audio.src = '';
this.ac.suspend();
}
stop() {
this.flush();
}
}
```
**Usage:**
```html
<script src="pmo-player.js"></script>
<script>
const player = new PMOPlayer('ws://localhost:8080/ws');
</script>
```
### Fichier à créer
`pmoapp/webapp/src/services/PMOPlayer.ts`
### Endpoints HTTP
| Endpoint | Methode | Description |
|----------|---------|-------------|
| `/api/webrenderer/register` | POST | Enregistre instance |
| `/api/webrenderer/{id}/stream` | GET | Flux audio OGG-FLAC |
| `/api/webrenderer/{id}/position` | POST | Rapporte position |
| `/api/webrenderer/{id}/report` | POST | Rapporte etat player |
| `/api/webrenderer/{id}/command` | GET | Recupere commande pending |
| `/api/webrenderer/{id}` | DELETE | Desenregistre |
### Architecture
```
Player (Browser) <--HTTP--> Backend
- Report: position/state via POST /report
- Poll: command via GET /command (500ms)
- Stream: GET /stream
```
### Réactivité
Pour maximiser la réactivité:
| Technique | Impact |
|-----------|--------|
| WebSocket | Temps réel vs HTTP polling |
| AudioContext.suspend() | Buffer ~50ms au lieu de ~5s |
| Flush command | Vide le buffer immediatement |
| Native HTML5 audio | Le plus simple = le plus stable |
### Schéma
```mermaid
sequenceDiagram
participant WMR as Web Media Renderer
participant BP as Browser Player
WMR->>BP: stream(url)
BP->>BP: audio.src = url; play()
WMR->>BP: pause()
BP->>BP: audio.pause()
WMR->>BP: seek(timestamp)
BP->>BP: audio.currentTime = timestamp
WMR->>BP: flush()
BP->>BP: audioContext.suspend()
```
### Mapping orders → actions par device
| UPnP order | Browser | Android Auto | CarPlay | Sonos | Chromecast |
|-----------|---------|-------------|--------|------|-------------|
| Play | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
| Pause | `audio.pause()` | AA pause | CP pause | UPnP Pause | Cast pause |
| Resume | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
| Seek | `audio.currentTime=t` | AA seek | CP seek | UPnP Seek | Cast seek |
| Next | fetch new stream | AA next | CP next | UPnP Next | Cast next |
| Prev | fetch new stream | AA prev | CP prev | UPnP Prev | Cast prev |
| Flush | JS `audioContext.suspend()` | AA flush | CP flush | N/A | Cast load |
| Stop | `audio.stop()` | AA stop | CP stop | UPnP Stop | Cast stop |
### Protocole de contrôle
Le Web Media Renderer expose une API de contrôle uniforme qui est traduite selon le device:
### Commandes
| Commande | Description |
|---------|-------------|
| `play` | Lecture |
| `pause` | Pause (silence ou backpressure) |
| `resume` | Reprise |
| `seek(t)` | Seek vers timestamp t |
| `next` | Track suivante |
| `prev` | Track précédente |
| `flush` | **Flush buffer** - ordre critique pour reponse rapide |
| `stop` | Arrêt total |
### Métadonnées
| Endpoint | Description |
|----------|-------------|
| `/nowplaying` | Track actuelle, timestamp, is_paused |
| `/metadata` | TITLE, ARTIST, ALBUM, COVER |
| `/state` | État complet (position, duration, volume...) |
### Ordres spéciaux pour devices avec buffer
Pour les devices типа Android Auto, CarPlay, Browser:
- `flush` = vide le buffer immédiatement
- `stop` = arrête + flush
- Ces ordres doivent être traités en priorité
```mermaid
sequenceDiagram
participant CP as Control Point
participant WMR as Web Media Renderer
participant D as Device (Android Auto, Browser...)
CP->>WMR: flush()
Note over WMR: Priorité haute - immédiat
WMR->>D: FLUSH order
D-->>WMR: ack
WMR-->>CP: flushed
CP->>WMR: pause()
Note over WMR: Standard
WMR->>D: flux with silence
```
## multipoint/multi-room
```mermaid
flowchart TB
WMR[Web Media Renderer] -->|flux| R1[Renderer 1]
WMR -->|flux| R2[Renderer 2]
WMR -->|flux| R3[Renderer N]
R1 -->|sync| R2
R2 -->|sync| R3
```
Possibilités:
- UPnP pour renderers UPnP
- Cast API pour Chromecast
- Serveur temps réel pour sync
## Notes techniques
### AudioChunk::silence()
```rust
impl AudioChunk {
pub fn silence(frames: usize, sample_rate: u32) -> Self {
AudioChunk::I32(AudioChunkData::<i32>::silence(frames, sample_rate))
}
}
impl<T: Sample> AudioChunkData<T> {
pub fn silence(frames: usize, sample_rate: u32) -> Arc<Self> {
Self::new(vec![[T::ZERO; 2]; frames], sample_rate, 0.0)
}
}
```
### StreamType
```rust
pub enum StreamType {
Continuous, // radio - silence pendant pause
Finite, // tracks - backpressure pendant pause
}
```
### TrackBoundary avec StreamType
```rust
SyncMarker::TrackBoundary {
metadata: ...,
stream_type: StreamType
}
```

File diff suppressed because it is too large Load Diff

140
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.32"
version = "0.3.35"
dependencies = [
"axum 0.8.7",
"console-subscriber",
@@ -517,7 +517,6 @@ dependencies = [
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -575,7 +574,6 @@ dependencies = [
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -597,42 +595,25 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-embed"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "077959a7f8cf438676af90b483304528eb7e16eadadb7f44e9ada4f9dceb9e62"
dependencies = [
"axum-core 0.4.5",
"chrono",
"http",
"mime_guess",
"rust-embed",
"tower-service",
]
[[package]]
name = "axum-extra"
version = "0.9.6"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
checksum = "dbfe9f610fe4e99cf0cfcd03ccf8c63c28c616fe714d80475ef731f3b13dd21b"
dependencies = [
"axum 0.7.9",
"axum-core 0.4.5",
"axum 0.8.7",
"axum-core 0.5.5",
"bytes",
"fastrand",
"futures-core",
"futures-util",
"headers",
"http",
"http-body",
"http-body-util",
"mime",
"multer",
"pin-project-lite",
"serde",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -701,7 +682,7 @@ dependencies = [
"portable-atomic",
"portable-atomic-util",
"serde",
"spin 0.10.0",
"spin",
"wasm-bindgen",
"wasm-bindgen-futures",
]
@@ -2137,30 +2118,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "headers"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64 0.22.1",
"bytes",
"headers-core",
"http",
"httpdate",
"mime",
"sha1",
]
[[package]]
name = "headers-core"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http",
]
[[package]]
name = "heapless"
version = "0.8.0"
@@ -3113,23 +3070,6 @@ dependencies = [
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin 0.9.8",
"version_check",
]
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -3897,6 +3837,7 @@ dependencies = [
"tokio",
"tokio-util",
"tracing",
"ureq 2.12.1",
]
[[package]]
@@ -4016,7 +3957,7 @@ dependencies = [
"tracing",
"tracing-log 0.1.4",
"tracing-subscriber",
"ureq",
"ureq 3.1.4",
"url",
"urlencoding",
"utoipa",
@@ -4086,9 +4027,25 @@ dependencies = [
"bevy_reflect",
"htmlescape",
"once_cell",
"parking_lot",
"pmoaudio",
"pmoaudio-ext",
"pmoconfig",
"pmocontrol",
"pmodidl",
"pmoflac",
"pmometadata",
"pmoserver",
"pmoupnp",
"pmoutils",
"quick-xml",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tokio-util",
"tracing",
"uuid",
]
[[package]]
@@ -4279,10 +4236,10 @@ dependencies = [
"anyhow",
"async-stream 0.3.6",
"axum 0.8.7",
"axum-embed",
"axum-server",
"futures",
"futures-util",
"mime_guess",
"once_cell",
"pmoconfig",
"rust-embed",
@@ -4388,26 +4345,16 @@ dependencies = [
"bytes",
"futures",
"parking_lot",
"pmoaudio",
"pmoaudio-ext",
"pmoconfig",
"pmocontrol",
"pmodidl",
"pmoflac",
"pmomediarenderer",
"pmometadata",
"pmoserver",
"pmoupnp",
"pmoutils",
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tokio-util",
"tower-http",
"tracing",
"uuid",
]
[[package]]
@@ -5524,12 +5471,6 @@ dependencies = [
"libsoxr-sys",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "spin"
version = "0.10.0"
@@ -6513,6 +6454,22 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "ureq"
version = "3.1.4"
@@ -6527,7 +6484,7 @@ dependencies = [
"rustls-pki-types",
"ureq-proto",
"utf-8",
"webpki-roots",
"webpki-roots 1.0.4",
]
[[package]]
@@ -6820,6 +6777,15 @@ dependencies = [
"libwebp-sys",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.4",
]
[[package]]
name = "webpki-roots"
version = "1.0.4"

View File

@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
version = "0.3.33"
version = "0.3.35"
edition = "2024"
[dependencies]

View File

@@ -1,6 +1,5 @@
use pmoapp::{WebAppExt, Webapp};
use pmocontrol::ControlPointExt;
use pmomediarenderer::MEDIA_RENDERER;
use pmomediaserver::{
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
};
@@ -71,17 +70,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Enregistrer les devices UPnP (HTTP + SSDP automatique)
info!("📡 Registering UPnP devices...");
let renderer_instance = server
.write()
.await
.register_device(MEDIA_RENDERER.clone(), true)
.await
.expect("Failed to register MediaRenderer");
let base_url = renderer_instance.base_url();
let desc_route = renderer_instance.description_route();
info!("✅ MediaRenderer ready at {}{}", base_url, desc_route);
let server_instance = server
.write()
.await

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

@@ -1,7 +1,7 @@
[package]
name = "pmoapp"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
rust-embed = "8.5.0"

View File

@@ -11,6 +11,7 @@ import {
ArrowRightLeft,
} from "lucide-vue-next";
import { useRenderers } from "@/composables/useRenderers";
import { useWebRenderer } from "@/composables/useWebRenderer";
import { useRouter } from "vue-router";
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
import type { RendererSummary } from "@/services/pmocontrol/types";
@@ -27,8 +28,21 @@ const emit = defineEmits<{
}>();
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
const webRenderer = useWebRenderer();
const router = useRouter();
// Filtre pour n'afficher que notre WebRenderer (pas les autres onglets/navigateurs)
const myUdn = computed(() => webRenderer.rendererUdn.value);
const filteredRenderers = computed(() => {
const udn = myUdn.value;
return allRenderers.value.filter((r: RendererSummary) => {
if (r.model_name !== "WebRenderer") return true;
if (udn === null) return false;
return r.id === udn;
});
});
// Gestion du menu déroulant
const openMenuId = ref<string | null>(null);
@@ -110,16 +124,16 @@ watch(
);
const onlineRenderers = computed(() =>
allRenderers.value.filter((r: RendererSummary) => r.online),
filteredRenderers.value.filter((r: RendererSummary) => r.online),
);
const offlineRenderers = computed(() =>
allRenderers.value.filter((r: RendererSummary) => !r.online),
filteredRenderers.value.filter((r: RendererSummary) => !r.online),
);
// Noms de renderers qui apparaissent plus d'une fois (online + offline)
const duplicateNames = computed(() => {
const counts = new Map<string, number>();
for (const r of allRenderers.value) {
for (const r of filteredRenderers.value) {
counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1);
}
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));

View File

@@ -1,24 +1,13 @@
/**
* Composable pour gérer le WebRenderer navigateur.
*
* S'enregistre via POST /api/webrenderer/register au montage
* et diffuse l'audio via un élément <audio> pointant sur
* /api/webrenderer/{id}/stream (flux FLAC encodé côté serveur).
*
* Le navigateur est vu comme un renderer UPnP par le ControlPoint.
* Le gapless est géré côté serveur : le navigateur lit un flux continu.
*
* Cycle de vie du flux audio :
* - PLAYING/TRANSITIONING → src = stream_url + play() (nouvelle connexion HTTP)
* - PAUSED/STOPPED → pause() + src = "" (déconnexion HTTP)
* Utilise PMOPlayer pour le controle remote.
* S'enregistre via POST /api/webrenderer/register
* Utilise polling HTTP pour les commands
*/
import { ref, onMounted, onUnmounted, readonly } from "vue";
import { useSSE } from "./useSSE";
// ─── Identifiant stable de l'instance navigateur ─────────────────────────────
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
import { PMOPlayer } from "@/services/PMOPlayer";
function generateUUID(): string {
if (typeof crypto.randomUUID === "function") {
@@ -32,6 +21,18 @@ function generateUUID(): string {
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
// Module-level singleton for PMOPlayer to prevent duplicate instances
let globalPlayer: PMOPlayer | null = null;
let globalInstanceId: string | null = null;
let registering = false;
// Module-level reactive state shared across all composable invocations
const sharedConnected = ref(false);
const sharedStreamUrl = ref<string | null>(null);
const sharedRendererUdn = ref<string | null>(null);
function getOrCreateInstanceId(): string {
try {
let id = sessionStorage.getItem(INSTANCE_ID_KEY);
@@ -45,146 +46,36 @@ function getOrCreateInstanceId(): string {
}
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface RegisterRequest {
instance_id: string;
user_agent: string;
}
interface RegisterResponse {
stream_url: string;
udn: string;
}
// ─── Composable ───────────────────────────────────────────────────────────────
export function useWebRenderer() {
const connected = ref(false);
const streamUrl = ref<string | null>(null);
/** UDN du device UPnP créé côté serveur pour ce navigateur */
const rendererUdn = ref<string | null>(null);
const connected = sharedConnected;
const streamUrl = sharedStreamUrl;
const rendererUdn = sharedRendererUdn;
let audioEl: HTMLAudioElement | null = null;
let instanceId: string | null = null;
let currentStreamUrl: string | null = null;
let player: PMOPlayer | null = null;
let onConnectedCallback: (() => void) | null = null;
let sseUnsubscribe: (() => void) | null = null;
let pendingCanPlay: (() => void) | null = null;
let positionInterval: ReturnType<typeof setInterval> | null = null;
// ── Reporting de position ─────────────────────────────────────────────────
function startPositionReporting(): void {
stopPositionReporting();
positionInterval = setInterval(() => {
if (!audioEl || !instanceId) return;
const pos = audioEl.currentTime;
const dur = isFinite(audioEl.duration) ? audioEl.duration : null;
void fetch(`/api/webrenderer/${instanceId}/position`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ position_sec: pos, duration_sec: dur }),
});
}, 1000);
}
function stopPositionReporting(): void {
if (positionInterval !== null) {
clearInterval(positionInterval);
positionInterval = null;
}
}
// ── Connexion / déconnexion du flux audio ─────────────────────────────────
function startStream(): void {
console.log("[WebRenderer] startStream called, audioEl=", !!audioEl, "currentStreamUrl=", currentStreamUrl);
if (!audioEl || !currentStreamUrl) return;
const el = audioEl;
// Si le stream est en erreur (networkState=3), réinitialiser avant de réessayer
if (el.networkState === 3 /* NETWORK_NO_SOURCE */ && !pendingCanPlay) {
console.log("[WebRenderer] startStream: networkState=3, resetting before retry");
el.removeAttribute("src");
el.load();
}
// Si le stream est déjà chargé/en cours, ne pas réouvrir la connexion HTTP.
// (évite que PLAYING après TRANSITIONING ne crée un nouveau pipe)
if (el.hasAttribute("src") && (el.readyState > 0 || pendingCanPlay)) {
console.log("[WebRenderer] startStream: stream already open, ignoring (readyState=", el.readyState, ")");
return;
}
// Sur Safari, play() échoue avec NotSupportedError si appelé avant que
// l'élément audio ait reçu assez de données (readyState < HAVE_FUTURE_DATA).
// On attend canplay avant d'appeler play().
if (pendingCanPlay) {
el.removeEventListener("canplay", pendingCanPlay);
}
el.src = currentStreamUrl;
console.log("[WebRenderer] src set, readyState=", el.readyState, "networkState=", el.networkState);
el.addEventListener("error", () => {
console.error("[WebRenderer] event:error code=", el.error?.code, el.error?.message,
"readyState=", el.readyState, "networkState=", el.networkState);
// Nettoyer pendingCanPlay pour permettre un retry au prochain PLAYING/TRANSITIONING
if (pendingCanPlay) {
el.removeEventListener("canplay", pendingCanPlay);
pendingCanPlay = null;
}
}, { once: true });
el.addEventListener("loadstart", () => console.debug("[WebRenderer] event:loadstart readyState=", el.readyState), { once: true });
el.addEventListener("loadedmetadata", () => console.debug("[WebRenderer] event:loadedmetadata readyState=", el.readyState), { once: true });
el.addEventListener("loadeddata", () => console.debug("[WebRenderer] event:loadeddata readyState=", el.readyState), { once: true });
el.addEventListener("progress", () => console.debug("[WebRenderer] event:progress readyState=", el.readyState), { once: true });
el.addEventListener("stalled", () => console.warn("[WebRenderer] event:stalled readyState=", el.readyState, "networkState=", el.networkState));
el.addEventListener("waiting", () => console.warn("[WebRenderer] event:waiting readyState=", el.readyState));
el.addEventListener("suspend", () => console.debug("[WebRenderer] event:suspend readyState=", el.readyState, "networkState=", el.networkState), { once: true });
el.addEventListener("abort", () => console.warn("[WebRenderer] event:abort"), { once: true });
el.addEventListener("emptied", () => console.warn("[WebRenderer] event:emptied"), { once: true });
const onCanPlay = () => {
console.log("[WebRenderer] event:canplay readyState=", el.readyState, "calling play()");
pendingCanPlay = null;
el.removeEventListener("canplay", onCanPlay);
el.play().then(() => {
console.log("[WebRenderer] play() resolved OK");
startPositionReporting();
}).catch((e: unknown) => {
console.warn("[WebRenderer] play() rejected:", e);
});
};
pendingCanPlay = onCanPlay;
el.addEventListener("canplay", onCanPlay);
}
function stopStream(): void {
console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState);
stopPositionReporting();
if (!audioEl) return;
if (pendingCanPlay) {
audioEl.removeEventListener("canplay", pendingCanPlay);
pendingCanPlay = null;
}
audioEl.pause();
audioEl.removeAttribute("src"); // ferme la connexion HTTP (src="" résolu comme URL de page sur Safari)
audioEl.load(); // force le reset de l'état réseau
}
// ── Enregistrement ────────────────────────────────────────────────────────
async function register(): Promise<void> {
instanceId = getOrCreateInstanceId();
// Prevent concurrent registrations (race condition → double player)
if (globalPlayer || registering) {
if (globalPlayer) {
player = globalPlayer;
connected.value = true;
}
return;
}
registering = true;
const body: RegisterRequest = {
instance_id: instanceId,
user_agent: navigator.userAgent,
};
const instanceId = getOrCreateInstanceId();
console.log('[WebRenderer] registering with instanceId:', instanceId);
try {
const resp = await fetch("/api/webrenderer/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify({
instance_id: instanceId,
user_agent: navigator.userAgent,
}),
});
if (!resp.ok) {
@@ -192,98 +83,82 @@ export function useWebRenderer() {
return;
}
const data = (await resp.json()) as RegisterResponse;
const data = await resp.json();
streamUrl.value = data.stream_url;
currentStreamUrl = data.stream_url;
rendererUdn.value = data.udn;
player = new PMOPlayer(instanceId);
globalPlayer = player;
globalInstanceId = instanceId;
player.setDebug(true);
player.on('play', () => console.log('[WebRenderer] playing'));
player.on('pause', () => console.log('[WebRenderer] paused'));
// Si le backend est déjà en lecture (reconnexion après reload), démarrer immédiatement
if (data.should_play && data.stream_url) {
console.log('[WebRenderer] backend already playing, starting stream');
player.playStream(data.stream_url);
}
connected.value = true;
onConnectedCallback?.();
// S'abonner aux événements SSE du renderer pour piloter la lecture
const { connect, onRendererEvent } = useSSE();
connect();
const udn = data.udn;
sseUnsubscribe?.();
sseUnsubscribe = onRendererEvent((event) => {
if (event.renderer_id !== udn) return;
if (event.type !== "state_changed") return;
const state = event.state;
console.log("[WebRenderer] SSE state_changed →", state, "| event.renderer_id=", event.renderer_id, "udn=", udn, "| audioEl.src=", audioEl?.src, "readyState=", audioEl?.readyState, "networkState=", audioEl?.networkState, "pendingCanPlay=", !!pendingCanPlay);
if (state === "PLAYING" || state === "TRANSITIONING") {
startStream();
} else if (state === "PAUSED" || state === "STOPPED") {
stopStream();
}
});
} catch (e) {
console.error("[WebRenderer] register error:", e);
} finally {
registering = false;
}
}
// ── Désenregistrement ─────────────────────────────────────────────────────
async function unregister(): Promise<void> {
if (!instanceId) return;
// Only unregister if this is the global player - prevent duplicate unregister calls
if (player !== globalPlayer || !player) {
return;
}
const instanceId = globalInstanceId || getOrCreateInstanceId();
// Clear global first to prevent other components from using it
globalPlayer = null;
globalInstanceId = null;
try {
await fetch(`/api/webrenderer/${instanceId}`, { method: "DELETE" });
} catch {
// Ignoré lors du déchargement de page
// Ignored
}
instanceId = null;
player?.destroy();
player = null;
}
// ── Volume / Mute ─────────────────────────────────────────────────────────
function setVolume(v: number): void {
if (audioEl) audioEl.volume = v;
function setVolume(_v: number): void {
// PMOPlayer doesn't control volume directly - handled by backend
}
function setMute(m: boolean): void {
if (audioEl) audioEl.muted = m;
function setMute(_m: boolean): void {
// PMOPlayer doesn't control mute directly - handled by backend
}
// ── Cycle de vie ──────────────────────────────────────────────────────────
const beforeUnloadHandler = () => void unregister();
onMounted(() => {
audioEl = document.createElement("audio");
audioEl.preload = "auto";
document.body.appendChild(audioEl);
void register();
window.addEventListener("beforeunload", () => void unregister());
window.addEventListener("beforeunload", beforeUnloadHandler);
});
onUnmounted(() => {
sseUnsubscribe?.();
sseUnsubscribe = null;
stopPositionReporting();
stopStream();
void unregister();
if (audioEl) {
audioEl.remove();
audioEl = null;
}
connected.value = false;
streamUrl.value = null;
rendererUdn.value = null;
window.removeEventListener("beforeunload", () => void unregister());
window.removeEventListener("beforeunload", beforeUnloadHandler);
});
// ── API publique ──────────────────────────────────────────────────────────
return {
/** true quand l'instance est enregistrée sur le serveur */
connected: readonly(connected),
/** URL du flux FLAC servi par le serveur */
streamUrl: readonly(streamUrl),
/** UDN du device UPnP créé pour ce navigateur (null avant enregistrement) */
rendererUdn: readonly(rendererUdn),
/** Callback appelé quand l'enregistrement est confirmé */
onConnected(fn: () => void) {
onConnectedCallback = fn;
},
setVolume,
setMute,
};
}
}

View File

@@ -0,0 +1,423 @@
/**
* PMOPlayer - Invisible remote-controlled audio player for browser
*
* Receives commands from Web Media Renderer (backend)
* Reports position/state back to backend via HTTP
*
* Usage:
* const player = new PMOPlayer('my-instance-id');
* player.on('play', () => console.log('playing'));
*/
export type PlayerState = 'playing' | 'paused' | 'stopped' | 'buffering' | 'error';
export type ReadyState = 'have_nothing' | 'have_metadata' | 'have_current_data' | 'have_future_data' | 'can_play' | 'can_play_through';
export interface PlayerEvents {
play: () => void;
pause: () => void;
stop: () => void;
flush: () => void;
positionchange: (position_sec: number) => void;
durationchange: (duration_sec: number) => void;
statechange: (state: PlayerState) => void;
trackchange: (track: TrackInfo) => void;
readychange: (ready: ReadyState) => void;
error: (error: string) => void;
}
export interface TrackInfo {
id: string;
title: string;
artist: string;
album?: string;
cover?: string;
}
export class PMOPlayer {
private audio: HTMLAudioElement;
private instanceId: string;
private ac: AudioContext | null = null;
private state: PlayerState = 'stopped';
private positionInterval: number | null = null;
private commandInterval: number | null = null;
private listeners: Partial<PlayerEvents> = {};
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;
console.log('[PMOPlayer] constructor called for:', instanceId);
this.audio = new Audio();
this.audio.preload = 'auto';
this.audio.style.display = 'none';
this.audio.style.visibility = 'hidden';
this.audio.style.position = 'absolute';
this.audio.style.width = '0';
this.audio.style.height = '0';
this.audio.style.overflow = 'hidden';
document.body.appendChild(this.audio);
this.setupAudioListeners();
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);
}
}
private async fetchCommand() {
try {
const resp = await fetch(`/api/webrenderer/${this.instanceId}/command`);
if (resp.status === 204) {
// No command pending
return;
}
if (resp.ok) {
const text = await resp.text();
if (text) {
const cmd = JSON.parse(text);
if (cmd && cmd.type) {
this.log('fetched command', cmd);
this.handleCommand(cmd);
}
}
}
} catch (err) {
this.log('fetch command error', err);
}
}
private startCommandPolling() {
this.commandInterval = window.setInterval(() => {
// ATTENTION : ne pas conditionner ce poll à l'état courant.
// L'état initial est 'stopped', et c'est dans cet état que le backend
// envoie la première commande 'stream' pour démarrer la lecture.
// Filtrer sur state !== 'stopped' casse le chemin nominal de démarrage.
this.fetchCommand();
}, 500);
}
private setupAudioListeners() {
this.audio.addEventListener('play', () => {
this.log('play event');
this.setState('playing');
this.startPositionReporting();
});
this.audio.addEventListener('pause', () => {
this.log('pause event');
this.setState('paused');
});
this.audio.addEventListener('ended', () => {
this.log('ended event');
this.setState('stopped');
this.stopPositionReporting();
});
this.audio.addEventListener('error', () => {
if (!this.audio.getAttribute('src')) return;
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', () => {
this.log('waiting event');
this.setState('buffering');
});
this.audio.addEventListener('canplay', () => {
this.log('canplay event');
this.reportReadyState('can_play');
});
this.audio.addEventListener('durationchange', () => {
const dur = this.audio.duration;
if (isFinite(dur)) {
this.log('durationchange', dur);
this.reportDuration(dur);
}
});
this.audio.addEventListener('loadedmetadata', () => {
this.log('loadedmetadata', this.audio.duration);
this.reportReadyState('have_metadata');
});
this.audio.addEventListener('loadeddata', () => {
this.log('loadeddata');
this.reportReadyState('have_current_data');
});
}
private handleCommand(msg: Record<string, unknown>) {
const type = msg.type as string;
switch (type) {
case 'stream': {
this.playStream(msg.url as string);
break;
}
case 'play': {
const streamUrl = `/api/webrenderer/${this.instanceId}/stream`;
this.playStream(streamUrl);
break;
}
case 'pause':
this.pause();
break;
case 'seek':
this.seek(msg.timestamp as number);
break;
case 'flush':
this.flush();
break;
case 'stop':
this.stop();
break;
}
}
// ─── Commands from backend ─────────────────────────────────────────────
stream(url: string) {
this.log('stream:', url);
this.audio.setAttribute('data-stream-url', url);
this.reconnectAttempts = 0;
this.audio.src = url;
this.audio.load();
}
playStream(url: string) {
this.stream(url);
this.play();
}
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');
this.pendingPlay = true;
this.setupAutoplayUnlock();
} else {
this.log('play error', err);
}
});
}
private setupAutoplayUnlock() {
if (this.unlockListener) return;
this.unlockListener = () => {
if (this.pendingPlay) {
this.pendingPlay = false;
this.log('retrying play after user interaction');
this.audio.play().catch(err => this.log('play retry error', err));
}
document.removeEventListener('click', this.unlockListener!);
this.unlockListener = null;
};
document.addEventListener('click', this.unlockListener);
}
pause() {
this.log('pause()');
this.audio.pause();
}
seek(timestamp: number) {
this.log('seek:', timestamp);
this.audio.currentTime = timestamp;
}
flush() {
this.log('flush()');
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.ac?.suspend();
this.listeners.flush?.();
}
stop() {
this.log('stop()');
this.flush();
this.setState('stopped');
}
// ─── Reports to backend ──────────────────────────────────────────
private setState(state: PlayerState) {
if (this.state !== state) {
this.state = state;
this.log('state:', state);
this.listeners.statechange?.(state);
this.httpReport('report', {
state: state,
});
}
}
private reportPosition() {
const pos = this.audio.currentTime;
const dur = isFinite(this.audio.duration) ? this.audio.duration : null;
this.listeners.positionchange?.(pos);
this.httpReport('report', {
position_sec: pos,
duration_sec: dur,
state: this.state,
});
}
private reportDuration(dur: number) {
this.listeners.durationchange?.(dur);
}
private reportReadyState(ready: ReadyState) {
this.log('ready_state:', ready);
this.listeners.readychange?.(ready);
this.httpReport('report', {
ready_state: ready,
});
}
private async httpReport(endpoint: string, data: Record<string, unknown>) {
try {
await fetch(`/api/webrenderer/${this.instanceId}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
} catch (err) {
this.log('http report error', err);
}
}
private startPositionReporting() {
this.stopPositionReporting();
this.positionInterval = window.setInterval(() => {
if (this.state === 'playing') {
this.reportPosition();
}
}, 1000);
}
private stopPositionReporting() {
if (this.positionInterval !== null) {
clearInterval(this.positionInterval);
this.positionInterval = null;
}
}
// ─── Public API ───────────────────────────────────────────────────────────
on<K extends keyof PlayerEvents>(event: K, fn: PlayerEvents[K]) {
this.listeners[event] = fn;
}
off<K extends keyof PlayerEvents>(event: K) {
delete this.listeners[event];
}
getState(): PlayerState {
return this.state;
}
getPosition(): number {
return this.audio.currentTime;
}
getDuration(): number | null {
const dur = this.audio.duration;
return isFinite(dur) ? dur : null;
}
// Track info from backend - not implemented yet
getTrack(): null {
return null;
}
setDebug(enabled: boolean) {
this.debug = enabled;
}
destroy() {
this.log('destroy()');
this.stopPositionReporting();
if (this.commandInterval !== null) {
clearInterval(this.commandInterval);
this.commandInterval = null;
}
if (this.unlockListener) {
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`,
JSON.stringify({ state: 'stopped' })
);
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.audio.remove();
}
}

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoaudio-ext"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
# Core audio types
@@ -32,10 +32,11 @@ serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
reqwest = { workspace = true, features = ["stream"], optional = true }
futures = { version = "0.3", optional = true }
ureq = { version = "2", optional = true }
[features]
default = []
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata", "dep:serde_json"]
playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"]
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde", "dep:reqwest", "dep:futures"]
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde", "dep:reqwest", "dep:futures", "dep:ureq"]
all = ["cache-sink", "playlist", "http-stream"]

View File

@@ -233,7 +233,7 @@ impl NodeLogic for FlacCacheSinkLogic {
}
}
_AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { metadata } => {
SyncMarker::TrackBoundary { metadata, .. } => {
// TrackBoundary pendant le prebuffer - track courte (< 512KB)
tracing::warn!(
"FlacCacheSink: TrackBoundary received before prebuffer complete - track shorter than 512KB, closing pump and waiting for ingestion"
@@ -589,7 +589,7 @@ impl NodeLogic for FlacCacheSinkLogic {
// Si pump_closed, ignorer silencieusement le chunk
}
_AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { metadata } => {
SyncMarker::TrackBoundary { metadata, .. } => {
// Nouveau morceau - fermer le pump si pas déjà fermé
tracing::debug!("FlacCacheSink: TrackBoundary received, closing pump and storing metadata for next track");
// Stocker les métadonnées pour la prochaine track

View File

@@ -307,7 +307,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
_AudioSegment::Sync(marker) => {
match marker.as_ref() {
SyncMarker::TrackBoundary { metadata } => {
SyncMarker::TrackBoundary { metadata, .. } => {
// Prepare encoder options (metadata + duration) for the upcoming track.
if let Err(e) =
self.ctx.prepare_encoder_options_for_track(metadata).await
@@ -533,6 +533,9 @@ impl StreamingFlacSink {
current_timestamp: Arc::new(RwLock::new(0.0)),
pending_track_duration: None,
pending_total_samples: None,
is_paused: Arc::new(AtomicBool::new(false)),
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
last_track_metadata: Arc::new(RwLock::new(None)),
},
};

View File

@@ -88,8 +88,17 @@ impl IcyClientStream {
// Add cover URL if we have a cover_pk
if let Some(pk) = &meta.cover_pk {
#[cfg(feature = "playlist")]
let cover_url = pmocache::covers_absolute_url_for_upnp(pk, None);
metadata_str.push_str(&format!("StreamUrl='{}';", cover_url));
#[cfg(feature = "playlist")]
{
metadata_str.push_str(&format!("StreamUrl='{}';", cover_url));
}
#[cfg(not(feature = "playlist"))]
{
// When playlist feature is not enabled, use relative URL
metadata_str.push_str(&format!("StreamUrl='/covers/image/{}/256';", pk));
}
} else if let Some(url) = &meta.cover_url {
// Fallback to external cover URL if no local pk
metadata_str.push_str(&format!("StreamUrl='{}';", url));

View File

@@ -61,7 +61,7 @@ use async_trait::async_trait;
use bytes::Bytes;
use pmoaudio::{
pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason},
AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
AudioChunk, AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
};
use pmoflac::{EncoderOptions, FlacEncodedStream};
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
@@ -109,6 +109,23 @@ impl OggFlacStreamHandle {
pub fn set_auto_stop(&self, enabled: bool) {
self.inner.auto_stop.store(enabled, Ordering::SeqCst);
}
/// Pause playback - sends silence (zeros) to maintain client connections
pub fn pause(&self) {
self.inner.is_paused.store(true, Ordering::SeqCst);
debug!("StreamingOggFlacSink paused");
}
/// Resume playback - continues sending actual audio
pub fn resume(&self) {
self.inner.is_paused.store(false, Ordering::SeqCst);
debug!("StreamingOggFlacSink resumed");
}
/// Check if currently paused
pub fn is_paused(&self) -> bool {
self.inner.is_paused.load(Ordering::SeqCst)
}
}
/// OGG-FLAC client stream (implements AsyncRead).
@@ -163,16 +180,98 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
debug!("StreamingOggFlacSink started");
// TODO: Implement OGG-FLAC encoding logic
// For now, just process segments without encoding
let mut was_paused = false;
let mut silence_timestamp: f64 = 0.0;
loop {
// Check pause state BEFORE select to avoid receiving chunks when Finite + paused
{
let is_paused = self.ctx.is_paused.load(Ordering::SeqCst);
let stream_type = *self.ctx.stream_type.read().await;
// For Finite + paused: loop WITHOUT receiving chunks
if is_paused && stream_type == pmoaudio::StreamType::Finite {
if self.ctx.sample_rate.is_none() {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
continue;
}
let sample_rate = self.ctx.sample_rate.unwrap();
let frames_per_50ms = (sample_rate as f64 * 0.05) as usize;
// Create proper silence chunk using AudioChunk::silence
let silence_chunk = AudioChunk::silence(frames_per_50ms, sample_rate);
let pcm_bytes = chunk_to_pcm_bytes(&silence_chunk, self.ctx.bits_per_sample)?;
trace!("Sending silence during pause (finite) - no recv");
let pcm_chunk = PcmChunk {
bytes: pcm_bytes,
timestamp_sec: silence_timestamp,
duration_sec: 0.05,
};
if let Some(tx) = &self.ctx.pcm_tx {
let _ = tx.send(pcm_chunk).await;
}
silence_timestamp += 0.05;
continue; // Loop again WITHOUT going to select
}
}
tokio::select! {
_ = stop_token.cancelled() => {
debug!("StreamingOggFlacSink stopped by cancellation");
break;
}
// Check for pause state changes periodically
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {
let is_paused = self.ctx.is_paused.load(Ordering::SeqCst);
if is_paused != was_paused {
if is_paused {
// Entering pause - restart encoder to close OGG segment
debug!("StreamingOggFlacSink: entering pause - restarting encoder");
let metadata = self.ctx.last_track_metadata.read().await.clone();
if let Some(ref meta) = metadata {
if let Err(e) = self.ctx.prepare_encoder_options_for_track(meta).await {
error!("Failed to prepare encoder options for pause: {}", e);
}
}
if self.ctx.encoder_state.is_some() {
if let Err(e) = self.ctx.restart_encoder_for_new_track(
|flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, _sample_rate, timestamp_offset_sec| {
broadcast_ogg_flac_stream(flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, timestamp_offset_sec)
},
).await {
warn!("Failed to restart encoder on pause: {}", e);
}
}
} else {
// Exiting pause - restart encoder to open new OGG segment
debug!("StreamingOggFlacSink: exiting pause - restarting encoder");
let metadata = self.ctx.last_track_metadata.read().await.clone();
if let Some(ref meta) = metadata {
if let Err(e) = self.ctx.prepare_encoder_options_for_track(meta).await {
error!("Failed to prepare encoder options for resume: {}", e);
}
}
if self.ctx.encoder_state.is_some() {
if let Err(e) = self.ctx.restart_encoder_for_new_track(
|flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, _sample_rate, timestamp_offset_sec| {
broadcast_ogg_flac_stream(flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, timestamp_offset_sec)
},
).await {
warn!("Failed to restart encoder on resume: {}", e);
}
}
}
was_paused = is_paused;
}
continue; // Skip processing - this is just a check
}
segment = input.recv() => {
match segment {
Some(seg) => {
@@ -241,7 +340,33 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
duration_sec
);
// Send to FLAC encoder with timestamp and duration
// Check pause state
let was_paused = self.ctx.is_paused.load(Ordering::SeqCst);
// Get stream type
let stream_type = *self.ctx.stream_type.read().await;
// Handle pause:
// - Always send silence to hear something
// - Continuous: receive chunks but drop them
// - Finite: don't receive chunks (backpressure)
if was_paused {
trace!("StreamingOggFlacSink: sending silence during pause ({})",
if stream_type == pmoaudio::StreamType::Continuous { "continuous" } else { "finite" });
let silence_bytes = vec![0u8; pcm_bytes.len()];
let silence_chunk = PcmChunk {
bytes: silence_bytes,
timestamp_sec: seg.timestamp_sec,
duration_sec,
};
if let Some(tx) = &self.ctx.pcm_tx {
let _ = tx.send(silence_chunk).await;
}
// Always continue (skip sending real audio)
continue;
}
// Normal case: send PCM to encoder
let pcm_chunk = PcmChunk {
bytes: pcm_bytes,
timestamp_sec: seg.timestamp_sec,
@@ -265,7 +390,13 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
_AudioSegment::Sync(marker) => {
match marker.as_ref() {
SyncMarker::TrackBoundary { metadata } => {
SyncMarker::TrackBoundary { metadata, stream_type } => {
// Store the last TrackBoundary metadata for pause/resume
*self.ctx.last_track_metadata.write().await = Some(metadata.clone());
// Update stream type
*self.ctx.stream_type.write().await = *stream_type;
// Inject per-track metadata and duration into the next FLAC header.
if let Err(e) =
self.ctx.prepare_encoder_options_for_track(metadata).await
@@ -442,30 +573,33 @@ impl StreamingOggFlacSink {
let logic = StreamingOggFlacSinkLogic {
ctx: SharedSinkContext {
encoder_options,
bits_per_sample,
enable_total_samples: options.enable_total_samples,
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
default_title: options.default_title.clone(),
default_artist: options.default_artist.clone(),
use_only_default_metadata: options.use_only_default_metadata,
pcm_tx: Some(pcm_tx),
pcm_rx: Some(pcm_rx),
metadata,
broadcast,
header,
encoder_state: None,
sample_rate: None,
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
first_chunk_timestamp_checked: false,
timestamp_offset_sec: 0.0,
current_timestamp: Arc::new(RwLock::new(0.0)),
pending_track_duration: None,
pending_total_samples: None,
},
encoder_options,
bits_per_sample,
enable_total_samples: options.enable_total_samples,
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
default_title: options.default_title.clone(),
default_artist: options.default_artist.clone(),
use_only_default_metadata: options.use_only_default_metadata,
pcm_tx: Some(pcm_tx),
pcm_rx: Some(pcm_rx),
metadata,
broadcast,
header,
encoder_state: None,
sample_rate: None,
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
first_chunk_timestamp_checked: false,
timestamp_offset_sec: 0.0,
current_timestamp: Arc::new(RwLock::new(0.0)),
pending_track_duration: None,
pending_total_samples: None,
is_paused: Arc::new(AtomicBool::new(false)),
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
last_track_metadata: Arc::new(RwLock::new(None)),
},
};
let sink = Self {
let sink = StreamingOggFlacSink {
inner: Node::new_with_input(logic, 16),
};

View File

@@ -118,6 +118,10 @@ pub struct SharedStreamHandleInner {
pub stop_token: CancellationToken,
pub header: Arc<RwLock<Option<Bytes>>>,
pub auto_stop: Arc<AtomicBool>,
/// Pause state - true if paused, false if playing
pub is_paused: Arc<AtomicBool>,
/// Stream type (Continuous for radio, Finite for tracks)
pub stream_type: Arc<RwLock<pmoaudio::StreamType>>,
}
impl SharedStreamHandleInner {
@@ -135,6 +139,8 @@ impl SharedStreamHandleInner {
stop_token,
header,
auto_stop,
is_paused: Arc::new(AtomicBool::new(false)),
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
}
}
@@ -294,6 +300,12 @@ pub struct SharedSinkContext {
pub current_timestamp: Arc<RwLock<f64>>,
pub pending_track_duration: Option<Duration>,
pub pending_total_samples: Option<u64>,
/// Pause state - true if paused, false if playing
pub is_paused: Arc<AtomicBool>,
/// Stream type (Continuous for radio, Finite for tracks)
pub stream_type: Arc<RwLock<pmoaudio::StreamType>>,
/// Last TrackBoundary metadata received (for pause/resume)
pub last_track_metadata: Arc<RwLock<Option<Arc<RwLock<dyn TrackMetadata>>>>>,
}
impl SharedSinkContext {

View File

@@ -35,6 +35,7 @@ use pmoaudio::{
AudioSegment,
nodes::AudioError,
pipeline::{AudioPipelineNode, Node, NodeLogic, send_to_children},
StreamType,
};
use tokio::sync::{broadcast, mpsc};
use tokio_util::sync::CancellationToken;
@@ -164,6 +165,7 @@ impl NodeLogic for PlayerSourceLogic {
let mut current_uri: Option<String> = None;
let mut next_uri: Option<String> = None;
let mut paused_at_sec: f64 = 0.0;
let mut is_continuous: bool = false;
info!("PlayerSource: started");
@@ -181,7 +183,7 @@ impl NodeLogic for PlayerSourceLogic {
None => break,
Some(cmd) => {
self.handle_command(
cmd, &mut state, &mut current_uri,
cmd, is_continuous, &mut state, &mut current_uri,
&mut next_uri, &mut paused_at_sec,
&output, &stop_token,
).await?;
@@ -212,15 +214,17 @@ impl NodeLogic for PlayerSourceLogic {
};
let duration_sec = source.duration_sec();
let is_continuous = source.is_continuous();
let _ = self.event_tx.send(PlayerEvent::Playing {
uri: uri.clone(),
duration_sec,
});
info!("PlayerSource: playing {:?} from {:.1}s", uri, paused_at_sec);
info!("PlayerSource: playing {:?} from {:.1}s continuous={}", uri, paused_at_sec, is_continuous);
// Pompe audio — s'arrête sur EOF, Pause, Stop, ou commande
let result = self.pump(
source,
is_continuous,
&mut state,
&mut current_uri,
&mut next_uri,
@@ -255,6 +259,7 @@ impl PlayerSourceLogic {
async fn handle_command(
&mut self,
cmd: PlayerCommand,
is_continuous: bool,
state: &mut TransportState,
current_uri: &mut Option<String>,
next_uri: &mut Option<String>,
@@ -281,14 +286,16 @@ impl PlayerSourceLogic {
TransportState::Paused => {
info!("PlayerSource: Play (resume from {:.1}s)", paused_at_sec);
// Injecter un TrackBoundary pour EOS + nouveau BOS OGG propre
send_track_boundary(current_uri.as_deref(), output, *paused_at_sec, stop_token).await?;
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
send_track_boundary(current_uri.as_deref(), output, *paused_at_sec, stream_type, stop_token).await?;
*state = TransportState::Playing;
}
TransportState::Loaded => {
info!("PlayerSource: Play (start)");
*paused_at_sec = 0.0;
// TrackBoundary initial pour le premier BOS OGG
send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await?;
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
send_track_boundary(current_uri.as_deref(), output, 0.0, stream_type, stop_token).await?;
*state = TransportState::Playing;
}
TransportState::Idle => {
@@ -320,7 +327,8 @@ impl PlayerSourceLogic {
if current_uri.is_some() {
info!("PlayerSource: Seek to {:.1}s", pos);
*paused_at_sec = pos;
send_track_boundary(current_uri.as_deref(), output, pos, stop_token).await?;
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
send_track_boundary(current_uri.as_deref(), output, pos, stream_type, stop_token).await?;
*state = TransportState::Playing;
}
}
@@ -335,6 +343,7 @@ impl PlayerSourceLogic {
async fn pump(
&mut self,
source: UriSource,
is_continuous: bool,
state: &mut TransportState,
current_uri: &mut Option<String>,
next_uri: &mut Option<String>,
@@ -394,7 +403,8 @@ impl PlayerSourceLogic {
*next_uri = None;
*paused_at_sec = 0.0;
// TrackBoundary pour clore le bitstream OGG proprement
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await {
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stream_type, stop_token).await {
result = Err(e);
}
*state = TransportState::Playing;
@@ -408,7 +418,8 @@ impl PlayerSourceLogic {
info!("PlayerSource: Seek to {:.1}s", pos);
source_stop.cancel();
*paused_at_sec = pos;
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, pos, stop_token).await {
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, pos, stream_type, stop_token).await {
result = Err(e);
break;
}
@@ -434,7 +445,8 @@ impl PlayerSourceLogic {
info!("PlayerSource: gapless transition to {:?}", next);
*current_uri = Some(next);
*paused_at_sec = 0.0;
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await {
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stream_type, stop_token).await {
result = Err(e);
}
*state = TransportState::Playing;
@@ -494,6 +506,7 @@ async fn send_track_boundary(
uri: Option<&str>,
output: &[mpsc::Sender<Arc<AudioSegment>>],
timestamp_sec: f64,
stream_type: StreamType,
stop_token: &CancellationToken,
) -> Result<(), AudioError> {
if output.is_empty() || stop_token.is_cancelled() {
@@ -505,7 +518,7 @@ async fn send_track_boundary(
let _ = meta.set_title(Some(u.to_string())).await;
}
let meta_arc = Arc::new(tokio::sync::RwLock::new(meta));
let boundary = AudioSegment::new_track_boundary(0, timestamp_sec, meta_arc);
let boundary = AudioSegment::new_track_boundary(0, timestamp_sec, meta_arc, stream_type);
send_to_children("PlayerSource", output, boundary).await
}

View File

@@ -113,7 +113,7 @@ use pmoaudio::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioSegment,
AudioSegment, StreamType,
};
use pmoaudiocache::Cache as AudioCache;
use pmoflac::decode_audio_stream;
@@ -279,7 +279,7 @@ impl NodeLogic for PlaylistSourceLogic {
let track_start = std::time::Instant::now();
tracing::debug!("PlaylistSourceLogic: emitting TrackBoundary");
let metadata_for_boundary = metadata.clone();
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata_for_boundary);
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata_for_boundary, StreamType::Finite);
send_to_children(node_name, &output, boundary).await?;
// Obtenir le chemin du fichier

View File

@@ -49,6 +49,8 @@ pub struct UriSource {
reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
stream_info: StreamInfo,
frames_to_skip: u64,
/// true si c'est un flux continu (radio, stream) sans durée définie
pub is_continuous: bool,
}
impl UriSource {
@@ -88,6 +90,11 @@ impl UriSource {
})
}
/// Retourne true si c'est un flux continu (radio, stream) sans durée définie.
pub fn is_continuous(&self) -> bool {
self.is_continuous
}
/// Émet les chunks audio vers `tx`.
///
/// Retourne `Ok(true)` si EOF naturel, `Ok(false)` si annulé ou receiver fermé.
@@ -201,7 +208,7 @@ impl UriSource {
);
let (_, reader) = stream.into_reader();
Ok(Self { reader: Box::new(reader), stream_info, frames_to_skip })
Ok(Self { reader: Box::new(reader), stream_info, frames_to_skip, is_continuous: false })
}
async fn open_http(
@@ -209,6 +216,9 @@ impl UriSource {
seek_sec: f64,
stop_token: &CancellationToken,
) -> Result<Self, AudioError> {
// Détecter si c'est un flux continu (radio, stream) basé sur l'URL
let is_continuous = detect_continuous_stream(url);
let response = tokio::select! {
_ = stop_token.cancelled() => {
return Err(AudioError::IoError("Cancelled before HTTP connect".into()));
@@ -244,11 +254,89 @@ impl UriSource {
let frames_to_skip = (seek_sec * stream_info.sample_rate as f64) as u64;
info!(
"UriSource: opened HTTP {} Hz {} ch {} bps",
stream_info.sample_rate, stream_info.channels, stream_info.bits_per_sample,
"UriSource: opened HTTP {} Hz {} ch {} bps continuous={}",
stream_info.sample_rate, stream_info.channels, stream_info.bits_per_sample, is_continuous,
);
let (_, reader) = stream.into_reader();
Ok(Self { reader: Box::new(reader), stream_info, frames_to_skip })
Ok(Self {
reader: Box::new(reader),
stream_info,
frames_to_skip,
is_continuous,
})
}
}
/// Détecte si une URL correspond à un flux continu (radio, stream) sans durée définie.
///
/// Cette fonction:
/// 1. Vérifie les patterns d'URL connus (stream, live, radio, etc.)
/// 2. Fait une requête HTTP HEAD pour vérifier les headers (Content-Length, ICY, etc.)
fn detect_continuous_stream(url: &str) -> bool {
let url_lower = url.to_lowercase();
// 1. Quick check sur les patterns d'URL très explicites
// Ces patterns indiquent clairement un stream live
if url_lower.contains("/live")
|| url_lower.contains("/radiolar")
|| url_lower.contains(".pls")
|| url_lower.contains(".m3u")
|| url_lower.contains("icy")
{
return true;
}
// 2. Vérification HTTP headers (le plus fiable)
if url.starts_with("http://") || url.starts_with("https://") {
if let Ok(is_stream) = check_http_stream_headers(url) {
if is_stream {
return true;
}
}
}
false
}
/// Vérifie les headers HTTP pour déterminer si c'est un stream
fn check_http_stream_headers(url: &str) -> Result<bool, String> {
use std::time::Duration;
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(3))
.build();
let response = agent
.head(url)
.call()
.map_err(|e| format!("HTTP HEAD failed: {}", e))?;
// Headers ICY (Icecast/Shoutcast) = toujours un stream
if response.header("icy-name").is_some()
|| response.header("icy-metaint").is_some()
{
return Ok(true);
}
// Pas de Content-Length = stream potentiel
let has_content_length = response.header("content-length").is_some();
// Transfer-Encoding: chunked = stream potentiel
let is_chunked = response
.header("transfer-encoding")
.map(|v| v.to_lowercase().contains("chunked"))
.unwrap_or(false);
// Decision: pas de Content-Length + (chunked ou content-type streaming)
let content_type = response
.header("content-type")
.unwrap_or("")
.to_lowercase();
let is_streaming_mime = content_type.contains("audio/mpeg")
|| content_type.contains("audio/aac")
|| content_type.contains("application/ogg");
Ok(!has_content_length && (is_streaming_mime || is_chunked))
}

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoaudio"
version = "0.1.0"
edition = "2021"
edition = "2024"
[features]
default = []

View File

@@ -91,6 +91,25 @@ impl<T: Sample> AudioChunkData<T> {
})
}
/// Crée un chunk de silence
///
/// # Arguments
///
/// * `frames` - Nombre de frames (échantillons par canal)
/// * `sample_rate` - Taux d'échantillonnage en Hz
///
/// # Exemples
///
/// ```
/// use pmoaudio::AudioChunkData;
///
/// let silence = AudioChunkData::<i32>::silence(48000, 48000);
/// assert_eq!(silence.len(), 48000);
/// ```
pub fn silence(frames: usize, sample_rate: u32) -> Arc<Self> {
Self::new(vec![[T::ZERO; 2]; frames], sample_rate, 0.0)
}
/// Retourne le nombre d'échantillons par canal (frames)
#[inline]
pub fn len(&self) -> usize {
@@ -317,6 +336,11 @@ pub enum AudioChunk {
}
impl AudioChunk {
/// Crée un chunk de silence (I32 par défaut)
pub fn silence(frames: usize, sample_rate: u32) -> Self {
AudioChunk::I32(AudioChunkData::<i32>::silence(frames, sample_rate))
}
/// Retourne le nombre de frames du chunk
pub fn len(&self) -> usize {
match self {

View File

@@ -3,7 +3,7 @@ use tokio::sync::RwLock;
use pmometadata::TrackMetadata;
use crate::{gain_db_from_linear, AudioChunk, AudioChunkData, BitDepth, SyncMarker};
use crate::{gain_db_from_linear, AudioChunk, AudioChunkData, BitDepth, StreamType, SyncMarker};
pub enum _AudioSegment {
Chunk(Arc<AudioChunk>),
@@ -160,9 +160,11 @@ impl AudioSegment {
order: u64,
timestamp_sec: f64,
metadata: Arc<RwLock<dyn TrackMetadata>>,
stream_type: StreamType,
) -> Arc<Self> {
let marker = Arc::new(SyncMarker::TrackBoundary {
metadata: Arc::clone(&metadata),
stream_type,
});
Arc::new(Self {
order,
@@ -303,7 +305,18 @@ impl AudioSegment {
pub fn as_track_metadata(&self) -> Option<&Arc<RwLock<dyn TrackMetadata>>> {
match &self.segment {
_AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { metadata } => Some(metadata),
SyncMarker::TrackBoundary { metadata, .. } => Some(metadata),
_ => None,
},
_ => None,
}
}
/// Récupère le stream_type si c'est un TrackBoundary
pub fn stream_type(&self) -> Option<StreamType> {
match &self.segment {
_AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { stream_type, .. } => Some(*stream_type),
_ => None,
},
_ => None,
@@ -409,7 +422,7 @@ impl<'a> TryInto<&'a Arc<AudioChunk>> for &'a AudioSegment {
fn try_into(self) -> Result<&'a Arc<AudioChunk>, Self::Error> {
match &self.segment {
_AudioSegment::Chunk(ref chunk) => Ok(chunk),
_AudioSegment::Chunk(chunk) => Ok(chunk),
_ => Err(()),
}
}
@@ -420,7 +433,7 @@ impl<'a> TryInto<&'a Arc<SyncMarker>> for &'a AudioSegment {
fn try_into(self) -> Result<&'a Arc<SyncMarker>, Self::Error> {
match &self.segment {
_AudioSegment::Sync(ref marker) => Ok(marker),
_AudioSegment::Sync(marker) => Ok(marker),
_ => Err(()),
}
}

View File

@@ -95,7 +95,7 @@ pub mod bit_depth;
pub mod dsp;
pub use audio_segment::{AudioSegment, _AudioSegment};
pub use sync_marker::SyncMarker;
pub use sync_marker::{StreamType, SyncMarker};
pub use audio_chunk::{
gain_db_from_linear, gain_linear_from_db, AudioChunk, AudioChunkData, AudioFloatChunk,

View File

@@ -2,7 +2,7 @@ use crate::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioChunkData, AudioSegment, I24,
AudioChunk, AudioChunkData, AudioSegment, I24, StreamType,
};
use pmoflac::{decode_audio_stream, AudioFileMetadata, StreamInfo};
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
@@ -98,6 +98,7 @@ impl NodeLogic for FileSourceLogic {
0,
0.0,
Arc::new(tokio::sync::RwLock::new(metadata)),
StreamType::Finite,
);
send_to_children(std::any::type_name::<Self>(), &output, track_boundary).await?;
}

View File

@@ -2,7 +2,7 @@ use crate::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
pipeline::{Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker,
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, StreamType,
};
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
use std::{
@@ -822,6 +822,7 @@ mod tests {
0,
0.0,
std::sync::Arc::new(tokio::sync::RwLock::new(metadata)),
crate::StreamType::Finite,
);
tx.send(track_boundary).await.unwrap();

View File

@@ -2,7 +2,7 @@ use crate::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
pipeline::{send_to_children, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioChunkData, AudioPipelineNode, AudioSegment, I24,
AudioChunk, AudioChunkData, AudioPipelineNode, AudioSegment, I24, StreamType,
};
use futures_util::StreamExt;
use pmoflac::{decode_audio_stream, StreamInfo};
@@ -178,7 +178,7 @@ impl NodeLogic for HttpSourceLogic {
// Émettre TrackBoundary avec les métadonnées HTTP
let track_boundary =
AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata)));
AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata)), StreamType::Finite);
send_to_children(std::any::type_name::<Self>(), &output, track_boundary).await?;
// Préparer la lecture des chunks audio

View File

@@ -33,7 +33,7 @@ use crate::{
nodes::{AudioError, TypedAudioNode},
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24,
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24, StreamType,
};
use std::sync::Arc;
use tokio::sync::mpsc;
@@ -495,7 +495,7 @@ mod tests {
let metadata = Arc::new(tokio::sync::RwLock::new(
pmometadata::MemoryTrackMetadata::new(),
));
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata, StreamType::Finite);
// Envoyer le boundary
input_tx.send(boundary.clone()).await.unwrap();

View File

@@ -3,9 +3,16 @@ use tokio::sync::RwLock;
use pmometadata::TrackMetadata;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamType {
Continuous,
Finite,
}
pub enum SyncMarker {
TrackBoundary {
metadata: Arc<RwLock<dyn TrackMetadata>>,
stream_type: StreamType,
},
StreamMetadata {
key: String,
@@ -15,5 +22,4 @@ pub enum SyncMarker {
Heartbeat,
EndOfStream,
Error(String),
// autres cas à venir…
}

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoaudiocache"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
# Cache générique

View File

@@ -1,7 +1,7 @@
[package]
name = "pmocache"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
# Base de données

View File

@@ -2,7 +2,7 @@
[package]
name = "pmoconfig"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
pmoutils = { version = "0.1.2", registry = "pmo" }

View File

@@ -1,7 +1,7 @@
[package]
name = "pmocovers"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
# Cache générique

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoflac"
version = "0.1.0"
edition = "2021"
edition = "2024"
authors = ["PMOMusic"]
description = "Asynchronous FLAC <-> PCM streaming utilities"
license = "MIT"

View File

@@ -467,7 +467,7 @@ unsafe fn setup_metadata(
append_comment("TRACKNUMBER", &track_number.to_string())?;
}
// Construct cover URL: use cover_pk with server_base_url if available, fallback to cover_url
if let (Some(ref pk), Some(ref base_url)) = (&metadata.cover_pk, &metadata.server_base_url) {
if let (Some(pk), Some(base_url)) = (&metadata.cover_pk, &metadata.server_base_url) {
let cover_url = format!("{}/covers/image/{}", base_url, pk);
append_comment("COVERART", &cover_url)?;
} else if let Some(cover_url) = &metadata.cover_url {

View File

@@ -1,7 +1,7 @@
[package]
name = "pmomediarenderer"
version = "0.1.0"
edition = "2024"
edition = "2021"
[dependencies]
pmoupnp = { path = "../pmoupnp" }
@@ -11,3 +11,28 @@ once_cell = "1.20"
bevy_reflect = "0.17.1"
htmlescape = "0.3"
quick-xml = { workspace = true }
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["http-stream"] }
pmoaudio = { path = "../pmoaudio" }
pmoflac = { path = "../pmoflac" }
pmoconfig = { path = "../pmoconfig" }
pmometadata = { path = "../pmometadata" }
pmoutils = { version = "0.1.2", registry = "pmo" }
tokio = { workspace = true, features = ["full"] }
tokio-util = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true, features = ["v4", "serde"] }
parking_lot = "0.12"
thiserror = { workspace = true }
tracing = { workspace = true }
pmoserver = { path = "../pmoserver", optional = true }
pmocontrol = { path = "../pmocontrol", optional = true }
[features]
default = []
pmoserver = ["dep:pmoserver", "dep:pmocontrol"]

View File

@@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};
#[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>;
}

View File

@@ -1,6 +1,6 @@
use crate::avtransport::variables::{
A_ARG_TYPE_INSTANCE_ID, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, AVTRANSPORTURI,
AVTRANSPORTURIMETADATA, CURRENTTRACK, NUMBEROFTRACKS,
AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, AVTRANSPORTURI, AVTRANSPORTURIMETADATA,
A_ARG_TYPE_INSTANCE_ID, CURRENTTRACK, NUMBEROFTRACKS,
};
use pmoupnp::define_action;

View File

@@ -1,5 +1,5 @@
use crate::avtransport::variables::{
A_ARG_TYPE_INSTANCE_ID, ABSOLUTETIMEPOSITION, AVTRANSPORTURI, AVTRANSPORTURIMETADATA,
ABSOLUTETIMEPOSITION, AVTRANSPORTURI, AVTRANSPORTURIMETADATA, A_ARG_TYPE_INSTANCE_ID,
CURRENTTRACK, CURRENTTRACKDURATION, RELATIVETIMEPOSITION,
};
use pmoupnp::define_action;

View File

@@ -1,5 +1,5 @@
use crate::avtransport::variables::{
A_ARG_TYPE_INSTANCE_ID, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA,
AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, A_ARG_TYPE_INSTANCE_ID,
};
use pmoupnp::define_action;

View File

@@ -1,5 +1,5 @@
use crate::avtransport::variables::{
A_ARG_TYPE_INSTANCE_ID, AVTRANSPORTURI, AVTRANSPORTURIMETADATA,
AVTRANSPORTURI, AVTRANSPORTURIMETADATA, A_ARG_TYPE_INSTANCE_ID,
};
use pmoupnp::define_action;

View File

@@ -100,8 +100,8 @@ use actions::{
SETNEXTAVTRANSPORTURI, STOP,
};
use variables::{
A_ARG_TYPE_INSTANCE_ID, A_ARG_TYPE_PLAY_SPEED, A_ARG_TYPE_SEEKMODE, ABSOLUTETIMEPOSITION,
AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, AVTRANSPORTURI, AVTRANSPORTURIMETADATA,
ABSOLUTETIMEPOSITION, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, AVTRANSPORTURI,
AVTRANSPORTURIMETADATA, A_ARG_TYPE_INSTANCE_ID, A_ARG_TYPE_PLAY_SPEED, A_ARG_TYPE_SEEKMODE,
CURRENTMEDIADURATION, CURRENTPLAYMODE, CURRENTTRACK, CURRENTTRACKDURATION,
CURRENTTRACKMETADATA, CURRENTTRACKURI, NUMBEROFTRACKS, PLAYBACKSTORAGEMEDIUM,
POSSIBLEPLAYBACKSTORAGEMEDIA, RELATIVETIMEPOSITION, SEEKMODE, TRANSPORTPLAYSPEED,

View File

@@ -1,66 +0,0 @@
//! Définition du device MediaRenderer.
use once_cell::sync::Lazy;
use std::sync::Arc;
use crate::{
avtransport::AVTTRANSPORT, connectionmanager::CONNECTIONMANAGER,
renderingcontrol::RENDERINGCONTROL,
};
use pmoupnp::devices::Device;
/// Device MediaRenderer UPnP.
///
/// MediaRenderer audio-only conforme UPnP AV Architecture 1.0.
///
/// # Services inclus
///
/// - **AVTransport:1** : Contrôle de la lecture
/// - **RenderingControl:1** : Contrôle du volume et du mute
/// - **ConnectionManager:1** : Gestion des connexions
///
/// # Spécifications
///
/// - Device Type : `urn:schemas-upnp-org:device:MediaRenderer:1`
/// - Version : 1
/// - Manufacturer : PMOMusic
/// - Model : PMOMusic Audio Renderer
///
/// # Exemple
///
/// ```ignore
/// use pmomediarenderer::MEDIA_RENDERER;
/// use pmoupnp::UpnpModel;
///
/// // Créer une instance du renderer
/// let renderer_instance = MEDIA_RENDERER.create_instance();
///
/// // Accéder aux services
/// if let Some(avtransport) = renderer_instance.get_service("AVTransport") {
/// // Contrôler la lecture...
/// }
/// ```
pub static MEDIA_RENDERER: Lazy<Arc<Device>> = Lazy::new(|| {
let mut device = Device::new_from_config(
"PMO_MediaRenderer".to_string(),
"MediaRenderer".to_string(),
"Audio Renderer".to_string(),
);
device.set_model_description("UPnP AV MediaRenderer for audio streaming".to_string());
// Ajouter les trois services obligatoires
device
.add_service(Arc::clone(&AVTTRANSPORT))
.expect("Failed to add AVTransport service");
device
.add_service(Arc::clone(&RENDERINGCONTROL))
.expect("Failed to add RenderingControl service");
device
.add_service(Arc::clone(&CONNECTIONMANAGER))
.expect("Failed to add ConnectionManager service");
Arc::new(device)
});

View File

@@ -1,15 +1,9 @@
//! Erreurs liées au WebRenderer
//! Erreurs liées au MediaRenderer
use thiserror::Error;
#[derive(Error, Debug)]
pub enum WebRendererError {
#[error("Session not found: {0}")]
SessionNotFound(String),
#[error("Failed to send message to websocket: {0}")]
WebSocketSendError(String),
pub enum MediaRendererError {
#[error("Invalid argument: {0}")]
InvalidArgument(String),

View File

@@ -0,0 +1,233 @@
//! Action handlers SOAP → Pipeline pour le MediaRenderer
//!
//! Chaque handler bridge une action UPnP vers une commande `PipelineControl`
//! envoyée au pipeline audio serveur, ou lit l'état partagé pour les requêtes GET.
use pmodidl::DIDLLite;
use pmodidl::ToXmlElement;
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};
use crate::state::SharedState;
// ─── AVTransport : commandes de transport ─────────────────────────────────────
pub fn play_handler(
pipeline: PipelineHandle,
state: SharedState,
instance_id: String,
stream_url_base: String,
) -> ActionHandler {
action_handler!(
captures(pipeline, state, instance_id, stream_url_base) | data | {
tracing::info!("[MediaRenderer] UPnP Play action invoked");
let has_uri = state.read().current_uri.is_some();
if !has_uri {
tracing::warn!("[MediaRenderer] 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!("{}/{}/stream", stream_url_base, instance_id),
});
}
pipeline.flac_handle.resume();
pipeline.send(PipelineControl::Play).await;
Ok(data)
}
)
}
pub fn stop_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
action_handler!(
captures(pipeline, state) | data | {
pipeline.send(PipelineControl::Stop).await;
pipeline.flac_handle.pause();
pipeline
.adapter
.deliver(crate::adapter::DeviceCommand::Flush);
pipeline
.adapter
.deliver(crate::adapter::DeviceCommand::Stop);
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;
pipeline.flac_handle.pause();
pipeline
.adapter
.deliver(crate::adapter::DeviceCommand::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)
}
)
}
pub fn previous_handler(pipeline: PipelineHandle) -> ActionHandler {
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)
}
)
}
// ─── AVTransport : chargement de média ────────────────────────────────────────
pub fn set_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
action_handler!(captures(pipeline, state) |mut data| {
tracing::info!("[MediaRenderer] UPnP SetAVTransportURI action invoked");
let uri: String = get!(&data, "CurrentURI", String);
let metadata: String = get_value::<String>(&data, "CurrentURIMetaData")
.or_else(|_| get_value::<DIDLLite>(&data, "CurrentURIMetaData").map(|didl| didl.to_xml()))
.unwrap_or_default();
tracing::info!(uri = %uri, "SetAVTransportURI handler called - loading URI into pipeline");
{
let mut s = state.write();
s.current_uri = Some(uri.clone());
s.current_metadata = Some(metadata);
s.playback_state = PlaybackState::Transitioning;
}
pipeline.send(PipelineControl::LoadUri(uri)).await;
Ok(data)
})
}
pub fn set_next_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
action_handler!(captures(pipeline, state) |mut data| {
let uri: String = get!(&data, "NextURI", String);
let metadata: String = get_value::<String>(&data, "NextURIMetaData")
.or_else(|_| get_value::<DIDLLite>(&data, "NextURIMetaData").map(|didl| didl.to_xml()))
.unwrap_or_default();
{
let mut s = state.write();
s.next_uri = Some(uri.clone());
s.next_metadata = Some(metadata);
}
pipeline.send(PipelineControl::LoadNextUri(uri)).await;
Ok(data)
})
}
// ─── AVTransport : getters ─────────────────────────────────────────────────────
pub fn get_position_info_handler(state: SharedState) -> ActionHandler {
action_handler!(captures(state) |mut data| {
let s = state.read();
set!(&mut data, "Track", if s.current_uri.is_some() { 1u32 } else { 0u32 });
set!(&mut data, "TrackDuration", s.duration.clone().unwrap_or_else(|| "00:00:00".to_string()));
set!(&mut data, "TrackURI", s.current_uri.clone().unwrap_or_default());
set!(&mut data, "TrackMetaData", s.current_metadata.clone().unwrap_or_default());
set!(&mut data, "RelTime", s.position.clone().unwrap_or_else(|| "00:00:00".to_string()));
set!(&mut data, "AbsTime", s.position.clone().unwrap_or_else(|| "00:00:00".to_string()));
Ok(data)
})
}
pub fn get_transport_info_handler(state: SharedState) -> ActionHandler {
action_handler!(captures(state) |mut data| {
let s = state.read();
let transport_state = match s.playback_state {
PlaybackState::Stopped => "STOPPED",
PlaybackState::Playing => "PLAYING",
PlaybackState::Paused => "PAUSED_PLAYBACK",
PlaybackState::Transitioning => "TRANSITIONING",
};
set!(&mut data, "CurrentTransportState", transport_state.to_string());
set!(&mut data, "CurrentTransportStatus", "OK".to_string());
set!(&mut data, "CurrentSpeed", "1".to_string());
Ok(data)
})
}
pub fn get_media_info_handler(state: SharedState) -> ActionHandler {
action_handler!(captures(state) |mut data| {
let s = state.read();
set!(&mut data, "NrTracks", if s.current_uri.is_some() { 1u32 } else { 0u32 });
set!(&mut data, "CurrentURI", s.current_uri.clone().unwrap_or_default());
set!(&mut data, "CurrentURIMetaData", s.current_metadata.clone().unwrap_or_default());
set!(&mut data, "NextURI", s.next_uri.clone().unwrap_or_default());
set!(&mut data, "NextURIMetaData", s.next_metadata.clone().unwrap_or_default());
Ok(data)
})
}
// ─── ConnectionManager ─────────────────────────────────────────────────────────
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()
);
Ok(data)
})
}
// ─── RenderingControl ──────────────────────────────────────────────────────────
pub fn set_volume_handler(state: SharedState) -> ActionHandler {
action_handler!(captures(state) |mut data| {
let volume: u16 = get!(&data, "DesiredVolume", u16);
state.write().volume = volume;
Ok(data)
})
}
pub fn get_volume_handler(state: SharedState) -> ActionHandler {
action_handler!(captures(state) |mut data| {
let volume = state.read().volume;
set!(&mut data, "CurrentVolume", volume);
Ok(data)
})
}
pub fn set_mute_handler(state: SharedState) -> ActionHandler {
action_handler!(captures(state) |mut data| {
let mute: bool = get!(&data, "DesiredMute", bool);
state.write().mute = mute;
Ok(data)
})
}
pub fn get_mute_handler(state: SharedState) -> ActionHandler {
action_handler!(captures(state) |mut data| {
let mute = state.read().mute;
set!(&mut data, "CurrentMute", mute);
Ok(data)
})
}

View File

@@ -11,25 +11,23 @@
//! - **AVTransport** : Contrôle de la lecture (play, pause, stop, seek, etc.)
//! - **RenderingControl** : Contrôle du volume et du mute
//! - **ConnectionManager** : Gestion des connexions et des protocoles supportés
//!
//! # Device UPnP
//!
//! - Type : `urn:schemas-upnp-org:device:MediaRenderer:1`
//! - Services : AVTransport:1, RenderingControl:1, ConnectionManager:1
//!
//! # Utilisation
//!
//! ```ignore
//! use pmomediarenderer::MEDIA_RENDERER;
//!
//! // Le device est déjà configuré avec tous ses services
//! let renderer = MEDIA_RENDERER.clone();
//! let instance = renderer.create_instance();
//! ```
pub mod adapter;
pub mod avtransport;
pub mod connectionmanager;
pub mod device;
pub mod error;
pub mod handlers;
pub mod messages;
pub mod pipeline;
pub mod registry;
pub mod renderingcontrol;
pub mod renderer;
pub mod state;
pub use device::MEDIA_RENDERER;
pub use error::MediaRendererError;
pub use handlers::*;
pub use messages::PlaybackState;
pub use pipeline::{PipelineControl, PipelineHandle, seconds_to_upnp_time, upnp_time_to_seconds, InstancePipeline};
pub use registry::{MediaRendererInstance, MediaRendererRegistry};
pub use state::{RendererState, SharedState};
pub use adapter::{DeviceAdapter, DeviceCommand, DevicePlaybackState, DeviceStateReport};

View File

@@ -0,0 +1,13 @@
//! Types de messages pour le MediaRenderer
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum PlaybackState {
Stopped,
Playing,
Paused,
Transitioning,
}

View File

@@ -1,12 +1,11 @@
//! Pipeline audio serveur par instance WebRenderer
//! Pipeline audio serveur par instance MediaRenderer
//!
//! Chaque instance WebRenderer possède un pipeline indépendant :
//! - Une `PlayerSource` qui gère le cycle de vie AVTransport (Play/Pause/Stop/Seek/LoadUri)
//! - Un `StreamingOggFlacSink` qui encode et diffuse le flux OGG-FLAC aux clients HTTP
//! - Des nœuds de normalisation (resampling → 96 kHz, conversion → I24)
//! Chaque instance MediaRenderer possède un pipeline独立的音频处理:
//! - 一个 `PlayerSource` 管理 AVTransport 生命周期(Play/Pause/Stop/Seek/LoadUri
//! - 一个 `StreamingOggFlacSink` 编码并向 HTTP 客户端传输 OGG-FLAC 流
//! - 规范化节点(重采样 → 96 kHz转换 → I24
use std::sync::Arc;
use pmoaudio::{ResamplingNode, ToI24Node};
use pmoaudio_ext::{PlayerCommand, PlayerHandle, PlayerSource};
use pmoaudio_ext::sinks::{OggFlacStreamHandle, StreamingOggFlacSink};
@@ -18,25 +17,21 @@ use crate::state::SharedState;
// ─── Ré-export des commandes pour les handlers ────────────────────────────────
/// Commandes de transport — alias vers PlayerCommand pour compatibilité handlers
pub use pmoaudio_ext::PlayerCommand as PipelineControl;
// ─── Handle vers le pipeline ─────────────────────────────────────────────────
/// Handle partageable vers le pipeline audio d'une instance.
///
/// Expose le `PlayerHandle` pour les commandes AVTransport et le `CancellationToken`
/// pour l'arrêt complet du pipeline.
#[derive(Clone)]
pub struct PipelineHandle {
pub player: PlayerHandle,
pub stop_token: CancellationToken,
/// Volume courant (géré localement, pas dans PlayerSource)
pub flac_handle: pmoaudio_ext::sinks::OggFlacStreamHandle,
pub adapter: Arc<dyn crate::adapter::DeviceAdapter>,
#[allow(dead_code)]
state: SharedState,
}
impl PipelineHandle {
/// Envoie une commande de transport. Gère SetVolume/SetMute localement.
pub async fn send(&self, cmd: PipelineControl) {
match cmd {
PlayerCommand::LoadUri(uri) => self.player.load_uri(uri).await,
@@ -51,45 +46,34 @@ impl PipelineHandle {
// ─── Pipeline instancié ──────────────────────────────────────────────────────
/// Pipeline audio complet pour une instance WebRenderer.
///
/// Créé au `POST /register`. Le flux OGG-FLAC est accessible via `flac_handle`
/// (multi-client broadcast, chaque `subscribe()` crée un flux indépendant).
pub struct InstancePipeline {
/// Handle vers le sink OGG-FLAC — clonable, subscribe() crée un flux indépendant par client.
pub flac_handle: OggFlacStreamHandle,
pub pipeline_handle: PipelineHandle,
}
impl InstancePipeline {
/// Crée et démarre le pipeline en background.
/// Retourne immédiatement avec les handles nécessaires.
pub fn start(
state: SharedState,
#[cfg(feature = "pmoserver")]
control_point: Arc<pmocontrol::ControlPoint>,
udn: String,
adapter: Arc<dyn crate::adapter::DeviceAdapter>,
) -> Self {
let stop_token = CancellationToken::new();
use pmoaudio::pipeline::AudioPipelineNode;
// Sink broadcast OGG-FLAC (multi-client, pacé à 0.5s max d'avance)
let (sink, flac_handle) = StreamingOggFlacSink::new(EncoderOptions::default(), 24);
// Nœud de conversion de profondeur : tout type entier → I24
let mut to_i24 = ToI24Node::new();
to_i24.register(sink.boxed());
// Nœud de rééchantillonnage : n'importe quel sample rate → 96 kHz
let mut resampler = ResamplingNode::new(96_000);
resampler.register(to_i24.boxed());
// Source avec contrôle AVTransport complet
let (mut player_source, player_handle) = PlayerSource::new();
player_source.register(resampler.boxed());
// Lancer le pipeline en background
let sink_stop = stop_token.clone();
tokio::spawn(async move {
if let Err(e) = player_source.boxed().run(sink_stop).await {
@@ -98,16 +82,17 @@ impl InstancePipeline {
debug!("Pipeline task terminated");
});
// Écouter les événements PlayerSource pour mettre à jour le state UPnP
let event_rx = player_handle.subscribe_events();
let state_clone = state.clone();
let udn_clone = udn.clone();
let adapter_clone = Arc::downgrade(&adapter);
#[cfg(feature = "pmoserver")]
let cp_clone = control_point.clone();
tokio::spawn(async move {
run_event_listener(
event_rx,
state_clone,
adapter_clone,
udn_clone,
#[cfg(feature = "pmoserver")]
cp_clone,
@@ -117,6 +102,8 @@ impl InstancePipeline {
let pipeline_handle = PipelineHandle {
player: player_handle,
stop_token: stop_token.clone(),
flac_handle: flac_handle.clone(),
adapter,
state,
};
@@ -132,12 +119,14 @@ impl InstancePipeline {
async fn run_event_listener(
mut event_rx: tokio::sync::broadcast::Receiver<pmoaudio_ext::PlayerEvent>,
state: SharedState,
adapter: std::sync::Weak<dyn crate::adapter::DeviceAdapter>,
udn: String,
#[cfg(feature = "pmoserver")]
control_point: Arc<pmocontrol::ControlPoint>,
) {
use pmoaudio_ext::PlayerEvent;
use crate::messages::PlaybackState;
use crate::adapter::DeviceCommand;
loop {
match event_rx.recv().await {
@@ -148,7 +137,6 @@ async fn run_event_listener(
s.current_uri = Some(uri);
s.duration = duration_sec.map(seconds_to_upnp_time);
s.position = None;
// Effacer next_uri/next_metadata : la nouvelle piste est maintenant courante
s.next_uri = None;
s.next_metadata = None;
}
@@ -166,6 +154,10 @@ async fn run_event_listener(
state.write().position = Some(seconds_to_upnp_time(position_sec));
}
PlayerEvent::TrackEnded => {
state.write().playback_state = PlaybackState::Transitioning;
if let Some(adapter) = adapter.upgrade() {
adapter.deliver(DeviceCommand::Flush);
}
#[cfg(feature = "pmoserver")]
{
let cp = control_point.clone();
@@ -208,4 +200,4 @@ pub fn upnp_time_to_seconds(t: &str) -> f64 {
[s] => *s,
_ => 0.0,
}
}
}

View File

@@ -1,7 +1,4 @@
//! Registre des instances WebRenderer actives.
//!
//! Remplace `SessionManager` et `websocket.rs`. La session est maintenant liée
//! au flux FLAC HTTP, pas à une connexion WebSocket.
//! Registre des instances MediaRenderer actives.
use parking_lot::RwLock;
use std::collections::HashMap;
@@ -10,10 +7,11 @@ use std::time::SystemTime;
use pmoupnp::devices::DeviceInstance;
use crate::error::WebRendererError;
use crate::error::MediaRendererError;
use crate::pipeline::{InstancePipeline, PipelineHandle};
use crate::renderer::WebRendererFactory;
use crate::renderer::MediaRendererFactory;
use crate::state::{RendererState, SharedState};
use super::adapter::DeviceAdapter;
#[cfg(feature = "pmoserver")]
@@ -23,31 +21,26 @@ use pmocontrol::model::{RendererCapabilities, RendererProtocol};
#[cfg(feature = "pmoserver")]
use pmoupnp::UpnpTypedInstance;
/// Une instance WebRenderer côté serveur
pub struct WebRendererInstance {
pub struct MediaRendererInstance {
pub instance_id: String,
pub udn: String,
pub device_instance: Arc<DeviceInstance>,
pub state: SharedState,
/// Handle vers le sink OGG-FLAC — clonable, subscribe() crée un flux indépendant par client.
pub flac_handle: pmoaudio_ext::sinks::OggFlacStreamHandle,
pub pipeline: PipelineHandle,
pub created_at: SystemTime,
pub adapter: Arc<dyn DeviceAdapter>,
}
/// Registre global des instances WebRenderer
pub struct RendererRegistry {
/// Map instance_id → instance
instances: RwLock<HashMap<String, Arc<WebRendererInstance>>>,
/// Map udn → instance (pour retrouver depuis les handlers UPnP)
by_udn: RwLock<HashMap<String, Arc<WebRendererInstance>>>,
/// Tokens d'annulation des unregister différés (instance_id → token)
pub struct MediaRendererRegistry {
instances: RwLock<HashMap<String, Arc<MediaRendererInstance>>>,
by_udn: RwLock<HashMap<String, Arc<MediaRendererInstance>>>,
pending_unregister: RwLock<HashMap<String, tokio_util::sync::CancellationToken>>,
#[cfg(feature = "pmoserver")]
control_point: Arc<ControlPoint>,
}
impl RendererRegistry {
impl MediaRendererRegistry {
#[cfg(feature = "pmoserver")]
pub fn new(control_point: Arc<ControlPoint>) -> Self {
Self {
@@ -67,35 +60,39 @@ impl RendererRegistry {
}
}
/// Enregistre ou reconnecte une instance.
/// Retourne `(stream_url, udn)`.
pub async fn register_or_reconnect(
&self,
instance_id: &str,
user_agent: &str,
) -> Result<(String, String), WebRendererError> {
// Annuler tout unregister différé pour cet instance_id
stream_url_base: &str,
renderer_name: &str,
adapter_fn: impl FnOnce(SharedState) -> Arc<dyn DeviceAdapter>,
) -> Result<(String, String, bool), MediaRendererError> {
if let Some(cancel) = self.pending_unregister.write().remove(instance_id) {
tracing::info!(instance_id = %instance_id, "WebRenderer: cancelled pending unregister (page reload)");
tracing::info!(instance_id = %instance_id, "MediaRenderer: cancelled pending unregister (page reload)");
cancel.cancel();
}
// Reconnexion : l'instance existe déjà (ou vient d'être conservée)
{
let instances = self.instances.read();
if let Some(existing) = instances.get(instance_id) {
tracing::info!(instance_id = %instance_id, "WebRenderer: reconnecting existing instance");
tracing::info!(instance_id = %instance_id, "MediaRenderer: reconnecting existing instance");
#[cfg(feature = "pmoserver")]
self.register_with_control_point(&existing.device_instance)?;
let stream_url = format!("/api/webrenderer/{}/stream", instance_id);
return Ok((stream_url, existing.udn.clone()));
self.register_with_control_point(&existing.device_instance, renderer_name)?;
let stream_url = format!("{}/{}/stream", stream_url_base, instance_id);
let should_play = {
let s = existing.state.read();
s.current_uri.is_some() && matches!(
s.playback_state,
crate::messages::PlaybackState::Playing | crate::messages::PlaybackState::Transitioning
)
};
return Ok((stream_url, existing.udn.clone(), should_play));
}
}
// Première connexion : créer device UPnP + pipeline
let instance = self.create_instance(instance_id, user_agent).await?;
let instance = self.create_instance_with_adapter(instance_id, stream_url_base, renderer_name, adapter_fn).await?;
let instance = Arc::new(instance);
let stream_url = format!("/api/webrenderer/{}/stream", instance_id);
let stream_url = format!("{}/{}/stream", stream_url_base, instance_id);
let udn = instance.udn.clone();
{
@@ -110,25 +107,29 @@ impl RendererRegistry {
tracing::info!(
instance_id = %instance_id,
udn = %udn,
"WebRenderer: new instance registered"
"MediaRenderer: new instance registered"
);
Ok((stream_url, udn))
Ok((stream_url, udn, false))
}
/// Retourne un OggFlacClientStream indépendant pour l'endpoint /stream.
/// Chaque appel crée un nouveau subscriber broadcast — safe pour connexions simultanées.
pub fn get_stream(
&self,
instance_id: &str,
) -> Option<pmoaudio_ext::sinks::OggFlacClientStream> {
self.instances
.read()
.get(instance_id)
.map(|i| i.flac_handle.subscribe())
let instances = self.instances.read();
match instances.get(instance_id) {
Some(i) => {
tracing::debug!(instance_id = %instance_id, "Found instance, getting flac_handle");
Some(i.flac_handle.subscribe())
}
None => {
tracing::error!(instance_id = %instance_id, "Instance not found in registry!");
None
}
}
}
/// Retourne le PipelineHandle par UDN (pour les handlers UPnP)
pub fn get_pipeline_by_udn(&self, udn: &str) -> Option<PipelineHandle> {
self.by_udn
.read()
@@ -136,7 +137,28 @@ impl RendererRegistry {
.map(|i| i.pipeline.clone())
}
/// Retourne le SharedState par UDN
pub fn get_instance(&self, instance_id: &str) -> Option<Arc<MediaRendererInstance>> {
self.instances.read().get(instance_id).cloned()
}
pub fn get_state(&self, instance_id: &str) -> Option<SharedState> {
self.instances
.read()
.get(instance_id)
.map(|i| i.state.clone())
}
pub fn get_pipeline(&self, instance_id: &str) -> Option<PipelineHandle> {
self.instances.read().get(instance_id).map(|i| i.pipeline.clone())
}
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()))
}
pub fn get_state_by_udn(&self, udn: &str) -> Option<SharedState> {
self.by_udn
.read()
@@ -144,7 +166,6 @@ impl RendererRegistry {
.map(|i| i.state.clone())
}
/// Retourne le DeviceInstance par UDN
pub fn get_device_by_udn(&self, udn: &str) -> Option<Arc<DeviceInstance>> {
self.by_udn
.read()
@@ -152,14 +173,10 @@ impl RendererRegistry {
.map(|i| i.device_instance.clone())
}
/// Met à jour la durée depuis le navigateur.
/// La position est gérée par PlayerSource via PlayerEvent::Position.
/// On n'utilise duration_sec que si la source ne la connaît pas (flux radio sans durée).
pub fn update_duration(&self, instance_id: &str, duration_sec: Option<f64>) {
let instances = self.instances.read();
if let Some(instance) = instances.get(instance_id) {
let mut s = instance.state.write();
// N'écraser la durée que si elle n'est pas déjà connue (la source est prioritaire)
if s.duration.is_none() {
if let Some(dur) = duration_sec {
if dur > 0.0 {
@@ -173,19 +190,18 @@ impl RendererRegistry {
pub fn schedule_unregister(self: &Arc<Self>, instance_id: &str) {
use tokio_util::sync::CancellationToken;
// Ne pas détruire immédiatement : attendre 5s au cas où la page se recharge
let cancel = CancellationToken::new();
self.pending_unregister.write().insert(instance_id.to_string(), cancel.clone());
let instance_id_owned = instance_id.to_string();
let registry = Arc::clone(self);
tracing::info!(instance_id = %instance_id, "WebRenderer: unregister scheduled (5s grace period)");
tracing::info!(instance_id = %instance_id, "MediaRenderer: unregister scheduled (5s grace period)");
tokio::spawn(async move {
tokio::select! {
_ = cancel.cancelled() => {
tracing::info!(instance_id = %instance_id_owned, "WebRenderer: deferred unregister cancelled (page reload)");
tracing::info!(instance_id = %instance_id_owned, "MediaRenderer: deferred unregister cancelled (page reload)");
}
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
registry.pending_unregister.write().remove(&instance_id_owned);
@@ -200,7 +216,7 @@ impl RendererRegistry {
tracing::info!(
instance_id = %instance_id_owned,
udn = %instance.udn,
"WebRenderer: instance unregistered"
"MediaRenderer: instance unregistered"
);
}
}
@@ -208,71 +224,72 @@ impl RendererRegistry {
});
}
// ── Création d'instance ────────────────────────────────────────────────────
async fn create_instance(
/// Créer une nouvelle instance avec un adapter fourni via une factory closure.
/// La closure reçoit le SharedState de l'instance afin que l'adapter partage le même état.
pub async fn create_instance_with_adapter(
&self,
instance_id: &str,
user_agent: &str,
) -> Result<WebRendererInstance, WebRendererError> {
// UDN stable dérivé de l'instance_id
stream_url_base: &str,
renderer_name: &str,
adapter_fn: impl FnOnce(SharedState) -> Arc<dyn DeviceAdapter>,
) -> Result<MediaRendererInstance, MediaRendererError> {
let candidate_udn = instance_id.to_ascii_lowercase();
let full_udn = format!("uuid:{}", candidate_udn);
// Persister l'UDN dans la config (pour que device_instance.rs le retrouve)
if let Err(e) = pmoconfig::get_config().set_device_udn(
"MediaRenderer",
instance_id,
candidate_udn.clone(),
) {
tracing::warn!("WebRenderer: failed to persist UDN: {:?}", e);
tracing::warn!("MediaRenderer: failed to persist UDN: {:?}", e);
}
let state: SharedState = Arc::new(parking_lot::RwLock::new(RendererState::default()));
let adapter = adapter_fn(state.clone());
#[cfg(feature = "pmoserver")]
let (device_instance, pipeline) = {
use pmoupnp::UpnpServerExt;
let server_arc = pmoserver::get_server()
.ok_or(WebRendererError::ServerNotAvailable)?;
.ok_or(MediaRendererError::ServerNotAvailable)?;
// Créer le pipeline d'abord pour avoir le PipelineHandle
let ip = InstancePipeline::start(
state.clone(),
self.control_point.clone(),
full_udn.clone(),
adapter.clone(),
);
let pipeline = ip.pipeline_handle.clone();
// Vérifier si un device avec ce même UDN existe déjà
let existing_di = {
let server = server_arc.read().await;
server.get_device(&candidate_udn)
};
let di = if let Some(di) = existing_di {
tracing::info!(udn = %candidate_udn, "WebRenderer: reusing device from registry");
tracing::info!(udn = %candidate_udn, "MediaRenderer: reusing device from registry");
di
} else {
// Créer le device UPnP
tracing::info!(udn = %candidate_udn, "WebRenderer: creating new device");
let device = WebRendererFactory::create_device_with_pipeline(
tracing::info!(udn = %candidate_udn, "MediaRenderer: creating new device");
let device = MediaRendererFactory::create_device_with_pipeline(
instance_id,
user_agent,
"MediaRenderer",
"WebRenderer",
pipeline.clone(),
state.clone(),
stream_url_base,
)
.map_err(|e| WebRendererError::DeviceCreationError(e.to_string()))?;
.map_err(|e| MediaRendererError::DeviceCreationError(e.to_string()))?;
let mut server = server_arc.write().await;
server
.register_device(Arc::new(device), false)
.await
.map_err(|e| WebRendererError::RegistrationError(e.to_string()))?
.map_err(|e| MediaRendererError::RegistrationError(e.to_string()))?
};
self.register_with_control_point(&di)?;
self.register_with_control_point(&di, renderer_name)?;
(di, ip)
};
@@ -280,21 +297,27 @@ impl RendererRegistry {
let (device_instance, pipeline) = {
use pmoupnp::UpnpModel;
let ip = InstancePipeline::start(state.clone(), full_udn.clone());
let ip = InstancePipeline::start(
state.clone(),
full_udn.clone(),
adapter.clone(),
);
let pipeline = ip.pipeline_handle.clone();
let device = WebRendererFactory::create_device_with_pipeline(
let device = MediaRendererFactory::create_device_with_pipeline(
instance_id,
user_agent,
"MediaRenderer",
"WebRenderer",
pipeline.clone(),
state.clone(),
stream_url_base,
)
.map_err(|e| WebRendererError::DeviceCreationError(e.to_string()))?;
.map_err(|e| MediaRendererError::DeviceCreationError(e.to_string()))?;
(Arc::new(device).create_instance(), ip)
};
Ok(WebRendererInstance {
Ok(MediaRendererInstance {
instance_id: instance_id.to_string(),
udn: full_udn,
device_instance,
@@ -302,15 +325,16 @@ impl RendererRegistry {
flac_handle: pipeline.flac_handle.clone(),
pipeline: pipeline.pipeline_handle,
created_at: SystemTime::now(),
adapter,
})
}
/// Enregistre le device dans le ControlPoint
#[cfg(feature = "pmoserver")]
fn register_with_control_point(
&self,
di: &Arc<DeviceInstance>,
) -> Result<(), WebRendererError> {
renderer_name: &str,
) -> Result<(), MediaRendererError> {
let base_url = di.base_url().to_string();
let udn = di.udn().to_ascii_lowercase();
let udn_with_prefix = format!("uuid:{}", udn);
@@ -345,7 +369,7 @@ impl RendererRegistry {
..Default::default()
},
format!("{}{}", base_url, di.description_route()),
"PMOMusic WebRenderer/2.0".to_string(),
renderer_name.to_string(),
Some("urn:schemas-upnp-org:service:AVTransport:1".to_string()),
avtransport_control_url,
Some("urn:schemas-upnp-org:service:RenderingControl:1".to_string()),
@@ -359,7 +383,7 @@ impl RendererRegistry {
registry.push_renderer(&renderer_info, 86400);
}
tracing::info!(udn = %udn, "WebRenderer: registered with ControlPoint");
tracing::info!(udn = %udn, "MediaRenderer: registered with ControlPoint");
Ok(())
}
}
}

View File

@@ -0,0 +1,344 @@
//! Factory pour créer des instances MediaRenderer privées
use pmoupnp::actions::{Action, Argument};
use pmoupnp::devices::Device;
use pmoupnp::services::Service;
use std::sync::Arc;
use thiserror::Error;
use crate::handlers;
use crate::pipeline::PipelineHandle;
use crate::state::SharedState;
// ─── Helper functions pour l'ajout d'arguments UPnP ───────────────────────
fn add_arg_in(
action: &mut Action,
name: &str,
var: &Arc<pmoupnp::state_variables::StateVariable>,
) -> Result<(), FactoryError> {
action
.add_argument(Arc::new(Argument::new_in(
name.to_string(),
Arc::clone(var),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))
}
fn add_arg_out(
action: &mut Action,
name: &str,
var: &Arc<pmoupnp::state_variables::StateVariable>,
) -> Result<(), FactoryError> {
action
.add_argument(Arc::new(Argument::new_out(
name.to_string(),
Arc::clone(var),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))
}
fn add_var(
svc: &mut Service,
var: &Arc<pmoupnp::state_variables::StateVariable>,
) -> Result<(), FactoryError> {
svc.add_variable(Arc::clone(var))
.map_err(|e| FactoryError::VariableError(e.to_string()))
}
fn add_action(svc: &mut Service, action: Arc<Action>) -> Result<(), FactoryError> {
svc.add_action(action)
.map_err(|e| FactoryError::ActionError(e.to_string()))
}
// ─── Réimport des variables statiques de pmomediarenderer ───────────────────
use crate::avtransport::variables::{
ABSOLUTETIMEPOSITION, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, AVTRANSPORTURI,
AVTRANSPORTURIMETADATA, A_ARG_TYPE_INSTANCE_ID as AVT_INSTANCE_ID, A_ARG_TYPE_PLAY_SPEED,
A_ARG_TYPE_SEEKMODE, CURRENTMEDIADURATION, CURRENTPLAYMODE, CURRENTTRACK, CURRENTTRACKDURATION,
CURRENTTRACKMETADATA, CURRENTTRACKURI, NUMBEROFTRACKS, PLAYBACKSTORAGEMEDIUM,
POSSIBLEPLAYBACKSTORAGEMEDIA, RELATIVETIMEPOSITION, SEEKMODE, TRANSPORTPLAYSPEED,
TRANSPORTSTATE, TRANSPORTSTATUS,
};
use crate::renderingcontrol::variables::{
A_ARG_TYPE_CHANNEL, A_ARG_TYPE_INSTANCE_ID as RC_INSTANCE_ID, MUTE, VOLUME,
};
use crate::connectionmanager::variables::{
A_ARG_TYPE_AVTRANSPORTID, A_ARG_TYPE_CONNECTIONID, A_ARG_TYPE_CONNECTIONSTATUS,
A_ARG_TYPE_DIRECTION, A_ARG_TYPE_PROTOCOLINFO, A_ARG_TYPE_RCSID, CURRENTCONNECTIONIDS,
SINKPROTOCOLINFO, SOURCEPROTOCOLINFO,
};
#[derive(Error, Debug)]
pub enum FactoryError {
#[error("Failed to add service to device: {0}")]
ServiceError(String),
#[error("Failed to add action to service: {0}")]
ActionError(String),
#[error("Failed to add variable to service: {0}")]
VariableError(String),
}
/// Factory pour créer des Device UPnP MediaRenderer avec un pipeline audio serveur
pub struct MediaRendererFactory;
impl MediaRendererFactory {
pub fn create_device_with_pipeline(
device_name: &str,
device_type: &str,
friendly_name_suffix: &str,
pipeline: PipelineHandle,
state: SharedState,
stream_url_base: &str,
) -> Result<Device, FactoryError> {
let avtransport = Self::build_avtransport(pipeline.clone(), state.clone(), device_name, stream_url_base)?;
let renderingcontrol = Self::build_renderingcontrol(state.clone())?;
let connectionmanager = Self::build_connectionmanager()?;
let device = Device::new_from_config(
device_name.to_string(),
device_type.to_string(),
friendly_name_suffix.to_string(),
);
device
.add_service(Arc::new(avtransport))
.map_err(|e| FactoryError::ServiceError(format!("{:?}", e)))?;
device
.add_service(Arc::new(renderingcontrol))
.map_err(|e| FactoryError::ServiceError(format!("{:?}", e)))?;
device
.add_service(Arc::new(connectionmanager))
.map_err(|e| FactoryError::ServiceError(format!("{:?}", e)))?;
Ok(device)
}
fn build_avtransport(
pipeline: PipelineHandle,
state: SharedState,
instance_id: &str,
stream_url_base: &str,
) -> Result<Service, FactoryError> {
let mut svc = Service::new("AVTransport".to_string());
add_var(&mut svc, &AVT_INSTANCE_ID)?;
add_var(&mut svc, &A_ARG_TYPE_PLAY_SPEED)?;
add_var(&mut svc, &A_ARG_TYPE_SEEKMODE)?;
add_var(&mut svc, &ABSOLUTETIMEPOSITION)?;
add_var(&mut svc, &AVTRANSPORTNEXTURI)?;
add_var(&mut svc, &AVTRANSPORTNEXTURIMETADATA)?;
add_var(&mut svc, &AVTRANSPORTURI)?;
add_var(&mut svc, &AVTRANSPORTURIMETADATA)?;
add_var(&mut svc, &CURRENTMEDIADURATION)?;
add_var(&mut svc, &CURRENTPLAYMODE)?;
add_var(&mut svc, &CURRENTTRACK)?;
add_var(&mut svc, &CURRENTTRACKDURATION)?;
add_var(&mut svc, &CURRENTTRACKMETADATA)?;
add_var(&mut svc, &CURRENTTRACKURI)?;
add_var(&mut svc, &NUMBEROFTRACKS)?;
add_var(&mut svc, &PLAYBACKSTORAGEMEDIUM)?;
add_var(&mut svc, &POSSIBLEPLAYBACKSTORAGEMEDIA)?;
add_var(&mut svc, &RELATIVETIMEPOSITION)?;
add_var(&mut svc, &SEEKMODE)?;
add_var(&mut svc, &TRANSPORTPLAYSPEED)?;
add_var(&mut svc, &TRANSPORTSTATE)?;
add_var(&mut svc, &TRANSPORTSTATUS)?;
let mut play = Action::new("Play".to_string());
add_arg_in(&mut play, "InstanceID", &AVT_INSTANCE_ID)?;
add_arg_in(&mut play, "Speed", &TRANSPORTPLAYSPEED)?;
play.set_handler(handlers::play_handler(
pipeline.clone(),
state.clone(),
instance_id.to_string(),
stream_url_base.to_string(),
));
add_action(&mut svc, Arc::new(play))?;
let mut stop = Action::new("Stop".to_string());
add_arg_in(&mut stop, "InstanceID", &AVT_INSTANCE_ID)?;
stop.set_handler(handlers::stop_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(stop))?;
let mut pause = Action::new("Pause".to_string());
add_arg_in(&mut pause, "InstanceID", &AVT_INSTANCE_ID)?;
pause.set_handler(handlers::pause_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(pause))?;
let mut next = Action::new("Next".to_string());
add_arg_in(&mut next, "InstanceID", &AVT_INSTANCE_ID)?;
next.set_handler(handlers::next_handler(pipeline.clone()));
add_action(&mut svc, Arc::new(next))?;
let mut previous = Action::new("Previous".to_string());
add_arg_in(&mut previous, "InstanceID", &AVT_INSTANCE_ID)?;
previous.set_handler(handlers::previous_handler(pipeline.clone()));
add_action(&mut svc, Arc::new(previous))?;
let mut seek = Action::new("Seek".to_string());
add_arg_in(&mut seek, "InstanceID", &AVT_INSTANCE_ID)?;
add_arg_in(&mut seek, "Unit", &A_ARG_TYPE_SEEKMODE)?;
add_arg_in(&mut seek, "Target", &SEEKMODE)?;
seek.set_handler(handlers::seek_handler(pipeline.clone()));
add_action(&mut svc, Arc::new(seek))?;
let mut set_uri = Action::new("SetAVTransportURI".to_string());
add_arg_in(&mut set_uri, "InstanceID", &AVT_INSTANCE_ID)?;
add_arg_in(&mut set_uri, "CurrentURI", &AVTRANSPORTURI)?;
add_arg_in(&mut set_uri, "CurrentURIMetaData", &AVTRANSPORTURIMETADATA)?;
set_uri.set_handler(handlers::set_uri_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(set_uri))?;
let mut set_next_uri = Action::new("SetNextAVTransportURI".to_string());
add_arg_in(&mut set_next_uri, "InstanceID", &AVT_INSTANCE_ID)?;
add_arg_in(&mut set_next_uri, "NextURI", &AVTRANSPORTNEXTURI)?;
add_arg_in(
&mut set_next_uri,
"NextURIMetaData",
&AVTRANSPORTNEXTURIMETADATA,
)?;
set_next_uri.set_handler(handlers::set_next_uri_handler(
pipeline.clone(),
state.clone(),
));
add_action(&mut svc, Arc::new(set_next_uri))?;
let mut get_pos = Action::new("GetPositionInfo".to_string());
add_arg_in(&mut get_pos, "InstanceID", &AVT_INSTANCE_ID)?;
add_arg_out(&mut get_pos, "Track", &CURRENTTRACK)?;
add_arg_out(&mut get_pos, "TrackDuration", &CURRENTTRACKDURATION)?;
add_arg_out(&mut get_pos, "TrackURI", &CURRENTTRACKURI)?;
add_arg_out(&mut get_pos, "TrackMetaData", &CURRENTTRACKMETADATA)?;
add_arg_out(&mut get_pos, "RelTime", &RELATIVETIMEPOSITION)?;
add_arg_out(&mut get_pos, "AbsTime", &ABSOLUTETIMEPOSITION)?;
get_pos.set_stateful(false);
get_pos.set_handler(handlers::get_position_info_handler(state.clone()));
add_action(&mut svc, Arc::new(get_pos))?;
let mut get_info = Action::new("GetTransportInfo".to_string());
add_arg_in(&mut get_info, "InstanceID", &AVT_INSTANCE_ID)?;
add_arg_out(&mut get_info, "CurrentTransportState", &TRANSPORTSTATE)?;
add_arg_out(&mut get_info, "CurrentTransportStatus", &TRANSPORTSTATUS)?;
add_arg_out(&mut get_info, "CurrentSpeed", &TRANSPORTPLAYSPEED)?;
get_info.set_stateful(false);
get_info.set_handler(handlers::get_transport_info_handler(state.clone()));
add_action(&mut svc, Arc::new(get_info))?;
let mut get_media = Action::new("GetMediaInfo".to_string());
add_arg_in(&mut get_media, "InstanceID", &AVT_INSTANCE_ID)?;
add_arg_out(&mut get_media, "NrTracks", &NUMBEROFTRACKS)?;
add_arg_out(&mut get_media, "CurrentURI", &AVTRANSPORTURI)?;
add_arg_out(
&mut get_media,
"CurrentURIMetaData",
&AVTRANSPORTURIMETADATA,
)?;
add_arg_out(&mut get_media, "NextURI", &AVTRANSPORTNEXTURI)?;
add_arg_out(
&mut get_media,
"NextURIMetaData",
&AVTRANSPORTNEXTURIMETADATA,
)?;
get_media.set_stateful(false);
get_media.set_handler(handlers::get_media_info_handler(state.clone()));
add_action(&mut svc, Arc::new(get_media))?;
let mut get_settings = Action::new("GetTransportSettings".to_string());
add_arg_in(&mut get_settings, "InstanceID", &AVT_INSTANCE_ID)?;
add_action(&mut svc, Arc::new(get_settings))?;
let mut get_caps = Action::new("GetDeviceCapabilities".to_string());
add_arg_in(&mut get_caps, "InstanceID", &AVT_INSTANCE_ID)?;
add_action(&mut svc, Arc::new(get_caps))?;
let mut get_actions = Action::new("GetCurrentTransportActions".to_string());
add_arg_in(&mut get_actions, "InstanceID", &AVT_INSTANCE_ID)?;
add_action(&mut svc, Arc::new(get_actions))?;
Ok(svc)
}
fn build_renderingcontrol(state: SharedState) -> Result<Service, FactoryError> {
let mut svc = Service::new("RenderingControl".to_string());
add_var(&mut svc, &RC_INSTANCE_ID)?;
add_var(&mut svc, &A_ARG_TYPE_CHANNEL)?;
add_var(&mut svc, &VOLUME)?;
add_var(&mut svc, &MUTE)?;
let mut set_vol = Action::new("SetVolume".to_string());
add_arg_in(&mut set_vol, "InstanceID", &RC_INSTANCE_ID)?;
add_arg_in(&mut set_vol, "Channel", &A_ARG_TYPE_CHANNEL)?;
add_arg_in(&mut set_vol, "DesiredVolume", &VOLUME)?;
set_vol.set_handler(handlers::set_volume_handler(state.clone()));
add_action(&mut svc, Arc::new(set_vol))?;
let mut get_vol = Action::new("GetVolume".to_string());
add_arg_in(&mut get_vol, "InstanceID", &RC_INSTANCE_ID)?;
add_arg_in(&mut get_vol, "Channel", &A_ARG_TYPE_CHANNEL)?;
add_arg_out(&mut get_vol, "CurrentVolume", &VOLUME)?;
get_vol.set_stateful(false);
get_vol.set_handler(handlers::get_volume_handler(state.clone()));
add_action(&mut svc, Arc::new(get_vol))?;
let mut set_mute = Action::new("SetMute".to_string());
add_arg_in(&mut set_mute, "InstanceID", &RC_INSTANCE_ID)?;
add_arg_in(&mut set_mute, "Channel", &A_ARG_TYPE_CHANNEL)?;
add_arg_in(&mut set_mute, "DesiredMute", &MUTE)?;
set_mute.set_handler(handlers::set_mute_handler(state.clone()));
add_action(&mut svc, Arc::new(set_mute))?;
let mut get_mute = Action::new("GetMute".to_string());
add_arg_in(&mut get_mute, "InstanceID", &RC_INSTANCE_ID)?;
add_arg_in(&mut get_mute, "Channel", &A_ARG_TYPE_CHANNEL)?;
add_arg_out(&mut get_mute, "CurrentMute", &MUTE)?;
get_mute.set_stateful(false);
get_mute.set_handler(handlers::get_mute_handler(state.clone()));
add_action(&mut svc, Arc::new(get_mute))?;
Ok(svc)
}
fn build_connectionmanager() -> Result<Service, FactoryError> {
let mut svc = Service::new("ConnectionManager".to_string());
add_var(&mut svc, &A_ARG_TYPE_CONNECTIONID)?;
add_var(&mut svc, &A_ARG_TYPE_CONNECTIONSTATUS)?;
add_var(&mut svc, &A_ARG_TYPE_DIRECTION)?;
add_var(&mut svc, &A_ARG_TYPE_PROTOCOLINFO)?;
add_var(&mut svc, &A_ARG_TYPE_RCSID)?;
add_var(&mut svc, &A_ARG_TYPE_AVTRANSPORTID)?;
add_var(&mut svc, &CURRENTCONNECTIONIDS)?;
add_var(&mut svc, &SINKPROTOCOLINFO)?;
add_var(&mut svc, &SOURCEPROTOCOLINFO)?;
let mut get_proto = Action::new("GetProtocolInfo".to_string());
add_arg_out(&mut get_proto, "Source", &SOURCEPROTOCOLINFO)?;
add_arg_out(&mut get_proto, "Sink", &SINKPROTOCOLINFO)?;
get_proto.set_stateful(false);
get_proto.set_handler(handlers::get_protocol_info_handler());
add_action(&mut svc, Arc::new(get_proto))?;
let mut get_ids = Action::new("GetCurrentConnectionIDs".to_string());
add_arg_out(&mut get_ids, "ConnectionIDs", &CURRENTCONNECTIONIDS)?;
add_action(&mut svc, Arc::new(get_ids))?;
let mut get_conn = Action::new("GetCurrentConnectionInfo".to_string());
add_arg_in(&mut get_conn, "ConnectionID", &A_ARG_TYPE_CONNECTIONID)?;
add_arg_out(&mut get_conn, "RcsID", &A_ARG_TYPE_RCSID)?;
add_arg_out(&mut get_conn, "AVTransportID", &A_ARG_TYPE_AVTRANSPORTID)?;
add_arg_out(&mut get_conn, "ProtocolInfo", &A_ARG_TYPE_PROTOCOLINFO)?;
add_arg_out(
&mut get_conn,
"PeerConnectionManager",
&A_ARG_TYPE_PROTOCOLINFO,
)?;
add_arg_out(&mut get_conn, "PeerConnectionID", &A_ARG_TYPE_CONNECTIONID)?;
add_arg_out(&mut get_conn, "Direction", &A_ARG_TYPE_DIRECTION)?;
add_arg_out(&mut get_conn, "Status", &A_ARG_TYPE_CONNECTIONSTATUS)?;
add_action(&mut svc, Arc::new(get_conn))?;
Ok(svc)
}
}

View File

@@ -1,11 +1,12 @@
//! État partagé du renderer (backend ↔ pipeline)
use parking_lot::RwLock;
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,
@@ -17,6 +18,17 @@ pub struct RendererState {
pub duration: Option<String>,
pub volume: u16,
pub mute: bool,
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 {
@@ -31,9 +43,9 @@ impl Default for RendererState {
duration: None,
volume: 100,
mute: false,
pending_commands: VecDeque::new(),
}
}
}
/// Alias pour l'état partagé
pub type SharedState = Arc<RwLock<RendererState>>;

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoparadise"
version = "0.1.0"
edition = "2021"
edition = "2024"
authors = ["PMOMusic Contributors"]
description = "Rust client for Radio Paradise streaming service"
license = "MIT OR Apache-2.0"

View File

@@ -13,7 +13,7 @@ use pmoaudio::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
pipeline::{send_to_children, send_to_children_with_timing, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioPipelineNode, AudioSegment, SyncMarker, I24,
AudioPipelineNode, AudioSegment, SyncMarker, I24, StreamType,
};
use pmoflac::decode_audio_stream;
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
@@ -243,6 +243,7 @@ impl RadioParadiseStreamSourceLogic {
let track_boundary = AudioSegment::new_track_boundary(
*order, 0.0, // timestamp = 0 au début du stream
metadata,
StreamType::Continuous,
);
self.send_to_children(output, track_boundary).await?;
song_index = 1;
@@ -336,7 +337,7 @@ impl RadioParadiseStreamSourceLogic {
let metadata = song_to_metadata(song, block).await;
let timestamp_sec = total_samples as f64 / sample_rate as f64;
let track_boundary =
AudioSegment::new_track_boundary(*order, timestamp_sec, metadata);
AudioSegment::new_track_boundary(*order, timestamp_sec, metadata, StreamType::Continuous);
self.send_to_children(output, track_boundary).await?;
// Passer à la song suivante

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoplaylist"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
# Caches PMO

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoqobuz"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
regex = "1.12"

View File

@@ -252,7 +252,7 @@ impl QobuzSource {
.await
.ok();
if let (Some(ref audio_pk), Some(ref cover_pk)) = (&cached_audio_pk, &cached_cover_pk) {
if let (Some(audio_pk), Some(cover_pk)) = (&cached_audio_pk, &cached_cover_pk) {
let _ =
self.inner
.cache_manager

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoradiofrance"
version = "0.1.0"
edition = "2021"
edition = "2024"
authors = ["PMOMusic Contributors"]
description = "Rust client for Radio France streaming services"
license = "MIT OR Apache-2.0"

View File

@@ -20,8 +20,8 @@ tracing-subscriber = { workspace = true }
futures = "0.3"
async-stream = "0.3.6"
axum-server = "0.7.2"
axum-embed = "0.1.0"
rust-embed = "8.7.2"
mime_guess = "2"
utoipa = { version = "5.4.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
once_cell = "1.19"

View File

@@ -71,6 +71,7 @@
pub mod config_ext;
pub mod logs;
pub mod server;
mod serve_embed;
pub use config_ext::ConfigExt;
pub use logs::{

View File

@@ -0,0 +1,90 @@
//! Remplacement minimal de `axum_embed` compatible avec axum 0.8.
//!
//! Implémente un service Tower qui sert des fichiers embarqués via `rust_embed`,
//! avec support optionnel du mode SPA (fallback vers index.html).
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use axum::response::{IntoResponse, Response};
use rust_embed::RustEmbed;
use std::convert::Infallible;
use std::marker::PhantomData;
use std::task::{Context, Poll};
use tower::Service;
/// Service Tower servant des fichiers embarqués via `RustEmbed`.
///
/// - Mode normal (`new`): retourne 404 si le fichier n'existe pas.
/// - Mode SPA (`with_spa_fallback`): retourne le fichier de fallback (200) si le fichier n'existe pas.
#[derive(Clone)]
pub struct ServeEmbed<E> {
spa_fallback: Option<String>,
_phantom: PhantomData<E>,
}
impl<E: RustEmbed> ServeEmbed<E> {
/// Mode normal : 404 pour les fichiers manquants.
pub fn new() -> Self {
Self {
spa_fallback: None,
_phantom: PhantomData,
}
}
/// Mode SPA : sert `fallback_file` (avec 200) pour les fichiers manquants.
pub fn with_spa_fallback(fallback_file: String) -> Self {
Self {
spa_fallback: Some(fallback_file),
_phantom: PhantomData,
}
}
fn serve(path: &str) -> Option<Response> {
let path = path.trim_start_matches('/');
// Essaie le chemin exact, puis index.html pour les répertoires
let candidates: &[&str] = if path.is_empty() || path.ends_with('/') {
&[&format!("{}index.html", path), path]
} else {
&[path]
};
for candidate in candidates {
if let Some(content) = E::get(candidate) {
let mime = mime_guess::from_path(candidate).first_or_octet_stream();
return Some(
(
[(header::CONTENT_TYPE, mime.as_ref())],
content.data.into_owned(),
)
.into_response(),
);
}
}
None
}
}
impl<E> Service<Request<Body>> for ServeEmbed<E>
where
E: RustEmbed + Clone + Send + Sync + 'static,
{
type Response = Response;
type Error = Infallible;
type Future = std::future::Ready<Result<Response, Infallible>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Infallible>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
let path = req.uri().path();
let response = Self::serve(path).unwrap_or_else(|| {
if let Some(ref fallback) = self.spa_fallback {
Self::serve(fallback).unwrap_or_else(|| StatusCode::NOT_FOUND.into_response())
} else {
StatusCode::NOT_FOUND.into_response()
}
});
std::future::ready(Ok(response))
}
}

View File

@@ -19,7 +19,7 @@ use axum::handler::Handler;
use axum::response::Redirect;
use axum::routing::{any, get, post};
use axum::{Json, Router};
use axum_embed::ServeEmbed;
use crate::serve_embed::ServeEmbed;
use pmoconfig::get_config;
use rust_embed::RustEmbed;
use serde::Serialize;
@@ -340,11 +340,7 @@ impl Server {
where
E: RustEmbed + Clone + Send + Sync + 'static,
{
let serve = ServeEmbed::<E>::with_parameters(
Some("index.html".to_string()),
axum_embed::FallbackBehavior::Ok,
Some("index.html".to_string()),
);
let serve = ServeEmbed::<E>::with_spa_fallback("index.html".to_string());
let mut r = self.router.write().await;

View File

@@ -1,7 +1,7 @@
[package]
name = "pmosource"
version = "0.1.0"
edition = "2021"
edition = "2024"
authors = ["PMOMusic Contributors"]
description = "Common traits and types for PMOMusic sources"
license = "MIT OR Apache-2.0"

View File

@@ -177,110 +177,27 @@ pub type ActionHandler = Arc<dyn Fn(ActionData) -> ActionFuture + Send + Sync>;
/// Macro pour créer facilement un ActionHandler.
///
/// Cette macro simplifie la création d'handlers asynchrones en cachant
/// la complexité de `Arc`, `Box::pin`, et `async move`.
/// Deux formes disponibles :
///
/// # Syntaxe
/// ## Forme simple (sans captures)
///
/// ```ignore
/// action_handler!(|data| {
/// // votre logique async avec ActionData
/// // Modifier les données et les retourner
/// Ok(data)
/// })
/// action_handler!(|data| { Ok(data) })
/// ```
///
/// # Arguments
/// ## Forme avec captures (clonées automatiquement à chaque appel)
///
/// - `data` : Paramètre de type [`ActionData`] - HashMap contenant les valeurs des arguments
/// - Le corps du bloc peut contenir du code asynchrone (`.await`)
///
/// # Type de retour
///
/// La macro retourne un [`ActionHandler`] prêt à l'emploi.
///
/// # Examples
///
/// ## Exemple 1 : Handler simple (retourne les données telles quelles)
/// Pour capturer un état partagé ou un handle, utilisez `captures(...)`.
/// Chaque variable listée est clonée une fois par invocation du handler,
/// ce qui satisfait la contrainte `Fn` (et non `FnOnce`).
///
/// ```ignore
/// use pmoupnp::action_handler;
///
/// let handler = action_handler!(|data| {
/// Ok(data) // Retourne les données non modifiées
/// });
/// ```
///
/// ## Exemple 2 : Handler qui calcule et modifie les données
///
/// ```ignore
/// use pmoupnp::{action_handler, get, set};
/// use pmoupnp::actions::ActionError;
///
/// let handler = action_handler!(|mut data| {
/// // Extraire les valeurs avec la macro get!
/// let celsius: f64 = get!(data, "Celsius", f64);
///
/// // Calculer
/// let fahrenheit = celsius * 9.0 / 5.0 + 32.0;
///
/// // Insérer avec la macro set!
/// set!(data, "Fahrenheit", fahrenheit);
///
/// Ok(data) // Retourner les données modifiées
/// });
/// ```
///
/// ## Exemple 3 : Handler avec logique métier asynchrone
///
/// ```ignore
/// use pmoupnp::{action_handler, get, set};
/// use pmoupnp::actions::ActionError;
///
/// let handler = action_handler!(|mut data| {
/// // Lire l'URI
/// let uri: String = get!(data, "URI", String);
///
/// // Appel asynchrone à un service externe
/// let metadata = external_service::fetch_metadata(&uri).await
/// .map_err(|e| ActionError::ExternalError(e.to_string()))?;
///
/// // Mettre à jour les données
/// set!(data, "Metadata", metadata);
///
/// Ok(data)
/// });
/// ```
///
/// ## Exemple 4 : Handler avec capture de contexte
///
/// ```ignore
/// use pmoupnp::{action_handler, get, set};
/// use pmoupnp::actions::ActionError;
/// use std::sync::Arc;
/// use tokio::sync::Mutex;
///
/// // Contexte partagé
/// let player_state = Arc::new(Mutex::new(PlayerState::Stopped));
///
/// let handler = action_handler!(|mut data| {
/// // Vérifier l'état
/// {
/// let state = player_state.lock().await;
/// if *state == PlayerState::Error {
/// return Err(ActionError::InvalidState("Player in error state".into()));
/// }
/// }
///
/// // Modifier l'état
/// {
/// let mut state = player_state.lock().await;
/// *state = PlayerState::Playing;
/// }
///
/// // Mettre à jour les données
/// set!(data, "TransportState", "PLAYING".to_string());
/// let state: SharedState = ...;
/// let pipeline: PipelineHandle = ...;
///
/// let handler = action_handler!(captures(state, pipeline) |mut data| {
/// pipeline.send(PipelineControl::Play).await;
/// state.write().playback_state = PlaybackState::Playing;
/// Ok(data)
/// });
/// ```
@@ -288,14 +205,41 @@ pub type ActionHandler = Arc<dyn Fn(ActionData) -> ActionFuture + Send + Sync>;
/// # Notes d'implémentation
///
/// - Le bloc est automatiquement wrappé dans `async move`
/// - Les captures de variables sont déplacées (`move`)
/// - Avec `captures(...)`, chaque variable capturée doit implémenter `Clone`
/// - Le résultat est automatiquement boxé et arcé
/// - Utilisez les macros `get!` et `set!` pour manipuler facilement les données
#[macro_export]
macro_rules! action_handler {
// ── Formes simples (sans captures externes) ──────────────────────────────
(|$data:ident| $body:block) => {
std::sync::Arc::new(|$data: $crate::actions::ActionData| {
Box::pin(async move $body)
})
};
(|mut $data:ident| $body:block) => {
std::sync::Arc::new(|mut $data: $crate::actions::ActionData| {
Box::pin(async move $body)
})
};
// ── Formes avec captures (clonées automatiquement à chaque appel) ────────
//
// Chaque variable listée dans captures(...) est clonée avant chaque appel,
// ce qui satisfait la contrainte `Fn` (vs `FnOnce`).
// Les variables capturées doivent implémenter `Clone + Send + Sync + 'static`.
(captures($($cap:ident),+ $(,)?) |$data:ident| $body:block) => {
std::sync::Arc::new(move |$data: $crate::actions::ActionData| {
$(let $cap = $cap.clone();)+
Box::pin(async move $body)
})
};
(captures($($cap:ident),+ $(,)?) |mut $data:ident| $body:block) => {
std::sync::Arc::new(move |mut $data: $crate::actions::ActionData| {
$(let $cap = $cap.clone();)+
Box::pin(async move $body)
})
};
}

View File

@@ -1,32 +1,24 @@
[package]
name = "pmowebrenderer"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
pmoupnp = { path = "../pmoupnp" }
pmomediarenderer = { path = "../pmomediarenderer" }
pmoserver = { path = "../pmoserver", optional = true }
pmocontrol = { path = "../pmocontrol", optional = true }
pmoconfig = { path = "../pmoconfig" }
# Audio pipeline (toujours actif — c'est le cœur du renderer)
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["http-stream"] }
pmoaudio = { path = "../pmoaudio" }
pmoflac = { path = "../pmoflac" }
pmometadata = { path = "../pmometadata" }
# Async runtime
tokio = { workspace = true, features = ["full"] }
tokio-util = { workspace = true }
async-trait = { workspace = true }
# HTTP
axum = { workspace = true }
axum-extra = { version = "0.9", features = ["typed-header"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }
axum = "0.8.4"
axum-extra = "0.12"
tower-http = "0.6"
futures = "0.3"
reqwest = { workspace = true, features = ["stream"] }
bytes = "1.0"
# Serialization
@@ -34,14 +26,9 @@ serde = { workspace = true }
serde_json = { workspace = true }
# Utilities
uuid = { workspace = true, features = ["v4", "serde"] }
parking_lot = "0.12"
thiserror = { workspace = true }
tracing = { workspace = true }
pmodidl = { path = "../pmodidl" }
pmoutils = { version = "0.1.2", registry = "pmo" }
[features]
default = []
pmoserver = ["dep:pmoserver", "dep:pmocontrol"]
pmoserver = ["dep:pmoserver", "dep:pmocontrol", "pmomediarenderer/pmoserver"]

View File

@@ -0,0 +1,23 @@
use pmomediarenderer::{
DeviceAdapter, DeviceCommand, DeviceStateReport, SharedState,
};
pub struct BrowserAdapter {
pub state: SharedState,
}
impl BrowserAdapter {
pub fn new(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

@@ -7,17 +7,23 @@ use std::sync::Arc;
use async_trait::async_trait;
#[cfg(feature = "pmoserver")]
use axum::{Router, routing::{delete, get, post}};
use axum::{
Router,
routing::{delete, get, post},
};
#[cfg(feature = "pmoserver")]
use pmocontrol::ControlPoint;
#[cfg(feature = "pmoserver")]
use crate::error::WebRendererError;
use pmomediarenderer::MediaRendererError;
#[cfg(feature = "pmoserver")]
use crate::register::{position_update_handler, register_handler, unregister_handler};
use crate::register::{
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;
use pmomediarenderer::MediaRendererRegistry;
#[cfg(feature = "pmoserver")]
use crate::stream::stream_handler;
@@ -28,7 +34,7 @@ pub trait WebRendererExt {
async fn register_web_renderer(
&mut self,
control_point: Arc<ControlPoint>,
) -> Result<(), WebRendererError>;
) -> Result<(), MediaRendererError>;
}
#[cfg(feature = "pmoserver")]
@@ -37,8 +43,8 @@ impl WebRendererExt for pmoserver::Server {
async fn register_web_renderer(
&mut self,
control_point: Arc<ControlPoint>,
) -> Result<(), WebRendererError> {
let registry = Arc::new(RendererRegistry::new(control_point));
) -> Result<(), MediaRendererError> {
let registry = Arc::new(MediaRendererRegistry::new(control_point));
// POST /api/webrenderer/register
self.add_post_handler_with_state(
@@ -48,19 +54,30 @@ impl WebRendererExt for pmoserver::Server {
)
.await;
// GET /api/webrenderer/{id}/stream + DELETE /api/webrenderer/{id} + POST /api/webrenderer/{id}/position
// GET /api/webrenderer/{id}/stream + DELETE /api/webrenderer/{id}
// POST /api/webrenderer/{id}/play -> tell player to start streaming
// POST /api/webrenderer/{id}/pause, /set_uri, /report
// GET /api/webrenderer/{id}/command, /position
let dynamic_router = Router::new()
.route("/{id}/stream", get(stream_handler))
.route("/{id}/position", post(position_update_handler))
.route("/{id}", delete(unregister_handler))
.route("/{id}/play", post(play_handler))
.route("/{id}/pause", post(pause_handler))
.route("/{id}/set_uri", post(set_uri_handler))
.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;
tracing::info!("WebRenderer server-side streaming endpoints registered");
tracing::info!(" POST /api/webrenderer/register");
tracing::info!(" GET /api/webrenderer/{{id}}/stream");
tracing::info!(" POST /api/webrenderer/{{id}}/position");
tracing::info!(" DELETE /api/webrenderer/{{id}}");
tracing::info!(" GET /api/webrenderer/{{id}}/nowplaying");
tracing::info!(" GET /api/webrenderer/{{id}}/state");
Ok(())
}
}

View File

@@ -1,291 +0,0 @@
//! Action handlers SOAP → Pipeline pour le WebRenderer serveur
//!
//! Chaque handler bridge une action UPnP vers une commande `PipelineControl`
//! envoyée au pipeline audio serveur, ou lit l'état partagé pour les requêtes GET.
use std::sync::Arc;
use pmodidl::DIDLLite;
use pmoupnp::actions::{ActionData, ActionError, ActionHandler, get_value};
use pmoupnp::{get, set};
use pmodidl::ToXmlElement;
use crate::messages::PlaybackState;
use crate::pipeline::{PipelineControl, PipelineHandle, upnp_time_to_seconds};
use crate::state::SharedState;
type ActionFuture =
std::pin::Pin<Box<dyn std::future::Future<Output = Result<ActionData, ActionError>> + Send>>;
// ─── AVTransport Handlers ───────────────────────────────────────────────────
pub fn play_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
// Ne pas écrire Playing ici : c'est stream_source qui le fera
// une fois que les premiers bytes FLAC ont été produits.
// Écrire Transitioning pour signaler que la lecture va démarrer.
state.write().playback_state = PlaybackState::Transitioning;
pipeline.send(PipelineControl::Play).await;
Ok(data)
})
})
}
pub fn stop_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
pipeline.send(PipelineControl::Stop).await;
state.write().playback_state = PlaybackState::Stopped;
Ok(data)
})
})
}
pub fn pause_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
pipeline.send(PipelineControl::Pause).await;
state.write().playback_state = PlaybackState::Paused;
Ok(data)
})
})
}
pub fn next_handler(pipeline: PipelineHandle) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let pipeline = pipeline.clone();
Box::pin(async move {
pipeline.send(PipelineControl::Play).await;
Ok(data)
})
})
}
pub fn previous_handler(pipeline: PipelineHandle) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let pipeline = pipeline.clone();
Box::pin(async move {
pipeline.send(PipelineControl::Play).await;
Ok(data)
})
})
}
pub fn seek_handler(pipeline: PipelineHandle) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let pipeline = pipeline.clone();
Box::pin(async move {
let target: String = get!(&data, "Target", String);
let pos_sec = upnp_time_to_seconds(&target);
pipeline.send(PipelineControl::Seek(pos_sec)).await;
Ok(data)
})
})
}
pub fn set_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
let uri: String = get!(&data, "CurrentURI", String);
let metadata: String = get_value::<String>(&data, "CurrentURIMetaData")
.or_else(|_| {
get_value::<DIDLLite>(&data, "CurrentURIMetaData")
.map(|didl| didl.to_xml())
})
.unwrap_or_default();
// Envoyer l'URI au pipeline serveur (remplace l'envoi WebSocket)
pipeline.send(PipelineControl::LoadUri(uri.clone())).await;
{
let mut s = state.write();
s.current_uri = Some(uri);
s.current_metadata = Some(metadata);
s.playback_state = PlaybackState::Transitioning;
}
Ok(data)
})
})
}
pub fn set_next_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let pipeline = pipeline.clone();
let state = state.clone();
Box::pin(async move {
let uri: String = get!(&data, "NextURI", String);
let metadata: String = get_value::<String>(&data, "NextURIMetaData")
.or_else(|_| {
get_value::<DIDLLite>(&data, "NextURIMetaData")
.map(|didl| didl.to_xml())
})
.unwrap_or_default();
pipeline.send(PipelineControl::LoadNextUri(uri.clone())).await;
{
let mut s = state.write();
s.next_uri = Some(uri);
s.next_metadata = Some(metadata);
}
Ok(data)
})
})
}
pub fn get_position_info_handler(state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let state = state.clone();
Box::pin(async move {
let mut data = data;
let s = state.read();
set!(
&mut data,
"Track",
if s.current_uri.is_some() { 1u32 } else { 0u32 }
);
set!(
&mut data,
"TrackDuration",
s.duration.clone().unwrap_or_else(|| "00:00:00".to_string())
);
set!(
&mut data,
"TrackURI",
s.current_uri.clone().unwrap_or_default()
);
set!(
&mut data,
"TrackMetaData",
s.current_metadata.clone().unwrap_or_default()
);
set!(
&mut data,
"RelTime",
s.position.clone().unwrap_or_else(|| "00:00:00".to_string())
);
set!(
&mut data,
"AbsTime",
s.position.clone().unwrap_or_else(|| "00:00:00".to_string())
);
Ok(data)
})
})
}
pub fn get_transport_info_handler(state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let state = state.clone();
Box::pin(async move {
let mut data = data;
let s = state.read();
tracing::info!("[WebRenderer] GetTransportInfo: state={:?}", s.playback_state);
let transport_state = match s.playback_state {
PlaybackState::Stopped => "STOPPED",
PlaybackState::Playing => "PLAYING",
PlaybackState::Paused => "PAUSED_PLAYBACK",
PlaybackState::Transitioning => "TRANSITIONING",
};
set!(&mut data, "CurrentTransportState", transport_state.to_string());
set!(&mut data, "CurrentTransportStatus", "OK".to_string());
set!(&mut data, "CurrentSpeed", "1".to_string());
Ok(data)
})
})
}
pub fn get_media_info_handler(state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let state = state.clone();
Box::pin(async move {
let mut data = data;
let s = state.read();
set!(
&mut data,
"NrTracks",
if s.current_uri.is_some() { 1u32 } else { 0u32 }
);
set!(&mut data, "CurrentURI", s.current_uri.clone().unwrap_or_default());
set!(&mut data, "CurrentURIMetaData", s.current_metadata.clone().unwrap_or_default());
set!(&mut data, "NextURI", s.next_uri.clone().unwrap_or_default());
set!(&mut data, "NextURIMetaData", s.next_metadata.clone().unwrap_or_default());
Ok(data)
})
})
}
// ─── RenderingControl Handlers ──────────────────────────────────────────────
pub fn set_volume_handler(_pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let state = state.clone();
Box::pin(async move {
let volume: u16 = get!(&data, "DesiredVolume", u16);
state.write().volume = volume;
Ok(data)
})
})
}
pub fn get_volume_handler(state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let state = state.clone();
Box::pin(async move {
let mut data = data;
let volume = state.read().volume;
set!(&mut data, "CurrentVolume", volume);
Ok(data)
})
})
}
pub fn set_mute_handler(_pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let state = state.clone();
Box::pin(async move {
let mute: bool = get!(&data, "DesiredMute", bool);
state.write().mute = mute;
Ok(data)
})
})
}
pub fn get_mute_handler(state: SharedState) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let state = state.clone();
Box::pin(async move {
let mut data = data;
let mute = state.read().mute;
set!(&mut data, "CurrentMute", mute);
Ok(data)
})
})
}
// ─── ConnectionManager Handlers ─────────────────────────────────────────────
pub fn get_protocol_info_handler() -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
Box::pin(async move {
let mut data = data;
set!(&mut data, "Source", String::new());
// Serveur-side streaming : on produit du FLAC uniquement
set!(
&mut data,
"Sink",
"http-get:*:audio/flac:*,http-get:*:audio/x-flac:*".to_string()
);
Ok(data)
})
})
}

View File

@@ -5,25 +5,14 @@
//! - 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 error;
mod handlers;
mod messages;
mod pipeline;
mod adapter;
mod register;
mod registry;
mod renderer;
mod state;
mod stream;
#[cfg(feature = "pmoserver")]
mod config;
pub use error::WebRendererError;
pub use messages::PlaybackState;
pub use pipeline::{PipelineControl, PipelineHandle};
pub use registry::{RendererRegistry, WebRendererInstance};
pub use renderer::{FactoryError, WebRendererFactory};
pub use state::{RendererState, SharedState};
pub use adapter::BrowserAdapter;
#[cfg(feature = "pmoserver")]
pub use config::WebRendererExt;
pub use config::WebRendererExt;

View File

@@ -1,123 +0,0 @@
//! Messages WebSocket pour la communication Backend ↔ Navigateur
use serde::{Deserialize, Serialize};
/// Messages envoyés du Backend → Navigateur
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[allow(dead_code)]
pub enum ServerMessage {
SessionCreated {
token: String,
renderer_info: RendererInfo,
},
/// Envoyé après SessionCreated lors d'une reconnexion pour resynchroniser
/// l'état audio du navigateur (URI courante, état de lecture, etc.).
StateSync {
current_uri: Option<String>,
current_metadata: Option<String>,
next_uri: Option<String>,
next_metadata: Option<String>,
playback_state: PlaybackState,
position: Option<String>,
volume: u16,
mute: bool,
},
Command {
action: TransportAction,
#[serde(skip_serializing_if = "Option::is_none")]
params: Option<CommandParams>,
},
SetVolume {
volume: u16,
},
SetMute {
mute: bool,
},
Ping,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub enum TransportAction {
Play,
Pause,
Stop,
Seek,
SetUri,
SetNextUri,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<String>,
}
/// Messages envoyés du Navigateur → Backend
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[allow(dead_code)]
pub enum ClientMessage {
Init {
capabilities: BrowserCapabilities,
},
StateUpdate {
state: PlaybackState,
},
PositionUpdate {
position: String,
duration: String,
},
MetadataUpdate {
metadata: TrackMetadata,
},
VolumeUpdate {
volume: u16,
mute: bool,
},
/// Envoyé quand la piste courante se termine naturellement (gapless).
/// Le backend fait avancer current → next dans l'état partagé.
TrackEnded,
Pong,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserCapabilities {
/// Identifiant stable de l'instance navigateur (UUID stocké en localStorage).
/// Permet de réutiliser le même renderer UPnP après un reload de page.
pub instance_id: String,
pub user_agent: String,
pub supported_formats: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum PlaybackState {
Stopped,
Playing,
Paused,
Transitioning,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackMetadata {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub duration: Option<String>,
pub album_art_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RendererInfo {
pub udn: String,
pub friendly_name: String,
pub model_name: String,
pub description_url: String,
}

View File

@@ -5,14 +5,27 @@
use axum::{
extract::{Path, State},
http::StatusCode,
http::{StatusCode, header::HeaderMap},
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::registry::RendererRegistry;
use pmomediarenderer::PlaybackState;
use pmomediarenderer::PipelineControl;
use pmomediarenderer::{DeviceCommand, MediaRendererRegistry};
use crate::adapter::BrowserAdapter;
#[derive(Debug, serde::Deserialize)]
pub struct PlayerStateReport {
pub position_sec: Option<f64>,
pub duration_sec: Option<f64>,
pub state: Option<String>,
#[allow(dead_code)]
pub ready_state: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
@@ -24,11 +37,12 @@ pub struct RegisterRequest {
pub struct RegisterResponse {
pub stream_url: String,
pub udn: String,
pub should_play: bool,
}
/// POST /api/webrenderer/register
#[axum::debug_handler]
pub async fn register_handler(
State(registry): State<Arc<RendererRegistry>>,
State(registry): State<Arc<MediaRendererRegistry>>,
Json(req): Json<RegisterRequest>,
) -> impl IntoResponse {
tracing::info!(
@@ -38,17 +52,23 @@ pub async fn register_handler(
);
match registry
.register_or_reconnect(&req.instance_id, &req.user_agent)
.register_or_reconnect(
&req.instance_id,
"/api/webrenderer",
"PMOMusic WebRenderer/2.0",
|state| Arc::new(BrowserAdapter::new(state)),
)
.await
{
Ok((stream_url, udn)) => {
Ok((stream_url, udn, should_play)) => {
tracing::info!(
instance_id = %req.instance_id,
stream_url = %stream_url,
udn = %udn,
should_play = %should_play,
"WebRenderer: registered"
);
(StatusCode::OK, Json(RegisterResponse { stream_url, udn })).into_response()
(StatusCode::OK, Json(RegisterResponse { stream_url, udn, should_play })).into_response()
}
Err(e) => {
tracing::error!(
@@ -63,15 +83,13 @@ pub async fn register_handler(
#[derive(Debug, Deserialize)]
pub struct PositionUpdateRequest {
pub position_sec: f64,
pub _position_sec: f64,
pub duration_sec: Option<f64>,
}
/// POST /api/webrenderer/{id}/position
/// position_sec est ignoré (géré par PlayerEvent::Position côté serveur).
/// duration_sec est utilisé comme fallback si la source ne connaît pas la durée (flux radio).
#[axum::debug_handler]
pub async fn position_update_handler(
State(registry): State<Arc<RendererRegistry>>,
State(registry): State<Arc<MediaRendererRegistry>>,
Path(instance_id): Path<String>,
Json(req): Json<PositionUpdateRequest>,
) -> impl IntoResponse {
@@ -79,12 +97,201 @@ pub async fn position_update_handler(
StatusCode::NO_CONTENT
}
/// DELETE /api/webrenderer/{id}
#[axum::debug_handler]
pub async fn unregister_handler(
State(registry): State<Arc<RendererRegistry>>,
State(registry): State<Arc<MediaRendererRegistry>>,
Path(instance_id): Path<String>,
) -> impl IntoResponse {
tracing::info!(instance_id = %instance_id, "WebRenderer: explicit unregister");
registry.schedule_unregister(&instance_id);
StatusCode::NO_CONTENT
}
#[derive(Debug, Deserialize)]
pub struct UriRequest {
pub uri: String,
}
#[axum::debug_handler]
pub async fn set_uri_handler(
State(registry): State<Arc<MediaRendererRegistry>>,
Path(instance_id): Path<String>,
Json(req): Json<UriRequest>,
) -> impl IntoResponse {
tracing::info!(instance_id = %instance_id, uri = %req.uri, "WebRenderer: set_uri request");
if let Some(pipeline) = registry.get_pipeline(&instance_id) {
pipeline.send(PipelineControl::LoadUri(req.uri.clone())).await;
pipeline.send(PipelineControl::Play).await;
}
StatusCode::OK
}
#[axum::debug_handler]
pub async fn pause_handler(
State(registry): State<Arc<MediaRendererRegistry>>,
Path(instance_id): Path<String>,
) -> impl IntoResponse {
tracing::info!(instance_id = %instance_id, "WebRenderer: pause request");
if let Some(instance) = registry.get_instance(instance_id.as_str()) {
instance.adapter.deliver(DeviceCommand::Pause);
}
StatusCode::OK
}
#[axum::debug_handler]
pub async fn report_handler(
State(registry): State<Arc<MediaRendererRegistry>>,
Path(instance_id): Path<String>,
Json(report): Json<PlayerStateReport>,
) -> impl IntoResponse {
let instance = match registry.get_instance(&instance_id) {
Some(i) => i,
None => return StatusCode::NOT_FOUND.into_response(),
};
let mut state = instance.state.write();
if let Some(pos) = report.position_sec {
state.position = Some(pmomediarenderer::seconds_to_upnp_time(pos));
}
if let Some(dur) = report.duration_sec {
state.duration = Some(pmomediarenderer::seconds_to_upnp_time(dur));
}
if let Some(s) = &report.state {
state.playback_state = match s.as_str() {
"playing" => PlaybackState::Playing,
"paused" => PlaybackState::Paused,
"stopped" => PlaybackState::Stopped,
_ => state.playback_state.clone(),
};
}
tracing::debug!(instance_id = %instance_id, position = ?state.position, "player state updated");
StatusCode::OK.into_response()
}
#[axum::debug_handler]
pub async fn command_handler(
State(registry): State<Arc<MediaRendererRegistry>>,
Path(instance_id): Path<String>,
) -> impl IntoResponse {
let Some(instance) = registry.get_instance(&instance_id) else {
return StatusCode::NO_CONTENT.into_response();
};
let cmd = instance.state.write().pop_command();
match cmd.and_then(|c| serde_json::to_value(c).ok()) {
Some(v) => (StatusCode::OK, Json(v)).into_response(),
None => StatusCode::NO_CONTENT.into_response(),
}
}
#[axum::debug_handler]
pub async fn play_handler(
State(registry): State<Arc<MediaRendererRegistry>>,
Path(instance_id): Path<String>,
) -> impl IntoResponse {
let instance = match registry.get_instance(&instance_id) {
Some(i) => i,
None => {
return (StatusCode::NOT_FOUND, "Instance not found").into_response();
}
};
let has_uri = instance.state.read().current_uri.is_some();
if !has_uri {
tracing::warn!(instance_id = %instance_id, "Play command ignored: no URI loaded");
let mut headers = HeaderMap::new();
headers.insert(axum::http::header::CONTENT_TYPE, "text/plain".parse().unwrap());
return (StatusCode::BAD_REQUEST, headers, "No URI loaded").into_response();
}
tracing::info!(instance_id = %instance_id, "WebRenderer: play request");
let stream_url = format!("/api/webrenderer/{}/stream", instance_id);
instance.adapter.deliver(DeviceCommand::Stream { url: stream_url });
instance.pipeline.send(PipelineControl::Play).await;
(StatusCode::OK, "OK").into_response()
}
#[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<MediaRendererRegistry>>,
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<MediaRendererRegistry>>,
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

@@ -1,645 +0,0 @@
//! Factory pour créer des instances WebRenderer privées
//!
//! Construit des Device/Service models UPnP dynamiques avec des action handlers
//! qui relaient les commandes SOAP vers le navigateur via WebSocket.
use std::sync::Arc;
use thiserror::Error;
use pmoupnp::actions::{Action, Argument};
use pmoupnp::devices::Device;
use pmoupnp::services::Service;
use crate::handlers;
use crate::pipeline::PipelineHandle;
use crate::state::SharedState;
// ─── Réimport des variables statiques de pmomediarenderer ───────────────────
// Variables AVTransport
use pmomediarenderer::avtransport::variables::{
ABSOLUTETIMEPOSITION, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, AVTRANSPORTURI,
AVTRANSPORTURIMETADATA, A_ARG_TYPE_INSTANCE_ID as AVT_INSTANCE_ID, A_ARG_TYPE_PLAY_SPEED,
A_ARG_TYPE_SEEKMODE, CURRENTMEDIADURATION, CURRENTPLAYMODE, CURRENTTRACK, CURRENTTRACKDURATION,
CURRENTTRACKMETADATA, CURRENTTRACKURI, NUMBEROFTRACKS, PLAYBACKSTORAGEMEDIUM,
POSSIBLEPLAYBACKSTORAGEMEDIA, RELATIVETIMEPOSITION, SEEKMODE, TRANSPORTPLAYSPEED,
TRANSPORTSTATE, TRANSPORTSTATUS,
};
// Variables RenderingControl
use pmomediarenderer::renderingcontrol::variables::{
A_ARG_TYPE_CHANNEL, A_ARG_TYPE_INSTANCE_ID as RC_INSTANCE_ID, MUTE, VOLUME,
};
// Variables ConnectionManager
use pmomediarenderer::connectionmanager::variables::{
A_ARG_TYPE_AVTRANSPORTID, A_ARG_TYPE_CONNECTIONID, A_ARG_TYPE_CONNECTIONSTATUS,
A_ARG_TYPE_DIRECTION, A_ARG_TYPE_PROTOCOLINFO, A_ARG_TYPE_RCSID, CURRENTCONNECTIONIDS,
SINKPROTOCOLINFO, SOURCEPROTOCOLINFO,
};
#[derive(Error, Debug)]
pub enum FactoryError {
#[error("Failed to add service to device: {0}")]
ServiceError(String),
#[error("Failed to add action to service: {0}")]
ActionError(String),
#[error("Failed to add variable to service: {0}")]
VariableError(String),
}
/// Extrait un nom de navigateur court depuis un User-Agent complet.
fn extract_browser_name(ua: &str) -> &str {
if ua.contains("Edg/") || ua.contains("EdgA/") {
"Edge"
} else if ua.contains("OPR/") || ua.contains("Opera") {
"Opera"
} else if ua.contains("Chrome/") {
"Chrome"
} else if ua.contains("Firefox/") {
"Firefox"
} else if ua.contains("Safari/") {
"Safari"
} else {
"Browser"
}
}
/// Factory pour créer des Device UPnP WebRenderer avec un pipeline audio serveur
pub struct WebRendererFactory;
impl WebRendererFactory {
/// Crée un Device model UPnP complet pour un WebRenderer.
///
/// `device_name` sert de clé pour retrouver l'UDN persistant dans la config.
/// `browser_ua` est le User-Agent complet (pour déterminer le nom affiché).
pub fn create_device_with_pipeline(
device_name: &str,
browser_ua: &str,
pipeline: PipelineHandle,
state: SharedState,
) -> Result<Device, FactoryError> {
let avtransport = Self::build_avtransport(pipeline.clone(), state.clone())?;
let renderingcontrol = Self::build_renderingcontrol(pipeline.clone(), state.clone())?;
let connectionmanager = Self::build_connectionmanager()?;
let short_name = extract_browser_name(browser_ua);
let mut device = Device::new(
device_name.to_string(),
"MediaRenderer".to_string(),
format!("Web Audio {}", short_name),
);
device.set_model_name("WebRenderer".to_string());
device
.add_service(Arc::new(avtransport))
.map_err(|e| FactoryError::ServiceError(format!("{:?}", e)))?;
device
.add_service(Arc::new(renderingcontrol))
.map_err(|e| FactoryError::ServiceError(format!("{:?}", e)))?;
device
.add_service(Arc::new(connectionmanager))
.map_err(|e| FactoryError::ServiceError(format!("{:?}", e)))?;
Ok(device)
}
/// Construit le service AVTransport avec les handlers pipeline
fn build_avtransport(
pipeline: PipelineHandle,
state: SharedState,
) -> Result<Service, FactoryError> {
let mut svc = Service::new("AVTransport".to_string());
let add_var = |svc: &mut Service, var: &Arc<pmoupnp::state_variables::StateVariable>| {
svc.add_variable(Arc::clone(var))
.map_err(|e| FactoryError::VariableError(e.to_string()))
};
// Ajouter toutes les variables d'état
add_var(&mut svc, &AVT_INSTANCE_ID)?;
add_var(&mut svc, &A_ARG_TYPE_PLAY_SPEED)?;
add_var(&mut svc, &A_ARG_TYPE_SEEKMODE)?;
add_var(&mut svc, &ABSOLUTETIMEPOSITION)?;
add_var(&mut svc, &AVTRANSPORTNEXTURI)?;
add_var(&mut svc, &AVTRANSPORTNEXTURIMETADATA)?;
add_var(&mut svc, &AVTRANSPORTURI)?;
add_var(&mut svc, &AVTRANSPORTURIMETADATA)?;
add_var(&mut svc, &CURRENTMEDIADURATION)?;
add_var(&mut svc, &CURRENTPLAYMODE)?;
add_var(&mut svc, &CURRENTTRACK)?;
add_var(&mut svc, &CURRENTTRACKDURATION)?;
add_var(&mut svc, &CURRENTTRACKMETADATA)?;
add_var(&mut svc, &CURRENTTRACKURI)?;
add_var(&mut svc, &NUMBEROFTRACKS)?;
add_var(&mut svc, &PLAYBACKSTORAGEMEDIUM)?;
add_var(&mut svc, &POSSIBLEPLAYBACKSTORAGEMEDIA)?;
add_var(&mut svc, &RELATIVETIMEPOSITION)?;
add_var(&mut svc, &SEEKMODE)?;
add_var(&mut svc, &TRANSPORTPLAYSPEED)?;
add_var(&mut svc, &TRANSPORTSTATE)?;
add_var(&mut svc, &TRANSPORTSTATUS)?;
let add_action = |svc: &mut Service, action: Arc<Action>| {
svc.add_action(action)
.map_err(|e| FactoryError::ActionError(e.to_string()))
};
// Play
let mut play = Action::new("Play".to_string());
play.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
play.add_argument(Arc::new(Argument::new_in(
"Speed".to_string(),
Arc::clone(&TRANSPORTPLAYSPEED),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
play.set_handler(handlers::play_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(play))?;
// Stop
let mut stop = Action::new("Stop".to_string());
stop.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
stop.set_handler(handlers::stop_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(stop))?;
// Pause
let mut pause = Action::new("Pause".to_string());
pause
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
pause.set_handler(handlers::pause_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(pause))?;
// Next
let mut next = Action::new("Next".to_string());
next.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
next.set_handler(handlers::next_handler(pipeline.clone()));
add_action(&mut svc, Arc::new(next))?;
// Previous
let mut previous = Action::new("Previous".to_string());
previous
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
previous.set_handler(handlers::previous_handler(pipeline.clone()));
add_action(&mut svc, Arc::new(previous))?;
// Seek
let mut seek = Action::new("Seek".to_string());
seek.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
seek.add_argument(Arc::new(Argument::new_in(
"Unit".to_string(),
Arc::clone(&A_ARG_TYPE_SEEKMODE),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
seek.add_argument(Arc::new(Argument::new_in(
"Target".to_string(),
Arc::clone(&SEEKMODE),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
seek.set_handler(handlers::seek_handler(pipeline.clone()));
add_action(&mut svc, Arc::new(seek))?;
// SetAVTransportURI
let mut set_uri = Action::new("SetAVTransportURI".to_string());
set_uri
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_uri
.add_argument(Arc::new(Argument::new_in(
"CurrentURI".to_string(),
Arc::clone(&AVTRANSPORTURI),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_uri
.add_argument(Arc::new(Argument::new_in(
"CurrentURIMetaData".to_string(),
Arc::clone(&AVTRANSPORTURIMETADATA),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_uri.set_handler(handlers::set_uri_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(set_uri))?;
// SetNextAVTransportURI
let mut set_next_uri = Action::new("SetNextAVTransportURI".to_string());
set_next_uri
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_next_uri
.add_argument(Arc::new(Argument::new_in(
"NextURI".to_string(),
Arc::clone(&AVTRANSPORTNEXTURI),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_next_uri
.add_argument(Arc::new(Argument::new_in(
"NextURIMetaData".to_string(),
Arc::clone(&AVTRANSPORTNEXTURIMETADATA),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_next_uri.set_handler(handlers::set_next_uri_handler(pipeline.clone(), state.clone()));
add_action(&mut svc, Arc::new(set_next_uri))?;
// GetPositionInfo
let mut get_pos = Action::new("GetPositionInfo".to_string());
get_pos
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_pos
.add_argument(Arc::new(Argument::new_out(
"Track".to_string(),
Arc::clone(&CURRENTTRACK),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_pos
.add_argument(Arc::new(Argument::new_out(
"TrackDuration".to_string(),
Arc::clone(&CURRENTTRACKDURATION),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_pos
.add_argument(Arc::new(Argument::new_out(
"TrackURI".to_string(),
Arc::clone(&CURRENTTRACKURI),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_pos
.add_argument(Arc::new(Argument::new_out(
"TrackMetaData".to_string(),
Arc::clone(&CURRENTTRACKMETADATA),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_pos
.add_argument(Arc::new(Argument::new_out(
"RelTime".to_string(),
Arc::clone(&RELATIVETIMEPOSITION),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_pos
.add_argument(Arc::new(Argument::new_out(
"AbsTime".to_string(),
Arc::clone(&ABSOLUTETIMEPOSITION),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_pos.set_stateful(false);
get_pos.set_handler(handlers::get_position_info_handler(state.clone()));
add_action(&mut svc, Arc::new(get_pos))?;
// GetTransportInfo
let mut get_info = Action::new("GetTransportInfo".to_string());
get_info
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_info
.add_argument(Arc::new(Argument::new_out(
"CurrentTransportState".to_string(),
Arc::clone(&TRANSPORTSTATE),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_info
.add_argument(Arc::new(Argument::new_out(
"CurrentTransportStatus".to_string(),
Arc::clone(&TRANSPORTSTATUS),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_info
.add_argument(Arc::new(Argument::new_out(
"CurrentSpeed".to_string(),
Arc::clone(&TRANSPORTPLAYSPEED),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_info.set_stateful(false);
get_info.set_handler(handlers::get_transport_info_handler(state.clone()));
add_action(&mut svc, Arc::new(get_info))?;
// GetMediaInfo
let mut get_media = Action::new("GetMediaInfo".to_string());
get_media
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_media
.add_argument(Arc::new(Argument::new_out(
"NrTracks".to_string(),
Arc::clone(&NUMBEROFTRACKS),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_media
.add_argument(Arc::new(Argument::new_out(
"CurrentURI".to_string(),
Arc::clone(&AVTRANSPORTURI),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_media
.add_argument(Arc::new(Argument::new_out(
"CurrentURIMetaData".to_string(),
Arc::clone(&AVTRANSPORTURIMETADATA),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_media
.add_argument(Arc::new(Argument::new_out(
"NextURI".to_string(),
Arc::clone(&AVTRANSPORTNEXTURI),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_media
.add_argument(Arc::new(Argument::new_out(
"NextURIMetaData".to_string(),
Arc::clone(&AVTRANSPORTNEXTURIMETADATA),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_media.set_stateful(false);
get_media.set_handler(handlers::get_media_info_handler(state.clone()));
add_action(&mut svc, Arc::new(get_media))?;
// GetTransportSettings (passthrough)
let mut get_settings = Action::new("GetTransportSettings".to_string());
get_settings
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
add_action(&mut svc, Arc::new(get_settings))?;
// GetDeviceCapabilities (passthrough)
let mut get_caps = Action::new("GetDeviceCapabilities".to_string());
get_caps
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
add_action(&mut svc, Arc::new(get_caps))?;
// GetCurrentTransportActions (passthrough)
let mut get_actions = Action::new("GetCurrentTransportActions".to_string());
get_actions
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&AVT_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
add_action(&mut svc, Arc::new(get_actions))?;
Ok(svc)
}
/// Construit le service RenderingControl avec les handlers pipeline
fn build_renderingcontrol(
pipeline: PipelineHandle,
state: SharedState,
) -> Result<Service, FactoryError> {
let mut svc = Service::new("RenderingControl".to_string());
svc.add_variable(Arc::clone(&RC_INSTANCE_ID))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
svc.add_variable(Arc::clone(&A_ARG_TYPE_CHANNEL))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
svc.add_variable(Arc::clone(&VOLUME))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
svc.add_variable(Arc::clone(&MUTE))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
// SetVolume
let mut set_vol = Action::new("SetVolume".to_string());
set_vol
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&RC_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_vol
.add_argument(Arc::new(Argument::new_in(
"Channel".to_string(),
Arc::clone(&A_ARG_TYPE_CHANNEL),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_vol
.add_argument(Arc::new(Argument::new_in(
"DesiredVolume".to_string(),
Arc::clone(&VOLUME),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_vol.set_handler(handlers::set_volume_handler(pipeline.clone(), state.clone()));
svc.add_action(Arc::new(set_vol))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
// GetVolume
let mut get_vol = Action::new("GetVolume".to_string());
get_vol
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&RC_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_vol
.add_argument(Arc::new(Argument::new_in(
"Channel".to_string(),
Arc::clone(&A_ARG_TYPE_CHANNEL),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_vol
.add_argument(Arc::new(Argument::new_out(
"CurrentVolume".to_string(),
Arc::clone(&VOLUME),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_vol.set_stateful(false);
get_vol.set_handler(handlers::get_volume_handler(state.clone()));
svc.add_action(Arc::new(get_vol))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
// SetMute
let mut set_mute = Action::new("SetMute".to_string());
set_mute
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&RC_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_mute
.add_argument(Arc::new(Argument::new_in(
"Channel".to_string(),
Arc::clone(&A_ARG_TYPE_CHANNEL),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_mute
.add_argument(Arc::new(Argument::new_in(
"DesiredMute".to_string(),
Arc::clone(&MUTE),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_mute.set_handler(handlers::set_mute_handler(pipeline.clone(), state.clone()));
svc.add_action(Arc::new(set_mute))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
// GetMute
let mut get_mute = Action::new("GetMute".to_string());
get_mute
.add_argument(Arc::new(Argument::new_in(
"InstanceID".to_string(),
Arc::clone(&RC_INSTANCE_ID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_mute
.add_argument(Arc::new(Argument::new_in(
"Channel".to_string(),
Arc::clone(&A_ARG_TYPE_CHANNEL),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_mute
.add_argument(Arc::new(Argument::new_out(
"CurrentMute".to_string(),
Arc::clone(&MUTE),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_mute.set_stateful(false);
get_mute.set_handler(handlers::get_mute_handler(state.clone()));
svc.add_action(Arc::new(get_mute))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
Ok(svc)
}
/// Construit le service ConnectionManager
fn build_connectionmanager() -> Result<Service, FactoryError> {
let mut svc = Service::new("ConnectionManager".to_string());
svc.add_variable(Arc::clone(&A_ARG_TYPE_CONNECTIONID))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
svc.add_variable(Arc::clone(&A_ARG_TYPE_CONNECTIONSTATUS))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
svc.add_variable(Arc::clone(&A_ARG_TYPE_DIRECTION))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
svc.add_variable(Arc::clone(&A_ARG_TYPE_PROTOCOLINFO))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
svc.add_variable(Arc::clone(&A_ARG_TYPE_RCSID))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
svc.add_variable(Arc::clone(&A_ARG_TYPE_AVTRANSPORTID))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
svc.add_variable(Arc::clone(&CURRENTCONNECTIONIDS))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
svc.add_variable(Arc::clone(&SINKPROTOCOLINFO))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
svc.add_variable(Arc::clone(&SOURCEPROTOCOLINFO))
.map_err(|e| FactoryError::VariableError(e.to_string()))?;
// GetProtocolInfo
let mut get_proto = Action::new("GetProtocolInfo".to_string());
get_proto
.add_argument(Arc::new(Argument::new_out(
"Source".to_string(),
Arc::clone(&SOURCEPROTOCOLINFO),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_proto
.add_argument(Arc::new(Argument::new_out(
"Sink".to_string(),
Arc::clone(&SINKPROTOCOLINFO),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_proto.set_stateful(false);
get_proto.set_handler(handlers::get_protocol_info_handler());
svc.add_action(Arc::new(get_proto))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
// GetCurrentConnectionIDs
let mut get_ids = Action::new("GetCurrentConnectionIDs".to_string());
get_ids
.add_argument(Arc::new(Argument::new_out(
"ConnectionIDs".to_string(),
Arc::clone(&CURRENTCONNECTIONIDS),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
svc.add_action(Arc::new(get_ids))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
// GetCurrentConnectionInfo
let mut get_conn = Action::new("GetCurrentConnectionInfo".to_string());
get_conn
.add_argument(Arc::new(Argument::new_in(
"ConnectionID".to_string(),
Arc::clone(&A_ARG_TYPE_CONNECTIONID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_conn
.add_argument(Arc::new(Argument::new_out(
"RcsID".to_string(),
Arc::clone(&A_ARG_TYPE_RCSID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_conn
.add_argument(Arc::new(Argument::new_out(
"AVTransportID".to_string(),
Arc::clone(&A_ARG_TYPE_AVTRANSPORTID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_conn
.add_argument(Arc::new(Argument::new_out(
"ProtocolInfo".to_string(),
Arc::clone(&A_ARG_TYPE_PROTOCOLINFO),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_conn
.add_argument(Arc::new(Argument::new_out(
"PeerConnectionManager".to_string(),
Arc::clone(&A_ARG_TYPE_PROTOCOLINFO),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_conn
.add_argument(Arc::new(Argument::new_out(
"PeerConnectionID".to_string(),
Arc::clone(&A_ARG_TYPE_CONNECTIONID),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_conn
.add_argument(Arc::new(Argument::new_out(
"Direction".to_string(),
Arc::clone(&A_ARG_TYPE_DIRECTION),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
get_conn
.add_argument(Arc::new(Argument::new_out(
"Status".to_string(),
Arc::clone(&A_ARG_TYPE_CONNECTIONSTATUS),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
svc.add_action(Arc::new(get_conn))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
Ok(svc)
}
}

View File

@@ -9,45 +9,46 @@ use axum::{
body::Body,
extract::{Path, State},
http::{
HeaderMap, StatusCode,
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE, TRANSFER_ENCODING},
HeaderMap, StatusCode,
},
response::{IntoResponse, Response},
};
use std::sync::Arc;
use tokio_util::io::ReaderStream;
use tracing::info;
use tracing::{error, info};
use crate::registry::RendererRegistry;
use pmomediarenderer::MediaRendererRegistry;
/// GET /api/webrenderer/{id}/stream
pub async fn stream_handler(
State(registry): State<Arc<RendererRegistry>>,
State(registry): State<Arc<MediaRendererRegistry>>,
Path(instance_id): Path<String>,
headers: HeaderMap,
) -> impl IntoResponse {
info!(instance_id = %instance_id, "FLAC stream client connecting");
// Ignorer le header Range — flux live infini, non seekable.
// On ne répond jamais 206 ni 416 : toujours 200 chunked.
// Safari (et d'autres clients) envoient parfois Range: bytes=0-N ;
// répondre 416 ou 206 leur fait croire à une ressource finie.
if let Some(range) = headers.get("range") {
info!(instance_id = %instance_id, "Range header ignored (live stream): {:?}", range);
info!(instance_id = %instance_id, "Range header ignored: {:?}", range);
}
let stream = match registry.get_stream(&instance_id) {
Some(s) => s,
Some(s) => {
info!(instance_id = %instance_id, "Found instance, getting stream");
s
}
None => {
error!(instance_id = %instance_id, "No WebRenderer instance found!");
return (
StatusCode::NOT_FOUND,
format!("No WebRenderer instance for id={}", instance_id),
)
.into_response()
.into_response();
}
};
info!(instance_id = %instance_id, "FLAC stream started");
info!(instance_id = %instance_id, "FLAC stream started - returning OGG-FLAC");
Response::builder()
.status(StatusCode::OK)

View File

@@ -1 +1 @@
0.3.33
0.3.35