Merge pull request 'push-nvqvwnmvprww' (#87) from push-nvqvwnmvprww into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m29s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m29s
Reviewed-on: #87
This commit was merged in pull request #87.
This commit is contained in:
151
.kilo/plans/1775302116634-sunny-nebula.md
Normal file
151
.kilo/plans/1775302116634-sunny-nebula.md
Normal 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)
|
||||||
58
.kilo/plans/1775382570642-kind-falcon.md
Normal file
58
.kilo/plans/1775382570642-kind-falcon.md
Normal 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 d’ajout d’arguments (`add_arg_in!`, `add_arg_out!`).
|
||||||
|
Cela améliore la maintenabilité, la lisibilité et diminue le risque d’incohérences.
|
||||||
|
|
||||||
|
## Étapes détaillées
|
||||||
|
|
||||||
|
1. **Création d’une 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 l’ajout de variables, d’actions 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 d’ajout d’arguments et centralisent la gestion d’erreur.
|
||||||
|
|
||||||
|
3. **Mise à jour des implémentations**
|
||||||
|
- Modifier `build_avtransport`, `build_renderingcontrol`, `build_connectionmanager` pour déléguer à `build_service` et aux nouvelles fonctions d’argument.
|
||||||
|
- 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 d’incohé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é.
|
||||||
108
.kilo/plans/1775386308232-quick-orchid.md
Normal file
108
.kilo/plans/1775386308232-quick-orchid.md
Normal 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
|
||||||
|
```
|
||||||
@@ -1,138 +1,443 @@
|
|||||||
Parfait. Voici un **schéma fonctionnel minimal** pour un **MediaRenderer UPnP privé par navigateur** avec **token**. L’idé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)
|
## Vision
|
||||||
- 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
|
|
||||||
|
|
||||||
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
|
```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
|
||||||
Browser (Vue.js Control Point)
|
D1[Browser]
|
||||||
┌───────────────┐
|
D2[Android Auto]
|
||||||
│ UI / audio │
|
D3[Apple CarPlay]
|
||||||
│ WebSocket │
|
D4[Sonos multipoint]
|
||||||
└───────▲───────┘
|
D5[Chromecast]
|
||||||
│ token
|
end
|
||||||
│
|
|
||||||
▼
|
D --> D1
|
||||||
Rust backend (UPnP MediaRenderer)
|
D --> D2
|
||||||
┌───────────────────────────┐
|
D --> D3
|
||||||
│ Token → Renderer mapping │
|
D --> D4
|
||||||
│ Device XML / SOAP endpoints│
|
D --> D5
|
||||||
│ Play/Pause/Stop → WS → Browser │
|
|
||||||
└───────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 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.
|
```mermaid
|
||||||
2. Rust génère un token unique pour ce client :
|
blockdiag
|
||||||
|
{
|
||||||
```rust
|
block = Commandes UPnP
|
||||||
use uuid::Uuid;
|
block -> "Web Media Renderer" -> Adaptation selon device
|
||||||
let token = Uuid::new_v4().to_string();
|
"Web Media Renderer" -> Device-specific protocols
|
||||||
```
|
|
||||||
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()),
|
|
||||||
_ => (),
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
* 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
|
### Ce qui est SPÉCIFIQUE (par device)
|
||||||
ws.onmessage = (evt) => {
|
|
||||||
const msg = evt.data;
|
| Device | Transport | Buffer Management | Sync |
|
||||||
if(msg.startsWith("play:")) {
|
|--------|-----------|-------------------|------|
|
||||||
audio.src = msg.split(":")[1];
|
| Browser | HTTP/WebSocket | JS flush | N/A |
|
||||||
audio.play();
|
| Android Auto | AA API | native | varies |
|
||||||
} else if(msg === "pause") {
|
| CarPlay | CP API | native | varies |
|
||||||
audio.pause();
|
| Sonos | UPnP | none | UPnP |
|
||||||
} else if(msg === "stop") {
|
| Chromecast | Cast API | none | Cast |
|
||||||
audio.pause();
|
|
||||||
audio.currentTime = 0;
|
### 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
|
```mermaid
|
||||||
* Émet un **byebye virtuel** pour le Control Point (si nécessaire)
|
sequenceDiagram
|
||||||
* Libère toutes les ressources
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
## 3. Points clés
|
## Le Web Media Renderer - Adaptateur
|
||||||
|
|
||||||
1. **Token unique** = session privée + sécurité
|
Le rôle central du Web Media Renderer est de **convertir des ordres UPnP en actions spécifiques** selon le device cible:
|
||||||
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 l’implémentation UPnP
|
|
||||||
5. **Lecture réelle** = navigateur via `<audio>` ou `<video>`
|
|
||||||
|
|
||||||
---
|
```
|
||||||
|
UPnP orders → [Web Media Renderer] → Device-specific actions
|
||||||
|
```
|
||||||
|
|
||||||
💡 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.
|
## Browser Player - Composant web invisible
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
1156
Blackboard/Todo/webrenderer_architecture_evolution.md
Normal file
1156
Blackboard/Todo/webrenderer_architecture_evolution.md
Normal file
File diff suppressed because it is too large
Load Diff
140
Cargo.lock
generated
140
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.32"
|
version = "0.3.35"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
"console-subscriber",
|
"console-subscriber",
|
||||||
@@ -517,7 +517,6 @@ dependencies = [
|
|||||||
"tower 0.5.2",
|
"tower 0.5.2",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -575,7 +574,6 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -597,42 +595,25 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "axum-extra"
|
name = "axum-extra"
|
||||||
version = "0.9.6"
|
version = "0.12.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
|
checksum = "dbfe9f610fe4e99cf0cfcd03ccf8c63c28c616fe714d80475ef731f3b13dd21b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum 0.7.9",
|
"axum 0.8.7",
|
||||||
"axum-core 0.4.5",
|
"axum-core 0.5.5",
|
||||||
"bytes",
|
"bytes",
|
||||||
"fastrand",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"headers",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"mime",
|
"mime",
|
||||||
"multer",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde",
|
|
||||||
"tower 0.5.2",
|
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -701,7 +682,7 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"portable-atomic-util",
|
"portable-atomic-util",
|
||||||
"serde",
|
"serde",
|
||||||
"spin 0.10.0",
|
"spin",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
]
|
]
|
||||||
@@ -2137,30 +2118,6 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "heapless"
|
name = "heapless"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -3113,23 +3070,6 @@ dependencies = [
|
|||||||
"pxfm",
|
"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]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.14"
|
version = "0.2.14"
|
||||||
@@ -3897,6 +3837,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"ureq 2.12.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4016,7 +3957,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-log 0.1.4",
|
"tracing-log 0.1.4",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ureq",
|
"ureq 3.1.4",
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
@@ -4086,9 +4027,25 @@ dependencies = [
|
|||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
"htmlescape",
|
"htmlescape",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"parking_lot",
|
||||||
|
"pmoaudio",
|
||||||
|
"pmoaudio-ext",
|
||||||
|
"pmoconfig",
|
||||||
|
"pmocontrol",
|
||||||
"pmodidl",
|
"pmodidl",
|
||||||
|
"pmoflac",
|
||||||
|
"pmometadata",
|
||||||
|
"pmoserver",
|
||||||
"pmoupnp",
|
"pmoupnp",
|
||||||
|
"pmoutils",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4279,10 +4236,10 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream 0.3.6",
|
"async-stream 0.3.6",
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
"axum-embed",
|
|
||||||
"axum-server",
|
"axum-server",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"mime_guess",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"pmoconfig",
|
"pmoconfig",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
@@ -4388,26 +4345,16 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"pmoaudio",
|
|
||||||
"pmoaudio-ext",
|
|
||||||
"pmoconfig",
|
"pmoconfig",
|
||||||
"pmocontrol",
|
"pmocontrol",
|
||||||
"pmodidl",
|
|
||||||
"pmoflac",
|
|
||||||
"pmomediarenderer",
|
"pmomediarenderer",
|
||||||
"pmometadata",
|
|
||||||
"pmoserver",
|
"pmoserver",
|
||||||
"pmoupnp",
|
|
||||||
"pmoutils",
|
|
||||||
"reqwest",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.17",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5524,12 +5471,6 @@ dependencies = [
|
|||||||
"libsoxr-sys",
|
"libsoxr-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "spin"
|
|
||||||
version = "0.9.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -6513,6 +6454,22 @@ version = "0.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
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]]
|
[[package]]
|
||||||
name = "ureq"
|
name = "ureq"
|
||||||
version = "3.1.4"
|
version = "3.1.4"
|
||||||
@@ -6527,7 +6484,7 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"ureq-proto",
|
"ureq-proto",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
"webpki-roots",
|
"webpki-roots 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6820,6 +6777,15 @@ dependencies = [
|
|||||||
"libwebp-sys",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.33"
|
version = "0.3.35"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use pmoapp::{WebAppExt, Webapp};
|
use pmoapp::{WebAppExt, Webapp};
|
||||||
use pmocontrol::ControlPointExt;
|
use pmocontrol::ControlPointExt;
|
||||||
use pmomediarenderer::MEDIA_RENDERER;
|
|
||||||
use pmomediaserver::{
|
use pmomediaserver::{
|
||||||
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
|
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)
|
// Enregistrer les devices UPnP (HTTP + SSDP automatique)
|
||||||
info!("📡 Registering UPnP devices...");
|
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
|
let server_instance = server
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
|
|||||||
24
Report/webrenderer_architecture_evolution.md
Normal file
24
Report/webrenderer_architecture_evolution.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Rapport : Évolution architecture webrenderer
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
Implémentation des phases 0-4 du plan d'évolution : correction du bug P0 (play_handler sans URI), introduction du typage fort avec DeviceCommand/VegearAdapter, integration flac_handle pause/resume, ajout AudioContext avec reconnexion automatique dans PMOPlayer.ts, correction du format de position UPnP, et endpoints JSON nowplaying/state.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
1. `pmowebrenderer/src/handlers.rs` - Fix P0 + pause/resume flac_handle
|
||||||
|
2. `pmowebrenderer/src/adapter.rs` - Nouveau fichier avec DeviceCommand et DeviceAdapter
|
||||||
|
3. `pmowebrenderer/src/state.rs` - VecDeque<DeviceCommand> au lieu de Option<Value>
|
||||||
|
4. `pmowebrenderer/src/pipeline.rs` - Ajout flac_handle dans PipelineHandle
|
||||||
|
5. `pmowebrenderer/src/registry.rs` - Format position + get_state + get_state_and_udn
|
||||||
|
6. `pmowebrenderer/src/register.rs` - Handlers nowplaying et state
|
||||||
|
7. `pmowebrenderer/src/config.rs` - Nouvelles routes
|
||||||
|
8. `pmowebrenderer/src/lib.rs` - Exports adapter
|
||||||
|
9. `pmoapp/webapp/src/services/PMOPlayer.ts` - AudioContext + reconnexion
|
||||||
|
|
||||||
|
## Modifications sémantiques
|
||||||
|
- **P0** : play_handler vérifie URI avant de changement d'état (plus de Transitioning bloqué)
|
||||||
|
- **P1-P2** : DeviceCommand typé compile-time (plus de serde_json Value non typé)
|
||||||
|
- **P3** : pause_handler/stop_handler/play_handler appellent flac_handle.pause()/resume()
|
||||||
|
- **P5** : AudioContext avec createMediaElementSource() et suspend() dans flush()
|
||||||
|
- **P6** : Reconnexion automatique avec backoff exponentiel
|
||||||
|
- **P7** : Format position统一 en HH:MM:SS (seconds_to_upnp_time)
|
||||||
|
- **P8** : Endpoints /nowplaying et /state en JSON
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmoapp"
|
name = "pmoapp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rust-embed = "8.5.0"
|
rust-embed = "8.5.0"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
} from "lucide-vue-next";
|
} from "lucide-vue-next";
|
||||||
import { useRenderers } from "@/composables/useRenderers";
|
import { useRenderers } from "@/composables/useRenderers";
|
||||||
|
import { useWebRenderer } from "@/composables/useWebRenderer";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||||
import type { RendererSummary } from "@/services/pmocontrol/types";
|
import type { RendererSummary } from "@/services/pmocontrol/types";
|
||||||
@@ -27,8 +28,21 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||||
|
const webRenderer = useWebRenderer();
|
||||||
const router = useRouter();
|
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
|
// Gestion du menu déroulant
|
||||||
const openMenuId = ref<string | null>(null);
|
const openMenuId = ref<string | null>(null);
|
||||||
|
|
||||||
@@ -110,16 +124,16 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onlineRenderers = computed(() =>
|
const onlineRenderers = computed(() =>
|
||||||
allRenderers.value.filter((r: RendererSummary) => r.online),
|
filteredRenderers.value.filter((r: RendererSummary) => r.online),
|
||||||
);
|
);
|
||||||
const offlineRenderers = computed(() =>
|
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)
|
// Noms de renderers qui apparaissent plus d'une fois (online + offline)
|
||||||
const duplicateNames = computed(() => {
|
const duplicateNames = computed(() => {
|
||||||
const counts = new Map<string, number>();
|
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);
|
counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));
|
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Composable pour gérer le WebRenderer navigateur.
|
* Composable pour gérer le WebRenderer navigateur.
|
||||||
*
|
*
|
||||||
* S'enregistre via POST /api/webrenderer/register au montage
|
* Utilise PMOPlayer pour le controle remote.
|
||||||
* et diffuse l'audio via un élément <audio> pointant sur
|
* S'enregistre via POST /api/webrenderer/register
|
||||||
* /api/webrenderer/{id}/stream (flux FLAC encodé côté serveur).
|
* Utilise polling HTTP pour les commands
|
||||||
*
|
|
||||||
* 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)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, onMounted, onUnmounted, readonly } from "vue";
|
import { ref, onMounted, onUnmounted, readonly } from "vue";
|
||||||
import { useSSE } from "./useSSE";
|
import { PMOPlayer } from "@/services/PMOPlayer";
|
||||||
|
|
||||||
// ─── Identifiant stable de l'instance navigateur ─────────────────────────────
|
|
||||||
|
|
||||||
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
|
|
||||||
|
|
||||||
function generateUUID(): string {
|
function generateUUID(): string {
|
||||||
if (typeof crypto.randomUUID === "function") {
|
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)}`;
|
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 {
|
function getOrCreateInstanceId(): string {
|
||||||
try {
|
try {
|
||||||
let id = sessionStorage.getItem(INSTANCE_ID_KEY);
|
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() {
|
export function useWebRenderer() {
|
||||||
const connected = ref(false);
|
const connected = sharedConnected;
|
||||||
const streamUrl = ref<string | null>(null);
|
const streamUrl = sharedStreamUrl;
|
||||||
/** UDN du device UPnP créé côté serveur pour ce navigateur */
|
const rendererUdn = sharedRendererUdn;
|
||||||
const rendererUdn = ref<string | null>(null);
|
|
||||||
|
|
||||||
let audioEl: HTMLAudioElement | null = null;
|
let player: PMOPlayer | null = null;
|
||||||
let instanceId: string | null = null;
|
|
||||||
let currentStreamUrl: string | null = null;
|
|
||||||
let onConnectedCallback: (() => void) | 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> {
|
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 = {
|
const instanceId = getOrCreateInstanceId();
|
||||||
instance_id: instanceId,
|
console.log('[WebRenderer] registering with instanceId:', instanceId);
|
||||||
user_agent: navigator.userAgent,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/webrenderer/register", {
|
const resp = await fetch("/api/webrenderer/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify({
|
||||||
|
instance_id: instanceId,
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@@ -192,94 +83,78 @@ export function useWebRenderer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await resp.json()) as RegisterResponse;
|
const data = await resp.json();
|
||||||
streamUrl.value = data.stream_url;
|
streamUrl.value = data.stream_url;
|
||||||
currentStreamUrl = data.stream_url;
|
|
||||||
rendererUdn.value = data.udn;
|
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;
|
connected.value = true;
|
||||||
onConnectedCallback?.();
|
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) {
|
} catch (e) {
|
||||||
console.error("[WebRenderer] register error:", e);
|
console.error("[WebRenderer] register error:", e);
|
||||||
|
} finally {
|
||||||
|
registering = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Désenregistrement ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function unregister(): Promise<void> {
|
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 {
|
try {
|
||||||
await fetch(`/api/webrenderer/${instanceId}`, { method: "DELETE" });
|
await fetch(`/api/webrenderer/${instanceId}`, { method: "DELETE" });
|
||||||
} catch {
|
} catch {
|
||||||
// Ignoré lors du déchargement de page
|
// Ignored
|
||||||
}
|
}
|
||||||
instanceId = null;
|
player?.destroy();
|
||||||
|
player = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Volume / Mute ─────────────────────────────────────────────────────────
|
function setVolume(_v: number): void {
|
||||||
|
// PMOPlayer doesn't control volume directly - handled by backend
|
||||||
function setVolume(v: number): void {
|
|
||||||
if (audioEl) audioEl.volume = v;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMute(m: boolean): void {
|
function setMute(_m: boolean): void {
|
||||||
if (audioEl) audioEl.muted = m;
|
// PMOPlayer doesn't control mute directly - handled by backend
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cycle de vie ──────────────────────────────────────────────────────────
|
const beforeUnloadHandler = () => void unregister();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
audioEl = document.createElement("audio");
|
|
||||||
audioEl.preload = "auto";
|
|
||||||
document.body.appendChild(audioEl);
|
|
||||||
|
|
||||||
void register();
|
void register();
|
||||||
window.addEventListener("beforeunload", () => void unregister());
|
window.addEventListener("beforeunload", beforeUnloadHandler);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
sseUnsubscribe?.();
|
|
||||||
sseUnsubscribe = null;
|
|
||||||
stopPositionReporting();
|
|
||||||
stopStream();
|
|
||||||
void unregister();
|
void unregister();
|
||||||
if (audioEl) {
|
window.removeEventListener("beforeunload", beforeUnloadHandler);
|
||||||
audioEl.remove();
|
|
||||||
audioEl = null;
|
|
||||||
}
|
|
||||||
connected.value = false;
|
|
||||||
streamUrl.value = null;
|
|
||||||
rendererUdn.value = null;
|
|
||||||
window.removeEventListener("beforeunload", () => void unregister());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── API publique ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** true quand l'instance est enregistrée sur le serveur */
|
|
||||||
connected: readonly(connected),
|
connected: readonly(connected),
|
||||||
/** URL du flux FLAC servi par le serveur */
|
|
||||||
streamUrl: readonly(streamUrl),
|
streamUrl: readonly(streamUrl),
|
||||||
/** UDN du device UPnP créé pour ce navigateur (null avant enregistrement) */
|
|
||||||
rendererUdn: readonly(rendererUdn),
|
rendererUdn: readonly(rendererUdn),
|
||||||
/** Callback appelé quand l'enregistrement est confirmé */
|
|
||||||
onConnected(fn: () => void) {
|
onConnected(fn: () => void) {
|
||||||
onConnectedCallback = fn;
|
onConnectedCallback = fn;
|
||||||
},
|
},
|
||||||
|
|||||||
423
pmoapp/webapp/src/services/PMOPlayer.ts
Normal file
423
pmoapp/webapp/src/services/PMOPlayer.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmoaudio-ext"
|
name = "pmoaudio-ext"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Core audio types
|
# Core audio types
|
||||||
@@ -32,10 +32,11 @@ serde = { workspace = true, optional = true }
|
|||||||
serde_json = { workspace = true, optional = true }
|
serde_json = { workspace = true, optional = true }
|
||||||
reqwest = { workspace = true, features = ["stream"], optional = true }
|
reqwest = { workspace = true, features = ["stream"], optional = true }
|
||||||
futures = { version = "0.3", optional = true }
|
futures = { version = "0.3", optional = true }
|
||||||
|
ureq = { version = "2", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata", "dep:serde_json"]
|
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata", "dep:serde_json"]
|
||||||
playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"]
|
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"]
|
all = ["cache-sink", "playlist", "http-stream"]
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ impl NodeLogic for FlacCacheSinkLogic {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_AudioSegment::Sync(marker) => match &**marker {
|
_AudioSegment::Sync(marker) => match &**marker {
|
||||||
SyncMarker::TrackBoundary { metadata } => {
|
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||||
// TrackBoundary pendant le prebuffer - track courte (< 512KB)
|
// TrackBoundary pendant le prebuffer - track courte (< 512KB)
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"FlacCacheSink: TrackBoundary received before prebuffer complete - track shorter than 512KB, closing pump and waiting for ingestion"
|
"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
|
// Si pump_closed, ignorer silencieusement le chunk
|
||||||
}
|
}
|
||||||
_AudioSegment::Sync(marker) => match &**marker {
|
_AudioSegment::Sync(marker) => match &**marker {
|
||||||
SyncMarker::TrackBoundary { metadata } => {
|
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||||
// Nouveau morceau - fermer le pump si pas déjà fermé
|
// Nouveau morceau - fermer le pump si pas déjà fermé
|
||||||
tracing::debug!("FlacCacheSink: TrackBoundary received, closing pump and storing metadata for next track");
|
tracing::debug!("FlacCacheSink: TrackBoundary received, closing pump and storing metadata for next track");
|
||||||
// Stocker les métadonnées pour la prochaine track
|
// Stocker les métadonnées pour la prochaine track
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
|||||||
|
|
||||||
_AudioSegment::Sync(marker) => {
|
_AudioSegment::Sync(marker) => {
|
||||||
match marker.as_ref() {
|
match marker.as_ref() {
|
||||||
SyncMarker::TrackBoundary { metadata } => {
|
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||||
// Prepare encoder options (metadata + duration) for the upcoming track.
|
// Prepare encoder options (metadata + duration) for the upcoming track.
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
self.ctx.prepare_encoder_options_for_track(metadata).await
|
self.ctx.prepare_encoder_options_for_track(metadata).await
|
||||||
@@ -533,6 +533,9 @@ impl StreamingFlacSink {
|
|||||||
current_timestamp: Arc::new(RwLock::new(0.0)),
|
current_timestamp: Arc::new(RwLock::new(0.0)),
|
||||||
pending_track_duration: None,
|
pending_track_duration: None,
|
||||||
pending_total_samples: 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)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,17 @@ impl IcyClientStream {
|
|||||||
|
|
||||||
// Add cover URL if we have a cover_pk
|
// Add cover URL if we have a cover_pk
|
||||||
if let Some(pk) = &meta.cover_pk {
|
if let Some(pk) = &meta.cover_pk {
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
let cover_url = pmocache::covers_absolute_url_for_upnp(pk, None);
|
let cover_url = pmocache::covers_absolute_url_for_upnp(pk, None);
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
{
|
||||||
metadata_str.push_str(&format!("StreamUrl='{}';", cover_url));
|
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 {
|
} else if let Some(url) = &meta.cover_url {
|
||||||
// Fallback to external cover URL if no local pk
|
// Fallback to external cover URL if no local pk
|
||||||
metadata_str.push_str(&format!("StreamUrl='{}';", url));
|
metadata_str.push_str(&format!("StreamUrl='{}';", url));
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ use async_trait::async_trait;
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use pmoaudio::{
|
use pmoaudio::{
|
||||||
pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason},
|
pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason},
|
||||||
AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
|
AudioChunk, AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
|
||||||
};
|
};
|
||||||
use pmoflac::{EncoderOptions, FlacEncodedStream};
|
use pmoflac::{EncoderOptions, FlacEncodedStream};
|
||||||
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
|
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
|
||||||
@@ -109,6 +109,23 @@ impl OggFlacStreamHandle {
|
|||||||
pub fn set_auto_stop(&self, enabled: bool) {
|
pub fn set_auto_stop(&self, enabled: bool) {
|
||||||
self.inner.auto_stop.store(enabled, Ordering::SeqCst);
|
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).
|
/// OGG-FLAC client stream (implements AsyncRead).
|
||||||
@@ -163,16 +180,98 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
|
|
||||||
debug!("StreamingOggFlacSink started");
|
debug!("StreamingOggFlacSink started");
|
||||||
|
|
||||||
// TODO: Implement OGG-FLAC encoding logic
|
let mut was_paused = false;
|
||||||
// For now, just process segments without encoding
|
let mut silence_timestamp: f64 = 0.0;
|
||||||
|
|
||||||
loop {
|
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! {
|
tokio::select! {
|
||||||
_ = stop_token.cancelled() => {
|
_ = stop_token.cancelled() => {
|
||||||
debug!("StreamingOggFlacSink stopped by cancellation");
|
debug!("StreamingOggFlacSink stopped by cancellation");
|
||||||
break;
|
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() => {
|
segment = input.recv() => {
|
||||||
match segment {
|
match segment {
|
||||||
Some(seg) => {
|
Some(seg) => {
|
||||||
@@ -241,7 +340,33 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
duration_sec
|
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 {
|
let pcm_chunk = PcmChunk {
|
||||||
bytes: pcm_bytes,
|
bytes: pcm_bytes,
|
||||||
timestamp_sec: seg.timestamp_sec,
|
timestamp_sec: seg.timestamp_sec,
|
||||||
@@ -265,7 +390,13 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
|
|
||||||
_AudioSegment::Sync(marker) => {
|
_AudioSegment::Sync(marker) => {
|
||||||
match marker.as_ref() {
|
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.
|
// Inject per-track metadata and duration into the next FLAC header.
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
self.ctx.prepare_encoder_options_for_track(metadata).await
|
self.ctx.prepare_encoder_options_for_track(metadata).await
|
||||||
@@ -462,10 +593,13 @@ impl StreamingOggFlacSink {
|
|||||||
current_timestamp: Arc::new(RwLock::new(0.0)),
|
current_timestamp: Arc::new(RwLock::new(0.0)),
|
||||||
pending_track_duration: None,
|
pending_track_duration: None,
|
||||||
pending_total_samples: 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),
|
inner: Node::new_with_input(logic, 16),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ pub struct SharedStreamHandleInner {
|
|||||||
pub stop_token: CancellationToken,
|
pub stop_token: CancellationToken,
|
||||||
pub header: Arc<RwLock<Option<Bytes>>>,
|
pub header: Arc<RwLock<Option<Bytes>>>,
|
||||||
pub auto_stop: Arc<AtomicBool>,
|
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 {
|
impl SharedStreamHandleInner {
|
||||||
@@ -135,6 +139,8 @@ impl SharedStreamHandleInner {
|
|||||||
stop_token,
|
stop_token,
|
||||||
header,
|
header,
|
||||||
auto_stop,
|
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 current_timestamp: Arc<RwLock<f64>>,
|
||||||
pub pending_track_duration: Option<Duration>,
|
pub pending_track_duration: Option<Duration>,
|
||||||
pub pending_total_samples: Option<u64>,
|
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 {
|
impl SharedSinkContext {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ use pmoaudio::{
|
|||||||
AudioSegment,
|
AudioSegment,
|
||||||
nodes::AudioError,
|
nodes::AudioError,
|
||||||
pipeline::{AudioPipelineNode, Node, NodeLogic, send_to_children},
|
pipeline::{AudioPipelineNode, Node, NodeLogic, send_to_children},
|
||||||
|
StreamType,
|
||||||
};
|
};
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
@@ -164,6 +165,7 @@ impl NodeLogic for PlayerSourceLogic {
|
|||||||
let mut current_uri: Option<String> = None;
|
let mut current_uri: Option<String> = None;
|
||||||
let mut next_uri: Option<String> = None;
|
let mut next_uri: Option<String> = None;
|
||||||
let mut paused_at_sec: f64 = 0.0;
|
let mut paused_at_sec: f64 = 0.0;
|
||||||
|
let mut is_continuous: bool = false;
|
||||||
|
|
||||||
info!("PlayerSource: started");
|
info!("PlayerSource: started");
|
||||||
|
|
||||||
@@ -181,7 +183,7 @@ impl NodeLogic for PlayerSourceLogic {
|
|||||||
None => break,
|
None => break,
|
||||||
Some(cmd) => {
|
Some(cmd) => {
|
||||||
self.handle_command(
|
self.handle_command(
|
||||||
cmd, &mut state, &mut current_uri,
|
cmd, is_continuous, &mut state, &mut current_uri,
|
||||||
&mut next_uri, &mut paused_at_sec,
|
&mut next_uri, &mut paused_at_sec,
|
||||||
&output, &stop_token,
|
&output, &stop_token,
|
||||||
).await?;
|
).await?;
|
||||||
@@ -212,15 +214,17 @@ impl NodeLogic for PlayerSourceLogic {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let duration_sec = source.duration_sec();
|
let duration_sec = source.duration_sec();
|
||||||
|
let is_continuous = source.is_continuous();
|
||||||
let _ = self.event_tx.send(PlayerEvent::Playing {
|
let _ = self.event_tx.send(PlayerEvent::Playing {
|
||||||
uri: uri.clone(),
|
uri: uri.clone(),
|
||||||
duration_sec,
|
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
|
// Pompe audio — s'arrête sur EOF, Pause, Stop, ou commande
|
||||||
let result = self.pump(
|
let result = self.pump(
|
||||||
source,
|
source,
|
||||||
|
is_continuous,
|
||||||
&mut state,
|
&mut state,
|
||||||
&mut current_uri,
|
&mut current_uri,
|
||||||
&mut next_uri,
|
&mut next_uri,
|
||||||
@@ -255,6 +259,7 @@ impl PlayerSourceLogic {
|
|||||||
async fn handle_command(
|
async fn handle_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
cmd: PlayerCommand,
|
cmd: PlayerCommand,
|
||||||
|
is_continuous: bool,
|
||||||
state: &mut TransportState,
|
state: &mut TransportState,
|
||||||
current_uri: &mut Option<String>,
|
current_uri: &mut Option<String>,
|
||||||
next_uri: &mut Option<String>,
|
next_uri: &mut Option<String>,
|
||||||
@@ -281,14 +286,16 @@ impl PlayerSourceLogic {
|
|||||||
TransportState::Paused => {
|
TransportState::Paused => {
|
||||||
info!("PlayerSource: Play (resume from {:.1}s)", paused_at_sec);
|
info!("PlayerSource: Play (resume from {:.1}s)", paused_at_sec);
|
||||||
// Injecter un TrackBoundary pour EOS + nouveau BOS OGG propre
|
// 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;
|
*state = TransportState::Playing;
|
||||||
}
|
}
|
||||||
TransportState::Loaded => {
|
TransportState::Loaded => {
|
||||||
info!("PlayerSource: Play (start)");
|
info!("PlayerSource: Play (start)");
|
||||||
*paused_at_sec = 0.0;
|
*paused_at_sec = 0.0;
|
||||||
// TrackBoundary initial pour le premier BOS OGG
|
// 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;
|
*state = TransportState::Playing;
|
||||||
}
|
}
|
||||||
TransportState::Idle => {
|
TransportState::Idle => {
|
||||||
@@ -320,7 +327,8 @@ impl PlayerSourceLogic {
|
|||||||
if current_uri.is_some() {
|
if current_uri.is_some() {
|
||||||
info!("PlayerSource: Seek to {:.1}s", pos);
|
info!("PlayerSource: Seek to {:.1}s", pos);
|
||||||
*paused_at_sec = 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;
|
*state = TransportState::Playing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,6 +343,7 @@ impl PlayerSourceLogic {
|
|||||||
async fn pump(
|
async fn pump(
|
||||||
&mut self,
|
&mut self,
|
||||||
source: UriSource,
|
source: UriSource,
|
||||||
|
is_continuous: bool,
|
||||||
state: &mut TransportState,
|
state: &mut TransportState,
|
||||||
current_uri: &mut Option<String>,
|
current_uri: &mut Option<String>,
|
||||||
next_uri: &mut Option<String>,
|
next_uri: &mut Option<String>,
|
||||||
@@ -394,7 +403,8 @@ impl PlayerSourceLogic {
|
|||||||
*next_uri = None;
|
*next_uri = None;
|
||||||
*paused_at_sec = 0.0;
|
*paused_at_sec = 0.0;
|
||||||
// TrackBoundary pour clore le bitstream OGG proprement
|
// 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);
|
result = Err(e);
|
||||||
}
|
}
|
||||||
*state = TransportState::Playing;
|
*state = TransportState::Playing;
|
||||||
@@ -408,7 +418,8 @@ impl PlayerSourceLogic {
|
|||||||
info!("PlayerSource: Seek to {:.1}s", pos);
|
info!("PlayerSource: Seek to {:.1}s", pos);
|
||||||
source_stop.cancel();
|
source_stop.cancel();
|
||||||
*paused_at_sec = pos;
|
*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);
|
result = Err(e);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -434,7 +445,8 @@ impl PlayerSourceLogic {
|
|||||||
info!("PlayerSource: gapless transition to {:?}", next);
|
info!("PlayerSource: gapless transition to {:?}", next);
|
||||||
*current_uri = Some(next);
|
*current_uri = Some(next);
|
||||||
*paused_at_sec = 0.0;
|
*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);
|
result = Err(e);
|
||||||
}
|
}
|
||||||
*state = TransportState::Playing;
|
*state = TransportState::Playing;
|
||||||
@@ -494,6 +506,7 @@ async fn send_track_boundary(
|
|||||||
uri: Option<&str>,
|
uri: Option<&str>,
|
||||||
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||||
timestamp_sec: f64,
|
timestamp_sec: f64,
|
||||||
|
stream_type: StreamType,
|
||||||
stop_token: &CancellationToken,
|
stop_token: &CancellationToken,
|
||||||
) -> Result<(), AudioError> {
|
) -> Result<(), AudioError> {
|
||||||
if output.is_empty() || stop_token.is_cancelled() {
|
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.set_title(Some(u.to_string())).await;
|
||||||
}
|
}
|
||||||
let meta_arc = Arc::new(tokio::sync::RwLock::new(meta));
|
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
|
send_to_children("PlayerSource", output, boundary).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ use pmoaudio::{
|
|||||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||||
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
||||||
type_constraints::TypeRequirement,
|
type_constraints::TypeRequirement,
|
||||||
AudioSegment,
|
AudioSegment, StreamType,
|
||||||
};
|
};
|
||||||
use pmoaudiocache::Cache as AudioCache;
|
use pmoaudiocache::Cache as AudioCache;
|
||||||
use pmoflac::decode_audio_stream;
|
use pmoflac::decode_audio_stream;
|
||||||
@@ -279,7 +279,7 @@ impl NodeLogic for PlaylistSourceLogic {
|
|||||||
let track_start = std::time::Instant::now();
|
let track_start = std::time::Instant::now();
|
||||||
tracing::debug!("PlaylistSourceLogic: emitting TrackBoundary");
|
tracing::debug!("PlaylistSourceLogic: emitting TrackBoundary");
|
||||||
let metadata_for_boundary = metadata.clone();
|
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?;
|
send_to_children(node_name, &output, boundary).await?;
|
||||||
|
|
||||||
// Obtenir le chemin du fichier
|
// Obtenir le chemin du fichier
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ pub struct UriSource {
|
|||||||
reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
|
reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
|
||||||
stream_info: StreamInfo,
|
stream_info: StreamInfo,
|
||||||
frames_to_skip: u64,
|
frames_to_skip: u64,
|
||||||
|
/// true si c'est un flux continu (radio, stream) sans durée définie
|
||||||
|
pub is_continuous: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UriSource {
|
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`.
|
/// Émet les chunks audio vers `tx`.
|
||||||
///
|
///
|
||||||
/// Retourne `Ok(true)` si EOF naturel, `Ok(false)` si annulé ou receiver fermé.
|
/// Retourne `Ok(true)` si EOF naturel, `Ok(false)` si annulé ou receiver fermé.
|
||||||
@@ -201,7 +208,7 @@ impl UriSource {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let (_, reader) = stream.into_reader();
|
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(
|
async fn open_http(
|
||||||
@@ -209,6 +216,9 @@ impl UriSource {
|
|||||||
seek_sec: f64,
|
seek_sec: f64,
|
||||||
stop_token: &CancellationToken,
|
stop_token: &CancellationToken,
|
||||||
) -> Result<Self, AudioError> {
|
) -> 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! {
|
let response = tokio::select! {
|
||||||
_ = stop_token.cancelled() => {
|
_ = stop_token.cancelled() => {
|
||||||
return Err(AudioError::IoError("Cancelled before HTTP connect".into()));
|
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;
|
let frames_to_skip = (seek_sec * stream_info.sample_rate as f64) as u64;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"UriSource: opened HTTP {} Hz {} ch {} bps",
|
"UriSource: opened HTTP {} Hz {} ch {} bps continuous={}",
|
||||||
stream_info.sample_rate, stream_info.channels, stream_info.bits_per_sample,
|
stream_info.sample_rate, stream_info.channels, stream_info.bits_per_sample, is_continuous,
|
||||||
);
|
);
|
||||||
|
|
||||||
let (_, reader) = stream.into_reader();
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmoaudio"
|
name = "pmoaudio"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
@@ -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)
|
/// Retourne le nombre d'échantillons par canal (frames)
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
@@ -317,6 +336,11 @@ pub enum AudioChunk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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
|
/// Retourne le nombre de frames du chunk
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
use pmometadata::TrackMetadata;
|
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 {
|
pub enum _AudioSegment {
|
||||||
Chunk(Arc<AudioChunk>),
|
Chunk(Arc<AudioChunk>),
|
||||||
@@ -160,9 +160,11 @@ impl AudioSegment {
|
|||||||
order: u64,
|
order: u64,
|
||||||
timestamp_sec: f64,
|
timestamp_sec: f64,
|
||||||
metadata: Arc<RwLock<dyn TrackMetadata>>,
|
metadata: Arc<RwLock<dyn TrackMetadata>>,
|
||||||
|
stream_type: StreamType,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
let marker = Arc::new(SyncMarker::TrackBoundary {
|
let marker = Arc::new(SyncMarker::TrackBoundary {
|
||||||
metadata: Arc::clone(&metadata),
|
metadata: Arc::clone(&metadata),
|
||||||
|
stream_type,
|
||||||
});
|
});
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
order,
|
order,
|
||||||
@@ -303,7 +305,18 @@ impl AudioSegment {
|
|||||||
pub fn as_track_metadata(&self) -> Option<&Arc<RwLock<dyn TrackMetadata>>> {
|
pub fn as_track_metadata(&self) -> Option<&Arc<RwLock<dyn TrackMetadata>>> {
|
||||||
match &self.segment {
|
match &self.segment {
|
||||||
_AudioSegment::Sync(marker) => match &**marker {
|
_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,
|
||||||
},
|
},
|
||||||
_ => 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> {
|
fn try_into(self) -> Result<&'a Arc<AudioChunk>, Self::Error> {
|
||||||
match &self.segment {
|
match &self.segment {
|
||||||
_AudioSegment::Chunk(ref chunk) => Ok(chunk),
|
_AudioSegment::Chunk(chunk) => Ok(chunk),
|
||||||
_ => Err(()),
|
_ => Err(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -420,7 +433,7 @@ impl<'a> TryInto<&'a Arc<SyncMarker>> for &'a AudioSegment {
|
|||||||
|
|
||||||
fn try_into(self) -> Result<&'a Arc<SyncMarker>, Self::Error> {
|
fn try_into(self) -> Result<&'a Arc<SyncMarker>, Self::Error> {
|
||||||
match &self.segment {
|
match &self.segment {
|
||||||
_AudioSegment::Sync(ref marker) => Ok(marker),
|
_AudioSegment::Sync(marker) => Ok(marker),
|
||||||
_ => Err(()),
|
_ => Err(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ pub mod bit_depth;
|
|||||||
pub mod dsp;
|
pub mod dsp;
|
||||||
|
|
||||||
pub use audio_segment::{AudioSegment, _AudioSegment};
|
pub use audio_segment::{AudioSegment, _AudioSegment};
|
||||||
pub use sync_marker::SyncMarker;
|
pub use sync_marker::{StreamType, SyncMarker};
|
||||||
|
|
||||||
pub use audio_chunk::{
|
pub use audio_chunk::{
|
||||||
gain_db_from_linear, gain_linear_from_db, AudioChunk, AudioChunkData, AudioFloatChunk,
|
gain_db_from_linear, gain_linear_from_db, AudioChunk, AudioChunkData, AudioFloatChunk,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||||
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
||||||
type_constraints::TypeRequirement,
|
type_constraints::TypeRequirement,
|
||||||
AudioChunk, AudioChunkData, AudioSegment, I24,
|
AudioChunk, AudioChunkData, AudioSegment, I24, StreamType,
|
||||||
};
|
};
|
||||||
use pmoflac::{decode_audio_stream, AudioFileMetadata, StreamInfo};
|
use pmoflac::{decode_audio_stream, AudioFileMetadata, StreamInfo};
|
||||||
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
||||||
@@ -98,6 +98,7 @@ impl NodeLogic for FileSourceLogic {
|
|||||||
0,
|
0,
|
||||||
0.0,
|
0.0,
|
||||||
Arc::new(tokio::sync::RwLock::new(metadata)),
|
Arc::new(tokio::sync::RwLock::new(metadata)),
|
||||||
|
StreamType::Finite,
|
||||||
);
|
);
|
||||||
send_to_children(std::any::type_name::<Self>(), &output, track_boundary).await?;
|
send_to_children(std::any::type_name::<Self>(), &output, track_boundary).await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
|
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
|
||||||
pipeline::{Node, NodeLogic},
|
pipeline::{Node, NodeLogic},
|
||||||
type_constraints::TypeRequirement,
|
type_constraints::TypeRequirement,
|
||||||
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker,
|
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, StreamType,
|
||||||
};
|
};
|
||||||
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
|
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
|
||||||
use std::{
|
use std::{
|
||||||
@@ -822,6 +822,7 @@ mod tests {
|
|||||||
0,
|
0,
|
||||||
0.0,
|
0.0,
|
||||||
std::sync::Arc::new(tokio::sync::RwLock::new(metadata)),
|
std::sync::Arc::new(tokio::sync::RwLock::new(metadata)),
|
||||||
|
crate::StreamType::Finite,
|
||||||
);
|
);
|
||||||
tx.send(track_boundary).await.unwrap();
|
tx.send(track_boundary).await.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||||
pipeline::{send_to_children, Node, NodeLogic},
|
pipeline::{send_to_children, Node, NodeLogic},
|
||||||
type_constraints::TypeRequirement,
|
type_constraints::TypeRequirement,
|
||||||
AudioChunk, AudioChunkData, AudioPipelineNode, AudioSegment, I24,
|
AudioChunk, AudioChunkData, AudioPipelineNode, AudioSegment, I24, StreamType,
|
||||||
};
|
};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use pmoflac::{decode_audio_stream, StreamInfo};
|
use pmoflac::{decode_audio_stream, StreamInfo};
|
||||||
@@ -178,7 +178,7 @@ impl NodeLogic for HttpSourceLogic {
|
|||||||
|
|
||||||
// Émettre TrackBoundary avec les métadonnées HTTP
|
// Émettre TrackBoundary avec les métadonnées HTTP
|
||||||
let track_boundary =
|
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?;
|
send_to_children(std::any::type_name::<Self>(), &output, track_boundary).await?;
|
||||||
|
|
||||||
// Préparer la lecture des chunks audio
|
// Préparer la lecture des chunks audio
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use crate::{
|
|||||||
nodes::{AudioError, TypedAudioNode},
|
nodes::{AudioError, TypedAudioNode},
|
||||||
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
||||||
type_constraints::TypeRequirement,
|
type_constraints::TypeRequirement,
|
||||||
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24,
|
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24, StreamType,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@@ -495,7 +495,7 @@ mod tests {
|
|||||||
let metadata = Arc::new(tokio::sync::RwLock::new(
|
let metadata = Arc::new(tokio::sync::RwLock::new(
|
||||||
pmometadata::MemoryTrackMetadata::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
|
// Envoyer le boundary
|
||||||
input_tx.send(boundary.clone()).await.unwrap();
|
input_tx.send(boundary.clone()).await.unwrap();
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
use pmometadata::TrackMetadata;
|
use pmometadata::TrackMetadata;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum StreamType {
|
||||||
|
Continuous,
|
||||||
|
Finite,
|
||||||
|
}
|
||||||
|
|
||||||
pub enum SyncMarker {
|
pub enum SyncMarker {
|
||||||
TrackBoundary {
|
TrackBoundary {
|
||||||
metadata: Arc<RwLock<dyn TrackMetadata>>,
|
metadata: Arc<RwLock<dyn TrackMetadata>>,
|
||||||
|
stream_type: StreamType,
|
||||||
},
|
},
|
||||||
StreamMetadata {
|
StreamMetadata {
|
||||||
key: String,
|
key: String,
|
||||||
@@ -15,5 +22,4 @@ pub enum SyncMarker {
|
|||||||
Heartbeat,
|
Heartbeat,
|
||||||
EndOfStream,
|
EndOfStream,
|
||||||
Error(String),
|
Error(String),
|
||||||
// autres cas à venir…
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmoaudiocache"
|
name = "pmoaudiocache"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Cache générique
|
# Cache générique
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmocache"
|
name = "pmocache"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Base de données
|
# Base de données
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmoconfig"
|
name = "pmoconfig"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pmoutils = { version = "0.1.2", registry = "pmo" }
|
pmoutils = { version = "0.1.2", registry = "pmo" }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmocovers"
|
name = "pmocovers"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Cache générique
|
# Cache générique
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmoflac"
|
name = "pmoflac"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
authors = ["PMOMusic"]
|
authors = ["PMOMusic"]
|
||||||
description = "Asynchronous FLAC <-> PCM streaming utilities"
|
description = "Asynchronous FLAC <-> PCM streaming utilities"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ unsafe fn setup_metadata(
|
|||||||
append_comment("TRACKNUMBER", &track_number.to_string())?;
|
append_comment("TRACKNUMBER", &track_number.to_string())?;
|
||||||
}
|
}
|
||||||
// Construct cover URL: use cover_pk with server_base_url if available, fallback to cover_url
|
// 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);
|
let cover_url = format!("{}/covers/image/{}", base_url, pk);
|
||||||
append_comment("COVERART", &cover_url)?;
|
append_comment("COVERART", &cover_url)?;
|
||||||
} else if let Some(cover_url) = &metadata.cover_url {
|
} else if let Some(cover_url) = &metadata.cover_url {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmomediarenderer"
|
name = "pmomediarenderer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pmoupnp = { path = "../pmoupnp" }
|
pmoupnp = { path = "../pmoupnp" }
|
||||||
@@ -11,3 +11,28 @@ once_cell = "1.20"
|
|||||||
bevy_reflect = "0.17.1"
|
bevy_reflect = "0.17.1"
|
||||||
htmlescape = "0.3"
|
htmlescape = "0.3"
|
||||||
quick-xml = { workspace = true }
|
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"]
|
||||||
33
pmomediarenderer/src/adapter.rs
Normal file
33
pmomediarenderer/src/adapter.rs
Normal 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>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::avtransport::variables::{
|
use crate::avtransport::variables::{
|
||||||
A_ARG_TYPE_INSTANCE_ID, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, AVTRANSPORTURI,
|
AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, AVTRANSPORTURI, AVTRANSPORTURIMETADATA,
|
||||||
AVTRANSPORTURIMETADATA, CURRENTTRACK, NUMBEROFTRACKS,
|
A_ARG_TYPE_INSTANCE_ID, CURRENTTRACK, NUMBEROFTRACKS,
|
||||||
};
|
};
|
||||||
use pmoupnp::define_action;
|
use pmoupnp::define_action;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::avtransport::variables::{
|
use crate::avtransport::variables::{
|
||||||
A_ARG_TYPE_INSTANCE_ID, ABSOLUTETIMEPOSITION, AVTRANSPORTURI, AVTRANSPORTURIMETADATA,
|
ABSOLUTETIMEPOSITION, AVTRANSPORTURI, AVTRANSPORTURIMETADATA, A_ARG_TYPE_INSTANCE_ID,
|
||||||
CURRENTTRACK, CURRENTTRACKDURATION, RELATIVETIMEPOSITION,
|
CURRENTTRACK, CURRENTTRACKDURATION, RELATIVETIMEPOSITION,
|
||||||
};
|
};
|
||||||
use pmoupnp::define_action;
|
use pmoupnp::define_action;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::avtransport::variables::{
|
use crate::avtransport::variables::{
|
||||||
A_ARG_TYPE_INSTANCE_ID, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA,
|
AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, A_ARG_TYPE_INSTANCE_ID,
|
||||||
};
|
};
|
||||||
use pmoupnp::define_action;
|
use pmoupnp::define_action;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::avtransport::variables::{
|
use crate::avtransport::variables::{
|
||||||
A_ARG_TYPE_INSTANCE_ID, AVTRANSPORTURI, AVTRANSPORTURIMETADATA,
|
AVTRANSPORTURI, AVTRANSPORTURIMETADATA, A_ARG_TYPE_INSTANCE_ID,
|
||||||
};
|
};
|
||||||
use pmoupnp::define_action;
|
use pmoupnp::define_action;
|
||||||
|
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ use actions::{
|
|||||||
SETNEXTAVTRANSPORTURI, STOP,
|
SETNEXTAVTRANSPORTURI, STOP,
|
||||||
};
|
};
|
||||||
use variables::{
|
use variables::{
|
||||||
A_ARG_TYPE_INSTANCE_ID, A_ARG_TYPE_PLAY_SPEED, A_ARG_TYPE_SEEKMODE, ABSOLUTETIMEPOSITION,
|
ABSOLUTETIMEPOSITION, AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, AVTRANSPORTURI,
|
||||||
AVTRANSPORTNEXTURI, AVTRANSPORTNEXTURIMETADATA, AVTRANSPORTURI, AVTRANSPORTURIMETADATA,
|
AVTRANSPORTURIMETADATA, A_ARG_TYPE_INSTANCE_ID, A_ARG_TYPE_PLAY_SPEED, A_ARG_TYPE_SEEKMODE,
|
||||||
CURRENTMEDIADURATION, CURRENTPLAYMODE, CURRENTTRACK, CURRENTTRACKDURATION,
|
CURRENTMEDIADURATION, CURRENTPLAYMODE, CURRENTTRACK, CURRENTTRACKDURATION,
|
||||||
CURRENTTRACKMETADATA, CURRENTTRACKURI, NUMBEROFTRACKS, PLAYBACKSTORAGEMEDIUM,
|
CURRENTTRACKMETADATA, CURRENTTRACKURI, NUMBEROFTRACKS, PLAYBACKSTORAGEMEDIUM,
|
||||||
POSSIBLEPLAYBACKSTORAGEMEDIA, RELATIVETIMEPOSITION, SEEKMODE, TRANSPORTPLAYSPEED,
|
POSSIBLEPLAYBACKSTORAGEMEDIA, RELATIVETIMEPOSITION, SEEKMODE, TRANSPORTPLAYSPEED,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
});
|
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
//! Erreurs liées au WebRenderer
|
//! Erreurs liées au MediaRenderer
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum WebRendererError {
|
pub enum MediaRendererError {
|
||||||
#[error("Session not found: {0}")]
|
|
||||||
SessionNotFound(String),
|
|
||||||
|
|
||||||
#[error("Failed to send message to websocket: {0}")]
|
|
||||||
WebSocketSendError(String),
|
|
||||||
|
|
||||||
#[error("Invalid argument: {0}")]
|
#[error("Invalid argument: {0}")]
|
||||||
InvalidArgument(String),
|
InvalidArgument(String),
|
||||||
|
|
||||||
233
pmomediarenderer/src/handlers.rs
Normal file
233
pmomediarenderer/src/handlers.rs
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,25 +11,23 @@
|
|||||||
//! - **AVTransport** : Contrôle de la lecture (play, pause, stop, seek, etc.)
|
//! - **AVTransport** : Contrôle de la lecture (play, pause, stop, seek, etc.)
|
||||||
//! - **RenderingControl** : Contrôle du volume et du mute
|
//! - **RenderingControl** : Contrôle du volume et du mute
|
||||||
//! - **ConnectionManager** : Gestion des connexions et des protocoles supportés
|
//! - **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 avtransport;
|
||||||
pub mod connectionmanager;
|
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 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};
|
||||||
13
pmomediarenderer/src/messages.rs
Normal file
13
pmomediarenderer/src/messages.rs
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 :
|
//! Chaque instance MediaRenderer possède un pipeline独立的音频处理:
|
||||||
//! - Une `PlayerSource` qui gère le cycle de vie AVTransport (Play/Pause/Stop/Seek/LoadUri)
|
//! - 一个 `PlayerSource` 管理 AVTransport 生命周期(Play/Pause/Stop/Seek/LoadUri)
|
||||||
//! - Un `StreamingOggFlacSink` qui encode et diffuse le flux OGG-FLAC aux clients HTTP
|
//! - 一个 `StreamingOggFlacSink` 编码并向 HTTP 客户端传输 OGG-FLAC 流
|
||||||
//! - Des nœuds de normalisation (resampling → 96 kHz, conversion → I24)
|
//! - 规范化节点(重采样 → 96 kHz,转换 → I24)
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use pmoaudio::{ResamplingNode, ToI24Node};
|
use pmoaudio::{ResamplingNode, ToI24Node};
|
||||||
use pmoaudio_ext::{PlayerCommand, PlayerHandle, PlayerSource};
|
use pmoaudio_ext::{PlayerCommand, PlayerHandle, PlayerSource};
|
||||||
use pmoaudio_ext::sinks::{OggFlacStreamHandle, StreamingOggFlacSink};
|
use pmoaudio_ext::sinks::{OggFlacStreamHandle, StreamingOggFlacSink};
|
||||||
@@ -18,25 +17,21 @@ use crate::state::SharedState;
|
|||||||
|
|
||||||
// ─── Ré-export des commandes pour les handlers ────────────────────────────────
|
// ─── Ré-export des commandes pour les handlers ────────────────────────────────
|
||||||
|
|
||||||
/// Commandes de transport — alias vers PlayerCommand pour compatibilité handlers
|
|
||||||
pub use pmoaudio_ext::PlayerCommand as PipelineControl;
|
pub use pmoaudio_ext::PlayerCommand as PipelineControl;
|
||||||
|
|
||||||
// ─── Handle vers le pipeline ─────────────────────────────────────────────────
|
// ─── 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)]
|
#[derive(Clone)]
|
||||||
pub struct PipelineHandle {
|
pub struct PipelineHandle {
|
||||||
pub player: PlayerHandle,
|
pub player: PlayerHandle,
|
||||||
pub stop_token: CancellationToken,
|
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,
|
state: SharedState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PipelineHandle {
|
impl PipelineHandle {
|
||||||
/// Envoie une commande de transport. Gère SetVolume/SetMute localement.
|
|
||||||
pub async fn send(&self, cmd: PipelineControl) {
|
pub async fn send(&self, cmd: PipelineControl) {
|
||||||
match cmd {
|
match cmd {
|
||||||
PlayerCommand::LoadUri(uri) => self.player.load_uri(uri).await,
|
PlayerCommand::LoadUri(uri) => self.player.load_uri(uri).await,
|
||||||
@@ -51,45 +46,34 @@ impl PipelineHandle {
|
|||||||
|
|
||||||
// ─── Pipeline instancié ──────────────────────────────────────────────────────
|
// ─── 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 {
|
pub struct InstancePipeline {
|
||||||
/// Handle vers le sink OGG-FLAC — clonable, subscribe() crée un flux indépendant par client.
|
|
||||||
pub flac_handle: OggFlacStreamHandle,
|
pub flac_handle: OggFlacStreamHandle,
|
||||||
pub pipeline_handle: PipelineHandle,
|
pub pipeline_handle: PipelineHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InstancePipeline {
|
impl InstancePipeline {
|
||||||
/// Crée et démarre le pipeline en background.
|
|
||||||
/// Retourne immédiatement avec les handles nécessaires.
|
|
||||||
pub fn start(
|
pub fn start(
|
||||||
state: SharedState,
|
state: SharedState,
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
control_point: Arc<pmocontrol::ControlPoint>,
|
control_point: Arc<pmocontrol::ControlPoint>,
|
||||||
udn: String,
|
udn: String,
|
||||||
|
adapter: Arc<dyn crate::adapter::DeviceAdapter>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let stop_token = CancellationToken::new();
|
let stop_token = CancellationToken::new();
|
||||||
|
|
||||||
use pmoaudio::pipeline::AudioPipelineNode;
|
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);
|
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();
|
let mut to_i24 = ToI24Node::new();
|
||||||
to_i24.register(sink.boxed());
|
to_i24.register(sink.boxed());
|
||||||
|
|
||||||
// Nœud de rééchantillonnage : n'importe quel sample rate → 96 kHz
|
|
||||||
let mut resampler = ResamplingNode::new(96_000);
|
let mut resampler = ResamplingNode::new(96_000);
|
||||||
resampler.register(to_i24.boxed());
|
resampler.register(to_i24.boxed());
|
||||||
|
|
||||||
// Source avec contrôle AVTransport complet
|
|
||||||
let (mut player_source, player_handle) = PlayerSource::new();
|
let (mut player_source, player_handle) = PlayerSource::new();
|
||||||
player_source.register(resampler.boxed());
|
player_source.register(resampler.boxed());
|
||||||
|
|
||||||
// Lancer le pipeline en background
|
|
||||||
let sink_stop = stop_token.clone();
|
let sink_stop = stop_token.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = player_source.boxed().run(sink_stop).await {
|
if let Err(e) = player_source.boxed().run(sink_stop).await {
|
||||||
@@ -98,16 +82,17 @@ impl InstancePipeline {
|
|||||||
debug!("Pipeline task terminated");
|
debug!("Pipeline task terminated");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Écouter les événements PlayerSource pour mettre à jour le state UPnP
|
|
||||||
let event_rx = player_handle.subscribe_events();
|
let event_rx = player_handle.subscribe_events();
|
||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
let udn_clone = udn.clone();
|
let udn_clone = udn.clone();
|
||||||
|
let adapter_clone = Arc::downgrade(&adapter);
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
let cp_clone = control_point.clone();
|
let cp_clone = control_point.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
run_event_listener(
|
run_event_listener(
|
||||||
event_rx,
|
event_rx,
|
||||||
state_clone,
|
state_clone,
|
||||||
|
adapter_clone,
|
||||||
udn_clone,
|
udn_clone,
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
cp_clone,
|
cp_clone,
|
||||||
@@ -117,6 +102,8 @@ impl InstancePipeline {
|
|||||||
let pipeline_handle = PipelineHandle {
|
let pipeline_handle = PipelineHandle {
|
||||||
player: player_handle,
|
player: player_handle,
|
||||||
stop_token: stop_token.clone(),
|
stop_token: stop_token.clone(),
|
||||||
|
flac_handle: flac_handle.clone(),
|
||||||
|
adapter,
|
||||||
state,
|
state,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,12 +119,14 @@ impl InstancePipeline {
|
|||||||
async fn run_event_listener(
|
async fn run_event_listener(
|
||||||
mut event_rx: tokio::sync::broadcast::Receiver<pmoaudio_ext::PlayerEvent>,
|
mut event_rx: tokio::sync::broadcast::Receiver<pmoaudio_ext::PlayerEvent>,
|
||||||
state: SharedState,
|
state: SharedState,
|
||||||
|
adapter: std::sync::Weak<dyn crate::adapter::DeviceAdapter>,
|
||||||
udn: String,
|
udn: String,
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
control_point: Arc<pmocontrol::ControlPoint>,
|
control_point: Arc<pmocontrol::ControlPoint>,
|
||||||
) {
|
) {
|
||||||
use pmoaudio_ext::PlayerEvent;
|
use pmoaudio_ext::PlayerEvent;
|
||||||
use crate::messages::PlaybackState;
|
use crate::messages::PlaybackState;
|
||||||
|
use crate::adapter::DeviceCommand;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match event_rx.recv().await {
|
match event_rx.recv().await {
|
||||||
@@ -148,7 +137,6 @@ async fn run_event_listener(
|
|||||||
s.current_uri = Some(uri);
|
s.current_uri = Some(uri);
|
||||||
s.duration = duration_sec.map(seconds_to_upnp_time);
|
s.duration = duration_sec.map(seconds_to_upnp_time);
|
||||||
s.position = None;
|
s.position = None;
|
||||||
// Effacer next_uri/next_metadata : la nouvelle piste est maintenant courante
|
|
||||||
s.next_uri = None;
|
s.next_uri = None;
|
||||||
s.next_metadata = None;
|
s.next_metadata = None;
|
||||||
}
|
}
|
||||||
@@ -166,6 +154,10 @@ async fn run_event_listener(
|
|||||||
state.write().position = Some(seconds_to_upnp_time(position_sec));
|
state.write().position = Some(seconds_to_upnp_time(position_sec));
|
||||||
}
|
}
|
||||||
PlayerEvent::TrackEnded => {
|
PlayerEvent::TrackEnded => {
|
||||||
|
state.write().playback_state = PlaybackState::Transitioning;
|
||||||
|
if let Some(adapter) = adapter.upgrade() {
|
||||||
|
adapter.deliver(DeviceCommand::Flush);
|
||||||
|
}
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
{
|
{
|
||||||
let cp = control_point.clone();
|
let cp = control_point.clone();
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
//! Registre des instances WebRenderer actives.
|
//! Registre des instances MediaRenderer actives.
|
||||||
//!
|
|
||||||
//! Remplace `SessionManager` et `websocket.rs`. La session est maintenant liée
|
|
||||||
//! au flux FLAC HTTP, pas à une connexion WebSocket.
|
|
||||||
|
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -10,10 +7,11 @@ use std::time::SystemTime;
|
|||||||
|
|
||||||
use pmoupnp::devices::DeviceInstance;
|
use pmoupnp::devices::DeviceInstance;
|
||||||
|
|
||||||
use crate::error::WebRendererError;
|
use crate::error::MediaRendererError;
|
||||||
use crate::pipeline::{InstancePipeline, PipelineHandle};
|
use crate::pipeline::{InstancePipeline, PipelineHandle};
|
||||||
use crate::renderer::WebRendererFactory;
|
use crate::renderer::MediaRendererFactory;
|
||||||
use crate::state::{RendererState, SharedState};
|
use crate::state::{RendererState, SharedState};
|
||||||
|
use super::adapter::DeviceAdapter;
|
||||||
|
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
@@ -23,31 +21,26 @@ use pmocontrol::model::{RendererCapabilities, RendererProtocol};
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use pmoupnp::UpnpTypedInstance;
|
use pmoupnp::UpnpTypedInstance;
|
||||||
|
|
||||||
/// Une instance WebRenderer côté serveur
|
pub struct MediaRendererInstance {
|
||||||
pub struct WebRendererInstance {
|
|
||||||
pub instance_id: String,
|
pub instance_id: String,
|
||||||
pub udn: String,
|
pub udn: String,
|
||||||
pub device_instance: Arc<DeviceInstance>,
|
pub device_instance: Arc<DeviceInstance>,
|
||||||
pub state: SharedState,
|
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 flac_handle: pmoaudio_ext::sinks::OggFlacStreamHandle,
|
||||||
pub pipeline: PipelineHandle,
|
pub pipeline: PipelineHandle,
|
||||||
pub created_at: SystemTime,
|
pub created_at: SystemTime,
|
||||||
|
pub adapter: Arc<dyn DeviceAdapter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registre global des instances WebRenderer
|
pub struct MediaRendererRegistry {
|
||||||
pub struct RendererRegistry {
|
instances: RwLock<HashMap<String, Arc<MediaRendererInstance>>>,
|
||||||
/// Map instance_id → instance
|
by_udn: RwLock<HashMap<String, Arc<MediaRendererInstance>>>,
|
||||||
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)
|
|
||||||
pending_unregister: RwLock<HashMap<String, tokio_util::sync::CancellationToken>>,
|
pending_unregister: RwLock<HashMap<String, tokio_util::sync::CancellationToken>>,
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
control_point: Arc<ControlPoint>,
|
control_point: Arc<ControlPoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RendererRegistry {
|
impl MediaRendererRegistry {
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub fn new(control_point: Arc<ControlPoint>) -> Self {
|
pub fn new(control_point: Arc<ControlPoint>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -67,35 +60,39 @@ impl RendererRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enregistre ou reconnecte une instance.
|
|
||||||
/// Retourne `(stream_url, udn)`.
|
|
||||||
pub async fn register_or_reconnect(
|
pub async fn register_or_reconnect(
|
||||||
&self,
|
&self,
|
||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
user_agent: &str,
|
stream_url_base: &str,
|
||||||
) -> Result<(String, String), WebRendererError> {
|
renderer_name: &str,
|
||||||
// Annuler tout unregister différé pour cet instance_id
|
adapter_fn: impl FnOnce(SharedState) -> Arc<dyn DeviceAdapter>,
|
||||||
|
) -> Result<(String, String, bool), MediaRendererError> {
|
||||||
if let Some(cancel) = self.pending_unregister.write().remove(instance_id) {
|
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();
|
cancel.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconnexion : l'instance existe déjà (ou vient d'être conservée)
|
|
||||||
{
|
{
|
||||||
let instances = self.instances.read();
|
let instances = self.instances.read();
|
||||||
if let Some(existing) = instances.get(instance_id) {
|
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")]
|
#[cfg(feature = "pmoserver")]
|
||||||
self.register_with_control_point(&existing.device_instance)?;
|
self.register_with_control_point(&existing.device_instance, renderer_name)?;
|
||||||
let stream_url = format!("/api/webrenderer/{}/stream", instance_id);
|
let stream_url = format!("{}/{}/stream", stream_url_base, instance_id);
|
||||||
return Ok((stream_url, existing.udn.clone()));
|
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_with_adapter(instance_id, stream_url_base, renderer_name, adapter_fn).await?;
|
||||||
let instance = self.create_instance(instance_id, user_agent).await?;
|
|
||||||
let instance = Arc::new(instance);
|
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();
|
let udn = instance.udn.clone();
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -110,25 +107,29 @@ impl RendererRegistry {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
instance_id = %instance_id,
|
instance_id = %instance_id,
|
||||||
udn = %udn,
|
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(
|
pub fn get_stream(
|
||||||
&self,
|
&self,
|
||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
) -> Option<pmoaudio_ext::sinks::OggFlacClientStream> {
|
) -> Option<pmoaudio_ext::sinks::OggFlacClientStream> {
|
||||||
self.instances
|
let instances = self.instances.read();
|
||||||
.read()
|
match instances.get(instance_id) {
|
||||||
.get(instance_id)
|
Some(i) => {
|
||||||
.map(|i| i.flac_handle.subscribe())
|
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> {
|
pub fn get_pipeline_by_udn(&self, udn: &str) -> Option<PipelineHandle> {
|
||||||
self.by_udn
|
self.by_udn
|
||||||
.read()
|
.read()
|
||||||
@@ -136,7 +137,28 @@ impl RendererRegistry {
|
|||||||
.map(|i| i.pipeline.clone())
|
.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> {
|
pub fn get_state_by_udn(&self, udn: &str) -> Option<SharedState> {
|
||||||
self.by_udn
|
self.by_udn
|
||||||
.read()
|
.read()
|
||||||
@@ -144,7 +166,6 @@ impl RendererRegistry {
|
|||||||
.map(|i| i.state.clone())
|
.map(|i| i.state.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retourne le DeviceInstance par UDN
|
|
||||||
pub fn get_device_by_udn(&self, udn: &str) -> Option<Arc<DeviceInstance>> {
|
pub fn get_device_by_udn(&self, udn: &str) -> Option<Arc<DeviceInstance>> {
|
||||||
self.by_udn
|
self.by_udn
|
||||||
.read()
|
.read()
|
||||||
@@ -152,14 +173,10 @@ impl RendererRegistry {
|
|||||||
.map(|i| i.device_instance.clone())
|
.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>) {
|
pub fn update_duration(&self, instance_id: &str, duration_sec: Option<f64>) {
|
||||||
let instances = self.instances.read();
|
let instances = self.instances.read();
|
||||||
if let Some(instance) = instances.get(instance_id) {
|
if let Some(instance) = instances.get(instance_id) {
|
||||||
let mut s = instance.state.write();
|
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 s.duration.is_none() {
|
||||||
if let Some(dur) = duration_sec {
|
if let Some(dur) = duration_sec {
|
||||||
if dur > 0.0 {
|
if dur > 0.0 {
|
||||||
@@ -173,19 +190,18 @@ impl RendererRegistry {
|
|||||||
pub fn schedule_unregister(self: &Arc<Self>, instance_id: &str) {
|
pub fn schedule_unregister(self: &Arc<Self>, instance_id: &str) {
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
// Ne pas détruire immédiatement : attendre 5s au cas où la page se recharge
|
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
self.pending_unregister.write().insert(instance_id.to_string(), cancel.clone());
|
self.pending_unregister.write().insert(instance_id.to_string(), cancel.clone());
|
||||||
|
|
||||||
let instance_id_owned = instance_id.to_string();
|
let instance_id_owned = instance_id.to_string();
|
||||||
let registry = Arc::clone(self);
|
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::spawn(async move {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancel.cancelled() => {
|
_ = 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)) => {
|
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
|
||||||
registry.pending_unregister.write().remove(&instance_id_owned);
|
registry.pending_unregister.write().remove(&instance_id_owned);
|
||||||
@@ -200,7 +216,7 @@ impl RendererRegistry {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
instance_id = %instance_id_owned,
|
instance_id = %instance_id_owned,
|
||||||
udn = %instance.udn,
|
udn = %instance.udn,
|
||||||
"WebRenderer: instance unregistered"
|
"MediaRenderer: instance unregistered"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,71 +224,72 @@ impl RendererRegistry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Création d'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.
|
||||||
async fn create_instance(
|
pub async fn create_instance_with_adapter(
|
||||||
&self,
|
&self,
|
||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
user_agent: &str,
|
stream_url_base: &str,
|
||||||
) -> Result<WebRendererInstance, WebRendererError> {
|
renderer_name: &str,
|
||||||
// UDN stable dérivé de l'instance_id
|
adapter_fn: impl FnOnce(SharedState) -> Arc<dyn DeviceAdapter>,
|
||||||
|
) -> Result<MediaRendererInstance, MediaRendererError> {
|
||||||
let candidate_udn = instance_id.to_ascii_lowercase();
|
let candidate_udn = instance_id.to_ascii_lowercase();
|
||||||
let full_udn = format!("uuid:{}", candidate_udn);
|
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(
|
if let Err(e) = pmoconfig::get_config().set_device_udn(
|
||||||
"MediaRenderer",
|
"MediaRenderer",
|
||||||
instance_id,
|
instance_id,
|
||||||
candidate_udn.clone(),
|
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 state: SharedState = Arc::new(parking_lot::RwLock::new(RendererState::default()));
|
||||||
|
let adapter = adapter_fn(state.clone());
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
let (device_instance, pipeline) = {
|
let (device_instance, pipeline) = {
|
||||||
use pmoupnp::UpnpServerExt;
|
use pmoupnp::UpnpServerExt;
|
||||||
|
|
||||||
let server_arc = pmoserver::get_server()
|
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(
|
let ip = InstancePipeline::start(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
self.control_point.clone(),
|
self.control_point.clone(),
|
||||||
full_udn.clone(),
|
full_udn.clone(),
|
||||||
|
adapter.clone(),
|
||||||
);
|
);
|
||||||
let pipeline = ip.pipeline_handle.clone();
|
let pipeline = ip.pipeline_handle.clone();
|
||||||
|
|
||||||
// Vérifier si un device avec ce même UDN existe déjà
|
|
||||||
let existing_di = {
|
let existing_di = {
|
||||||
let server = server_arc.read().await;
|
let server = server_arc.read().await;
|
||||||
server.get_device(&candidate_udn)
|
server.get_device(&candidate_udn)
|
||||||
};
|
};
|
||||||
|
|
||||||
let di = if let Some(di) = existing_di {
|
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
|
di
|
||||||
} else {
|
} else {
|
||||||
// Créer le device UPnP
|
tracing::info!(udn = %candidate_udn, "MediaRenderer: creating new device");
|
||||||
tracing::info!(udn = %candidate_udn, "WebRenderer: creating new device");
|
let device = MediaRendererFactory::create_device_with_pipeline(
|
||||||
let device = WebRendererFactory::create_device_with_pipeline(
|
|
||||||
instance_id,
|
instance_id,
|
||||||
user_agent,
|
"MediaRenderer",
|
||||||
|
"WebRenderer",
|
||||||
pipeline.clone(),
|
pipeline.clone(),
|
||||||
state.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;
|
let mut server = server_arc.write().await;
|
||||||
server
|
server
|
||||||
.register_device(Arc::new(device), false)
|
.register_device(Arc::new(device), false)
|
||||||
.await
|
.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)
|
(di, ip)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -280,21 +297,27 @@ impl RendererRegistry {
|
|||||||
let (device_instance, pipeline) = {
|
let (device_instance, pipeline) = {
|
||||||
use pmoupnp::UpnpModel;
|
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 pipeline = ip.pipeline_handle.clone();
|
||||||
|
|
||||||
let device = WebRendererFactory::create_device_with_pipeline(
|
let device = MediaRendererFactory::create_device_with_pipeline(
|
||||||
instance_id,
|
instance_id,
|
||||||
user_agent,
|
"MediaRenderer",
|
||||||
|
"WebRenderer",
|
||||||
pipeline.clone(),
|
pipeline.clone(),
|
||||||
state.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)
|
(Arc::new(device).create_instance(), ip)
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(WebRendererInstance {
|
Ok(MediaRendererInstance {
|
||||||
instance_id: instance_id.to_string(),
|
instance_id: instance_id.to_string(),
|
||||||
udn: full_udn,
|
udn: full_udn,
|
||||||
device_instance,
|
device_instance,
|
||||||
@@ -302,15 +325,16 @@ impl RendererRegistry {
|
|||||||
flac_handle: pipeline.flac_handle.clone(),
|
flac_handle: pipeline.flac_handle.clone(),
|
||||||
pipeline: pipeline.pipeline_handle,
|
pipeline: pipeline.pipeline_handle,
|
||||||
created_at: SystemTime::now(),
|
created_at: SystemTime::now(),
|
||||||
|
adapter,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enregistre le device dans le ControlPoint
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
fn register_with_control_point(
|
fn register_with_control_point(
|
||||||
&self,
|
&self,
|
||||||
di: &Arc<DeviceInstance>,
|
di: &Arc<DeviceInstance>,
|
||||||
) -> Result<(), WebRendererError> {
|
renderer_name: &str,
|
||||||
|
) -> Result<(), MediaRendererError> {
|
||||||
let base_url = di.base_url().to_string();
|
let base_url = di.base_url().to_string();
|
||||||
let udn = di.udn().to_ascii_lowercase();
|
let udn = di.udn().to_ascii_lowercase();
|
||||||
let udn_with_prefix = format!("uuid:{}", udn);
|
let udn_with_prefix = format!("uuid:{}", udn);
|
||||||
@@ -345,7 +369,7 @@ impl RendererRegistry {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
format!("{}{}", base_url, di.description_route()),
|
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()),
|
Some("urn:schemas-upnp-org:service:AVTransport:1".to_string()),
|
||||||
avtransport_control_url,
|
avtransport_control_url,
|
||||||
Some("urn:schemas-upnp-org:service:RenderingControl:1".to_string()),
|
Some("urn:schemas-upnp-org:service:RenderingControl:1".to_string()),
|
||||||
@@ -359,7 +383,7 @@ impl RendererRegistry {
|
|||||||
registry.push_renderer(&renderer_info, 86400);
|
registry.push_renderer(&renderer_info, 86400);
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(udn = %udn, "WebRenderer: registered with ControlPoint");
|
tracing::info!(udn = %udn, "MediaRenderer: registered with ControlPoint");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
344
pmomediarenderer/src/renderer.rs
Normal file
344
pmomediarenderer/src/renderer.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
//! État partagé du renderer (backend ↔ pipeline)
|
//! État partagé du renderer (backend ↔ pipeline)
|
||||||
|
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::adapter::DeviceCommand;
|
||||||
use crate::messages::PlaybackState;
|
use crate::messages::PlaybackState;
|
||||||
|
|
||||||
/// État temps-réel du renderer (partagé backend ↔ navigateur)
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RendererState {
|
pub struct RendererState {
|
||||||
pub playback_state: PlaybackState,
|
pub playback_state: PlaybackState,
|
||||||
@@ -17,6 +18,17 @@ pub struct RendererState {
|
|||||||
pub duration: Option<String>,
|
pub duration: Option<String>,
|
||||||
pub volume: u16,
|
pub volume: u16,
|
||||||
pub mute: bool,
|
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 {
|
impl Default for RendererState {
|
||||||
@@ -31,9 +43,9 @@ impl Default for RendererState {
|
|||||||
duration: None,
|
duration: None,
|
||||||
volume: 100,
|
volume: 100,
|
||||||
mute: false,
|
mute: false,
|
||||||
|
pending_commands: VecDeque::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alias pour l'état partagé
|
|
||||||
pub type SharedState = Arc<RwLock<RendererState>>;
|
pub type SharedState = Arc<RwLock<RendererState>>;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmoparadise"
|
name = "pmoparadise"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
authors = ["PMOMusic Contributors"]
|
authors = ["PMOMusic Contributors"]
|
||||||
description = "Rust client for Radio Paradise streaming service"
|
description = "Rust client for Radio Paradise streaming service"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use pmoaudio::{
|
|||||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||||
pipeline::{send_to_children, send_to_children_with_timing, Node, NodeLogic},
|
pipeline::{send_to_children, send_to_children_with_timing, Node, NodeLogic},
|
||||||
type_constraints::TypeRequirement,
|
type_constraints::TypeRequirement,
|
||||||
AudioPipelineNode, AudioSegment, SyncMarker, I24,
|
AudioPipelineNode, AudioSegment, SyncMarker, I24, StreamType,
|
||||||
};
|
};
|
||||||
use pmoflac::decode_audio_stream;
|
use pmoflac::decode_audio_stream;
|
||||||
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
||||||
@@ -243,6 +243,7 @@ impl RadioParadiseStreamSourceLogic {
|
|||||||
let track_boundary = AudioSegment::new_track_boundary(
|
let track_boundary = AudioSegment::new_track_boundary(
|
||||||
*order, 0.0, // timestamp = 0 au début du stream
|
*order, 0.0, // timestamp = 0 au début du stream
|
||||||
metadata,
|
metadata,
|
||||||
|
StreamType::Continuous,
|
||||||
);
|
);
|
||||||
self.send_to_children(output, track_boundary).await?;
|
self.send_to_children(output, track_boundary).await?;
|
||||||
song_index = 1;
|
song_index = 1;
|
||||||
@@ -336,7 +337,7 @@ impl RadioParadiseStreamSourceLogic {
|
|||||||
let metadata = song_to_metadata(song, block).await;
|
let metadata = song_to_metadata(song, block).await;
|
||||||
let timestamp_sec = total_samples as f64 / sample_rate as f64;
|
let timestamp_sec = total_samples as f64 / sample_rate as f64;
|
||||||
let track_boundary =
|
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?;
|
self.send_to_children(output, track_boundary).await?;
|
||||||
|
|
||||||
// Passer à la song suivante
|
// Passer à la song suivante
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmoplaylist"
|
name = "pmoplaylist"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Caches PMO
|
# Caches PMO
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmoqobuz"
|
name = "pmoqobuz"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
regex = "1.12"
|
regex = "1.12"
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ impl QobuzSource {
|
|||||||
.await
|
.await
|
||||||
.ok();
|
.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 _ =
|
let _ =
|
||||||
self.inner
|
self.inner
|
||||||
.cache_manager
|
.cache_manager
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmoradiofrance"
|
name = "pmoradiofrance"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
authors = ["PMOMusic Contributors"]
|
authors = ["PMOMusic Contributors"]
|
||||||
description = "Rust client for Radio France streaming services"
|
description = "Rust client for Radio France streaming services"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ tracing-subscriber = { workspace = true }
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
async-stream = "0.3.6"
|
async-stream = "0.3.6"
|
||||||
axum-server = "0.7.2"
|
axum-server = "0.7.2"
|
||||||
axum-embed = "0.1.0"
|
|
||||||
rust-embed = "8.7.2"
|
rust-embed = "8.7.2"
|
||||||
|
mime_guess = "2"
|
||||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
pub mod config_ext;
|
pub mod config_ext;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
mod serve_embed;
|
||||||
|
|
||||||
pub use config_ext::ConfigExt;
|
pub use config_ext::ConfigExt;
|
||||||
pub use logs::{
|
pub use logs::{
|
||||||
|
|||||||
90
pmoserver/src/serve_embed.rs
Normal file
90
pmoserver/src/serve_embed.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ use axum::handler::Handler;
|
|||||||
use axum::response::Redirect;
|
use axum::response::Redirect;
|
||||||
use axum::routing::{any, get, post};
|
use axum::routing::{any, get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use axum_embed::ServeEmbed;
|
use crate::serve_embed::ServeEmbed;
|
||||||
use pmoconfig::get_config;
|
use pmoconfig::get_config;
|
||||||
use rust_embed::RustEmbed;
|
use rust_embed::RustEmbed;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -340,11 +340,7 @@ impl Server {
|
|||||||
where
|
where
|
||||||
E: RustEmbed + Clone + Send + Sync + 'static,
|
E: RustEmbed + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let serve = ServeEmbed::<E>::with_parameters(
|
let serve = ServeEmbed::<E>::with_spa_fallback("index.html".to_string());
|
||||||
Some("index.html".to_string()),
|
|
||||||
axum_embed::FallbackBehavior::Ok,
|
|
||||||
Some("index.html".to_string()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut r = self.router.write().await;
|
let mut r = self.router.write().await;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmosource"
|
name = "pmosource"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
authors = ["PMOMusic Contributors"]
|
authors = ["PMOMusic Contributors"]
|
||||||
description = "Common traits and types for PMOMusic sources"
|
description = "Common traits and types for PMOMusic sources"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -177,110 +177,27 @@ pub type ActionHandler = Arc<dyn Fn(ActionData) -> ActionFuture + Send + Sync>;
|
|||||||
|
|
||||||
/// Macro pour créer facilement un ActionHandler.
|
/// Macro pour créer facilement un ActionHandler.
|
||||||
///
|
///
|
||||||
/// Cette macro simplifie la création d'handlers asynchrones en cachant
|
/// Deux formes disponibles :
|
||||||
/// la complexité de `Arc`, `Box::pin`, et `async move`.
|
|
||||||
///
|
///
|
||||||
/// # Syntaxe
|
/// ## Forme simple (sans captures)
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// action_handler!(|data| {
|
/// action_handler!(|data| { Ok(data) })
|
||||||
/// // votre logique async avec ActionData
|
|
||||||
/// // Modifier les données et les retourner
|
|
||||||
/// Ok(data)
|
|
||||||
/// })
|
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// ## Forme avec captures (clonées automatiquement à chaque appel)
|
||||||
///
|
///
|
||||||
/// - `data` : Paramètre de type [`ActionData`] - HashMap contenant les valeurs des arguments
|
/// Pour capturer un état partagé ou un handle, utilisez `captures(...)`.
|
||||||
/// - Le corps du bloc peut contenir du code asynchrone (`.await`)
|
/// Chaque variable listée est clonée une fois par invocation du handler,
|
||||||
///
|
/// ce qui satisfait la contrainte `Fn` (et non `FnOnce`).
|
||||||
/// # Type de retour
|
|
||||||
///
|
|
||||||
/// La macro retourne un [`ActionHandler`] prêt à l'emploi.
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ## Exemple 1 : Handler simple (retourne les données telles quelles)
|
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use pmoupnp::action_handler;
|
/// let state: SharedState = ...;
|
||||||
///
|
/// let pipeline: PipelineHandle = ...;
|
||||||
/// 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 handler = action_handler!(captures(state, pipeline) |mut data| {
|
||||||
|
/// pipeline.send(PipelineControl::Play).await;
|
||||||
|
/// state.write().playback_state = PlaybackState::Playing;
|
||||||
/// Ok(data)
|
/// Ok(data)
|
||||||
/// });
|
/// });
|
||||||
/// ```
|
/// ```
|
||||||
@@ -288,14 +205,41 @@ pub type ActionHandler = Arc<dyn Fn(ActionData) -> ActionFuture + Send + Sync>;
|
|||||||
/// # Notes d'implémentation
|
/// # Notes d'implémentation
|
||||||
///
|
///
|
||||||
/// - Le bloc est automatiquement wrappé dans `async move`
|
/// - 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é
|
/// - Le résultat est automatiquement boxé et arcé
|
||||||
/// - Utilisez les macros `get!` et `set!` pour manipuler facilement les données
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! action_handler {
|
macro_rules! action_handler {
|
||||||
|
// ── Formes simples (sans captures externes) ──────────────────────────────
|
||||||
|
|
||||||
(|$data:ident| $body:block) => {
|
(|$data:ident| $body:block) => {
|
||||||
std::sync::Arc::new(|$data: $crate::actions::ActionData| {
|
std::sync::Arc::new(|$data: $crate::actions::ActionData| {
|
||||||
Box::pin(async move $body)
|
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)
|
||||||
|
})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pmowebrenderer"
|
name = "pmowebrenderer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pmoupnp = { path = "../pmoupnp" }
|
|
||||||
pmomediarenderer = { path = "../pmomediarenderer" }
|
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||||
pmoserver = { path = "../pmoserver", optional = true }
|
pmoserver = { path = "../pmoserver", optional = true }
|
||||||
pmocontrol = { path = "../pmocontrol", optional = true }
|
pmocontrol = { path = "../pmocontrol", optional = true }
|
||||||
pmoconfig = { path = "../pmoconfig" }
|
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
|
# Async runtime
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
tokio-util = { workspace = true }
|
tokio-util = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
# HTTP
|
# HTTP
|
||||||
axum = { workspace = true }
|
axum = "0.8.4"
|
||||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
axum-extra = "0.12"
|
||||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
tower-http = "0.6"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
reqwest = { workspace = true, features = ["stream"] }
|
|
||||||
bytes = "1.0"
|
bytes = "1.0"
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
@@ -34,14 +26,9 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
thiserror = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|
||||||
pmodidl = { path = "../pmodidl" }
|
|
||||||
pmoutils = { version = "0.1.2", registry = "pmo" }
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
pmoserver = ["dep:pmoserver", "dep:pmocontrol"]
|
pmoserver = ["dep:pmoserver", "dep:pmocontrol", "pmomediarenderer/pmoserver"]
|
||||||
|
|||||||
23
pmowebrenderer/src/adapter.rs
Normal file
23
pmowebrenderer/src/adapter.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,17 +7,23 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use axum::{Router, routing::{delete, get, post}};
|
use axum::{
|
||||||
|
Router,
|
||||||
|
routing::{delete, get, post},
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use pmocontrol::ControlPoint;
|
use pmocontrol::ControlPoint;
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::error::WebRendererError;
|
use pmomediarenderer::MediaRendererError;
|
||||||
#[cfg(feature = "pmoserver")]
|
#[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")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::registry::RendererRegistry;
|
use pmomediarenderer::MediaRendererRegistry;
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::stream::stream_handler;
|
use crate::stream::stream_handler;
|
||||||
|
|
||||||
@@ -28,7 +34,7 @@ pub trait WebRendererExt {
|
|||||||
async fn register_web_renderer(
|
async fn register_web_renderer(
|
||||||
&mut self,
|
&mut self,
|
||||||
control_point: Arc<ControlPoint>,
|
control_point: Arc<ControlPoint>,
|
||||||
) -> Result<(), WebRendererError>;
|
) -> Result<(), MediaRendererError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
@@ -37,8 +43,8 @@ impl WebRendererExt for pmoserver::Server {
|
|||||||
async fn register_web_renderer(
|
async fn register_web_renderer(
|
||||||
&mut self,
|
&mut self,
|
||||||
control_point: Arc<ControlPoint>,
|
control_point: Arc<ControlPoint>,
|
||||||
) -> Result<(), WebRendererError> {
|
) -> Result<(), MediaRendererError> {
|
||||||
let registry = Arc::new(RendererRegistry::new(control_point));
|
let registry = Arc::new(MediaRendererRegistry::new(control_point));
|
||||||
|
|
||||||
// POST /api/webrenderer/register
|
// POST /api/webrenderer/register
|
||||||
self.add_post_handler_with_state(
|
self.add_post_handler_with_state(
|
||||||
@@ -48,19 +54,30 @@ impl WebRendererExt for pmoserver::Server {
|
|||||||
)
|
)
|
||||||
.await;
|
.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()
|
let dynamic_router = Router::new()
|
||||||
.route("/{id}/stream", get(stream_handler))
|
.route("/{id}/stream", get(stream_handler))
|
||||||
.route("/{id}/position", post(position_update_handler))
|
|
||||||
.route("/{id}", delete(unregister_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());
|
.with_state(registry.clone());
|
||||||
self.add_router("/api/webrenderer", dynamic_router).await;
|
self.add_router("/api/webrenderer", dynamic_router).await;
|
||||||
|
|
||||||
tracing::info!("WebRenderer server-side streaming endpoints registered");
|
tracing::info!("WebRenderer server-side streaming endpoints registered");
|
||||||
tracing::info!(" POST /api/webrenderer/register");
|
tracing::info!(" POST /api/webrenderer/register");
|
||||||
tracing::info!(" GET /api/webrenderer/{{id}}/stream");
|
tracing::info!(" GET /api/webrenderer/{{id}}/stream");
|
||||||
tracing::info!(" POST /api/webrenderer/{{id}}/position");
|
|
||||||
tracing::info!(" DELETE /api/webrenderer/{{id}}");
|
tracing::info!(" DELETE /api/webrenderer/{{id}}");
|
||||||
|
tracing::info!(" GET /api/webrenderer/{{id}}/nowplaying");
|
||||||
|
tracing::info!(" GET /api/webrenderer/{{id}}/state");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -5,25 +5,14 @@
|
|||||||
//! - Le navigateur lit un flux FLAC via GET /api/webrenderer/{id}/stream
|
//! - Le navigateur lit un flux FLAC via GET /api/webrenderer/{id}/stream
|
||||||
//! - Les commandes UPnP sont relayées vers le pipeline audio via PipelineControl
|
//! - Les commandes UPnP sont relayées vers le pipeline audio via PipelineControl
|
||||||
|
|
||||||
mod error;
|
mod adapter;
|
||||||
mod handlers;
|
|
||||||
mod messages;
|
|
||||||
mod pipeline;
|
|
||||||
mod register;
|
mod register;
|
||||||
mod registry;
|
|
||||||
mod renderer;
|
|
||||||
mod state;
|
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
mod config;
|
mod config;
|
||||||
|
|
||||||
pub use error::WebRendererError;
|
pub use adapter::BrowserAdapter;
|
||||||
pub use messages::PlaybackState;
|
|
||||||
pub use pipeline::{PipelineControl, PipelineHandle};
|
|
||||||
pub use registry::{RendererRegistry, WebRendererInstance};
|
|
||||||
pub use renderer::{FactoryError, WebRendererFactory};
|
|
||||||
pub use state::{RendererState, SharedState};
|
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub use config::WebRendererExt;
|
pub use config::WebRendererExt;
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,27 @@
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::{StatusCode, header::HeaderMap},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RegisterRequest {
|
pub struct RegisterRequest {
|
||||||
@@ -24,11 +37,12 @@ pub struct RegisterRequest {
|
|||||||
pub struct RegisterResponse {
|
pub struct RegisterResponse {
|
||||||
pub stream_url: String,
|
pub stream_url: String,
|
||||||
pub udn: String,
|
pub udn: String,
|
||||||
|
pub should_play: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/webrenderer/register
|
#[axum::debug_handler]
|
||||||
pub async fn register_handler(
|
pub async fn register_handler(
|
||||||
State(registry): State<Arc<RendererRegistry>>,
|
State(registry): State<Arc<MediaRendererRegistry>>,
|
||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -38,17 +52,23 @@ pub async fn register_handler(
|
|||||||
);
|
);
|
||||||
|
|
||||||
match registry
|
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
|
.await
|
||||||
{
|
{
|
||||||
Ok((stream_url, udn)) => {
|
Ok((stream_url, udn, should_play)) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
instance_id = %req.instance_id,
|
instance_id = %req.instance_id,
|
||||||
stream_url = %stream_url,
|
stream_url = %stream_url,
|
||||||
udn = %udn,
|
udn = %udn,
|
||||||
|
should_play = %should_play,
|
||||||
"WebRenderer: registered"
|
"WebRenderer: registered"
|
||||||
);
|
);
|
||||||
(StatusCode::OK, Json(RegisterResponse { stream_url, udn })).into_response()
|
(StatusCode::OK, Json(RegisterResponse { stream_url, udn, should_play })).into_response()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -63,15 +83,13 @@ pub async fn register_handler(
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct PositionUpdateRequest {
|
pub struct PositionUpdateRequest {
|
||||||
pub position_sec: f64,
|
pub _position_sec: f64,
|
||||||
pub duration_sec: Option<f64>,
|
pub duration_sec: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/webrenderer/{id}/position
|
#[axum::debug_handler]
|
||||||
/// 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).
|
|
||||||
pub async fn position_update_handler(
|
pub async fn position_update_handler(
|
||||||
State(registry): State<Arc<RendererRegistry>>,
|
State(registry): State<Arc<MediaRendererRegistry>>,
|
||||||
Path(instance_id): Path<String>,
|
Path(instance_id): Path<String>,
|
||||||
Json(req): Json<PositionUpdateRequest>,
|
Json(req): Json<PositionUpdateRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
@@ -79,12 +97,201 @@ pub async fn position_update_handler(
|
|||||||
StatusCode::NO_CONTENT
|
StatusCode::NO_CONTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /api/webrenderer/{id}
|
#[axum::debug_handler]
|
||||||
pub async fn unregister_handler(
|
pub async fn unregister_handler(
|
||||||
State(registry): State<Arc<RendererRegistry>>,
|
State(registry): State<Arc<MediaRendererRegistry>>,
|
||||||
Path(instance_id): Path<String>,
|
Path(instance_id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
tracing::info!(instance_id = %instance_id, "WebRenderer: explicit unregister");
|
tracing::info!(instance_id = %instance_id, "WebRenderer: explicit unregister");
|
||||||
registry.schedule_unregister(&instance_id);
|
registry.schedule_unregister(&instance_id);
|
||||||
StatusCode::NO_CONTENT
|
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()
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,45 +9,46 @@ use axum::{
|
|||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{
|
http::{
|
||||||
HeaderMap, StatusCode,
|
|
||||||
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE, TRANSFER_ENCODING},
|
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE, TRANSFER_ENCODING},
|
||||||
|
HeaderMap, StatusCode,
|
||||||
},
|
},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
use tracing::info;
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::registry::RendererRegistry;
|
use pmomediarenderer::MediaRendererRegistry;
|
||||||
|
|
||||||
/// GET /api/webrenderer/{id}/stream
|
/// GET /api/webrenderer/{id}/stream
|
||||||
pub async fn stream_handler(
|
pub async fn stream_handler(
|
||||||
State(registry): State<Arc<RendererRegistry>>,
|
State(registry): State<Arc<MediaRendererRegistry>>,
|
||||||
Path(instance_id): Path<String>,
|
Path(instance_id): Path<String>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
info!(instance_id = %instance_id, "FLAC stream client connecting");
|
info!(instance_id = %instance_id, "FLAC stream client connecting");
|
||||||
|
|
||||||
// Ignorer le header Range — flux live infini, non seekable.
|
// 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") {
|
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) {
|
let stream = match registry.get_stream(&instance_id) {
|
||||||
Some(s) => s,
|
Some(s) => {
|
||||||
|
info!(instance_id = %instance_id, "Found instance, getting stream");
|
||||||
|
s
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
|
error!(instance_id = %instance_id, "No WebRenderer instance found!");
|
||||||
return (
|
return (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
format!("No WebRenderer instance for id={}", instance_id),
|
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()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.3.33
|
0.3.35
|
||||||
|
|||||||
Reference in New Issue
Block a user