🗑️ Remove unused imports, macros and dead code
- Drop `Path`/``State``` from unused Axum imports in config.rs and registry - Mark `_position_sec` field as `#[allow(dead_code)]`` in PositionUpdateRequest and PlayerStateReport - Remove unused macro rules (`add_action_arg!`, `add_action!``, `` add_var!)`` - Delete unused PlayerReport struct and related handler code
This commit is contained in:
@@ -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]
|
||||||
Browser (Vue.js Control Point)
|
B -->|commandes| C[Web Media Renderer]
|
||||||
┌───────────────┐
|
C -->|flux + contrôles| D[Device physique]
|
||||||
│ UI / audio │
|
|
||||||
│ WebSocket │
|
subgraph Devices physiques
|
||||||
└───────▲───────┘
|
D1[Browser]
|
||||||
│ token
|
D2[Android Auto]
|
||||||
│
|
D3[Apple CarPlay]
|
||||||
▼
|
D4[Sonos multipoint]
|
||||||
Rust backend (UPnP MediaRenderer)
|
D5[Chromecast]
|
||||||
┌───────────────────────────┐
|
end
|
||||||
│ Token → Renderer mapping │
|
|
||||||
│ Device XML / SOAP endpoints│
|
D --> D1
|
||||||
│ Play/Pause/Stop → WS → Browser │
|
D --> D2
|
||||||
└───────────────────────────┘
|
D --> D3
|
||||||
|
D --> D4
|
||||||
|
D --> D5
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Rôles
|
||||||
|
|
||||||
## 2. Étapes détaillées
|
1. **Media Server** - Source audio (le flux OGG-FLAC existant)
|
||||||
|
2. **Control Point** - Interface UI qui envoie les commandes (pause, play, seek, next, prev)
|
||||||
|
3. **Web Media Renderer** - Hub qui expose le flux et traduit les commandes selon le device
|
||||||
|
4. **Physical Device** - Lecteur final (browser, voiture, Sonos, Chromecast...)
|
||||||
|
|
||||||
### a) Création du renderer
|
## Web Media Renderer - Rôle central
|
||||||
|
|
||||||
1. Le navigateur se connecte via WebSocket ou HTTP.
|
```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)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
## Le Web Media Renderer - Adaptateur
|
||||||
|
|
||||||
## 3. Points clés
|
Le rôle central du Web Media Renderer est de **convertir des ordres UPnP en actions spécifiques** selon le device cible:
|
||||||
|
|
||||||
1. **Token unique** = session privée + sécurité
|
```
|
||||||
2. **Pas besoin de SSDP / annonce** : le renderer est dédié à un navigateur connu
|
UPnP orders → [Web Media Renderer] → Device-specific actions
|
||||||
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>`
|
|
||||||
|
|
||||||
---
|
## Browser Player - Composant web invisible
|
||||||
|
|
||||||
💡 Ce modèle est très proche de ce que font **Mopidy avec Iris**, **Kodi Remote**, ou **Chromecast / local cast** : le device est connu et dédié, pas besoin de découverte réseau.
|
Pour s'entraîner, on peut se focaliser sur un composant web qui:
|
||||||
|
- Est **complètement invisible** (pas de UI)
|
||||||
|
- Est **télécommandable** par le Web Media Renderer
|
||||||
|
- Joue la musique dans le navigateur
|
||||||
|
|
||||||
|
### Specifications
|
||||||
|
|
||||||
|
| Requirement | Description |
|
||||||
|
|-------------|-------------|
|
||||||
|
| Invisible | Pas de UI, pas de controls, pas de visuel |
|
||||||
|
| Remote control | Reçoit commandes via WebSocket/HTTP |
|
||||||
|
| Auto-reconnect | Reconnection si stream coupé |
|
||||||
|
| Buffer management | Flush commandée |
|
||||||
|
| Audio format | OGG-FLAC stream |
|
||||||
|
|
||||||
|
### Architecture en 2 parties
|
||||||
|
|
||||||
|
| Partie | Langage | Rôle |
|
||||||
|
|--------|---------|------|
|
||||||
|
| Backend | Rust (pmoaudio-ext) | Contrôle, flux OGG-FLAC |
|
||||||
|
| Frontend | JavaScript | Player invisible dans le browser |
|
||||||
|
|
||||||
|
#### Backend (Rust)
|
||||||
|
|
||||||
|
- Expose le flux audio (`/stream`)
|
||||||
|
- API contrôle (`/pause`, `/resume`, `/seek`, `/flush`, `/stop`)
|
||||||
|
- WebSocket pour temps réel (`/ws`)
|
||||||
|
- **Reçoit les rapports de position/state**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Endpoints existants
|
||||||
|
POST /pause
|
||||||
|
POST /resume
|
||||||
|
POST /seek?t={timestamp}
|
||||||
|
POST /flush
|
||||||
|
POST /stop
|
||||||
|
|
||||||
|
// Stream
|
||||||
|
GET /stream
|
||||||
|
|
||||||
|
// WebSocket messages REÇUS du player:
|
||||||
|
{
|
||||||
|
"type": "position",
|
||||||
|
"position_sec": 125.5,
|
||||||
|
"duration_sec": 240.0,
|
||||||
|
"state": "playing"
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"type": "track",
|
||||||
|
"id": "...",
|
||||||
|
"title": "...",
|
||||||
|
"artist": "..."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"type": "ready_state",
|
||||||
|
"ready_state": "canplay"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend (JavaScript)
|
||||||
|
|
||||||
|
Composant minimal (~100 lignes):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class RemotePlayer {
|
||||||
|
constructor(wsUrl) {
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
this.audio = new Audio();
|
||||||
|
this.ac = new AudioContext();
|
||||||
|
|
||||||
|
this.ws.onmessage = (e) => this.handle(e.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(msg) {
|
||||||
|
switch(msg.type) {
|
||||||
|
case 'stream': this.load(msg.url); break;
|
||||||
|
case 'play': this.play(); break;
|
||||||
|
case 'pause': this.pause(); break;
|
||||||
|
case 'seek': this.seek(msg.timestamp); break;
|
||||||
|
case 'flush': this.flush(); break;
|
||||||
|
case 'stop': this.stop(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load(url) {
|
||||||
|
this.audio.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
this.audio.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
this.audio.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(ts) {
|
||||||
|
this.audio.currentTime = ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
// Flush buffer immediatement
|
||||||
|
this.audio.pause();
|
||||||
|
this.audio.currentTime = 0;
|
||||||
|
this.audio.src = '';
|
||||||
|
this.ac.suspend();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="pmo-player.js"></script>
|
||||||
|
<script>
|
||||||
|
const player = new PMOPlayer('ws://localhost:8080/ws');
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fichier à créer
|
||||||
|
|
||||||
|
`pmoapp/webapp/src/services/PMOPlayer.ts`
|
||||||
|
|
||||||
|
### Endpoints HTTP
|
||||||
|
|
||||||
|
| Endpoint | Methode | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `/api/webrenderer/register` | POST | Enregistre instance |
|
||||||
|
| `/api/webrenderer/{id}/stream` | GET | Flux audio OGG-FLAC |
|
||||||
|
| `/api/webrenderer/{id}/position` | POST | Rapporte position |
|
||||||
|
| `/api/webrenderer/{id}/report` | POST | Rapporte etat player |
|
||||||
|
| `/api/webrenderer/{id}/command` | GET | Recupere commande pending |
|
||||||
|
| `/api/webrenderer/{id}` | DELETE | Desenregistre |
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Player (Browser) <--HTTP--> Backend
|
||||||
|
- Report: position/state via POST /report
|
||||||
|
- Poll: command via GET /command (500ms)
|
||||||
|
- Stream: GET /stream
|
||||||
|
```
|
||||||
|
|
||||||
|
### Réactivité
|
||||||
|
|
||||||
|
Pour maximiser la réactivité:
|
||||||
|
|
||||||
|
| Technique | Impact |
|
||||||
|
|-----------|--------|
|
||||||
|
| WebSocket | Temps réel vs HTTP polling |
|
||||||
|
| AudioContext.suspend() | Buffer ~50ms au lieu de ~5s |
|
||||||
|
| Flush command | Vide le buffer immediatement |
|
||||||
|
| Native HTML5 audio | Le plus simple = le plus stable |
|
||||||
|
|
||||||
|
### Schéma
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant WMR as Web Media Renderer
|
||||||
|
participant BP as Browser Player
|
||||||
|
|
||||||
|
WMR->>BP: stream(url)
|
||||||
|
BP->>BP: audio.src = url; play()
|
||||||
|
|
||||||
|
WMR->>BP: pause()
|
||||||
|
BP->>BP: audio.pause()
|
||||||
|
|
||||||
|
WMR->>BP: seek(timestamp)
|
||||||
|
BP->>BP: audio.currentTime = timestamp
|
||||||
|
|
||||||
|
WMR->>BP: flush()
|
||||||
|
BP->>BP: audioContext.suspend()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mapping orders → actions par device
|
||||||
|
|
||||||
|
| UPnP order | Browser | Android Auto | CarPlay | Sonos | Chromecast |
|
||||||
|
|-----------|---------|-------------|--------|------|-------------|
|
||||||
|
| Play | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
|
||||||
|
| Pause | `audio.pause()` | AA pause | CP pause | UPnP Pause | Cast pause |
|
||||||
|
| Resume | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
|
||||||
|
| Seek | `audio.currentTime=t` | AA seek | CP seek | UPnP Seek | Cast seek |
|
||||||
|
| Next | fetch new stream | AA next | CP next | UPnP Next | Cast next |
|
||||||
|
| Prev | fetch new stream | AA prev | CP prev | UPnP Prev | Cast prev |
|
||||||
|
| Flush | JS `audioContext.suspend()` | AA flush | CP flush | N/A | Cast load |
|
||||||
|
| Stop | `audio.stop()` | AA stop | CP stop | UPnP Stop | Cast stop |
|
||||||
|
|
||||||
|
### Protocole de contrôle
|
||||||
|
|
||||||
|
Le Web Media Renderer expose une API de contrôle uniforme qui est traduite selon le device:
|
||||||
|
|
||||||
|
### Commandes
|
||||||
|
|
||||||
|
| Commande | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `play` | Lecture |
|
||||||
|
| `pause` | Pause (silence ou backpressure) |
|
||||||
|
| `resume` | Reprise |
|
||||||
|
| `seek(t)` | Seek vers timestamp t |
|
||||||
|
| `next` | Track suivante |
|
||||||
|
| `prev` | Track précédente |
|
||||||
|
| `flush` | **Flush buffer** - ordre critique pour reponse rapide |
|
||||||
|
| `stop` | Arrêt total |
|
||||||
|
|
||||||
|
### Métadonnées
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `/nowplaying` | Track actuelle, timestamp, is_paused |
|
||||||
|
| `/metadata` | TITLE, ARTIST, ALBUM, COVER |
|
||||||
|
| `/state` | État complet (position, duration, volume...) |
|
||||||
|
|
||||||
|
### Ordres spéciaux pour devices avec buffer
|
||||||
|
|
||||||
|
Pour les devices типа Android Auto, CarPlay, Browser:
|
||||||
|
- `flush` = vide le buffer immédiatement
|
||||||
|
- `stop` = arrête + flush
|
||||||
|
- Ces ordres doivent être traités en priorité
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant CP as Control Point
|
||||||
|
participant WMR as Web Media Renderer
|
||||||
|
participant D as Device (Android Auto, Browser...)
|
||||||
|
|
||||||
|
CP->>WMR: flush()
|
||||||
|
Note over WMR: Priorité haute - immédiat
|
||||||
|
WMR->>D: FLUSH order
|
||||||
|
D-->>WMR: ack
|
||||||
|
WMR-->>CP: flushed
|
||||||
|
|
||||||
|
CP->>WMR: pause()
|
||||||
|
Note over WMR: Standard
|
||||||
|
WMR->>D: flux with silence
|
||||||
|
```
|
||||||
|
|
||||||
|
## multipoint/multi-room
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
WMR[Web Media Renderer] -->|flux| R1[Renderer 1]
|
||||||
|
WMR -->|flux| R2[Renderer 2]
|
||||||
|
WMR -->|flux| R3[Renderer N]
|
||||||
|
|
||||||
|
R1 -->|sync| R2
|
||||||
|
R2 -->|sync| R3
|
||||||
|
```
|
||||||
|
|
||||||
|
Possibilités:
|
||||||
|
- UPnP pour renderers UPnP
|
||||||
|
- Cast API pour Chromecast
|
||||||
|
- Serveur temps réel pour sync
|
||||||
|
|
||||||
|
## Notes techniques
|
||||||
|
|
||||||
|
### AudioChunk::silence()
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl AudioChunk {
|
||||||
|
pub fn silence(frames: usize, sample_rate: u32) -> Self {
|
||||||
|
AudioChunk::I32(AudioChunkData::<i32>::silence(frames, sample_rate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Sample> AudioChunkData<T> {
|
||||||
|
pub fn silence(frames: usize, sample_rate: u32) -> Arc<Self> {
|
||||||
|
Self::new(vec![[T::ZERO; 2]; frames], sample_rate, 0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StreamType
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum StreamType {
|
||||||
|
Continuous, // radio - silence pendant pause
|
||||||
|
Finite, // tracks - backpressure pendant pause
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TrackBoundary avec StreamType
|
||||||
|
|
||||||
|
```rust
|
||||||
|
SyncMarker::TrackBoundary {
|
||||||
|
metadata: ...,
|
||||||
|
stream_type: StreamType
|
||||||
|
}
|
||||||
|
```
|
||||||
83
Cargo.lock
generated
83
Cargo.lock
generated
@@ -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"
|
||||||
@@ -4280,10 +4220,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",
|
||||||
@@ -4408,6 +4348,7 @@ dependencies = [
|
|||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5525,12 +5466,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"
|
||||||
|
|||||||
@@ -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,98 +83,80 @@ 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 ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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", () => void unregister());
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
sseUnsubscribe?.();
|
|
||||||
sseUnsubscribe = null;
|
|
||||||
stopPositionReporting();
|
|
||||||
stopStream();
|
|
||||||
void unregister();
|
void unregister();
|
||||||
if (audioEl) {
|
|
||||||
audioEl.remove();
|
|
||||||
audioEl = null;
|
|
||||||
}
|
|
||||||
connected.value = false;
|
|
||||||
streamUrl.value = null;
|
|
||||||
rendererUdn.value = null;
|
|
||||||
window.removeEventListener("beforeunload", () => void unregister());
|
window.removeEventListener("beforeunload", () => 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;
|
||||||
},
|
},
|
||||||
setVolume,
|
setVolume,
|
||||||
setMute,
|
setMute,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
369
pmoapp/webapp/src/services/PMOPlayer.ts
Normal file
369
pmoapp/webapp/src/services/PMOPlayer.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* 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 ac: AudioContext | null = null;
|
||||||
|
private instanceId: string;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 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(() => {
|
||||||
|
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', () => {
|
||||||
|
// Ignorer l'erreur produite par flush() (removeAttribute('src') + load())
|
||||||
|
if (!this.audio.getAttribute('src')) return;
|
||||||
|
this.log('error event', this.audio.error);
|
||||||
|
this.setState('error');
|
||||||
|
this.listeners.error?.(this.audio.error?.message || 'unknown error');
|
||||||
|
});
|
||||||
|
|
||||||
|
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': {
|
||||||
|
let url = msg.url as string;
|
||||||
|
if (url.startsWith('/api/webrenderer/stream')) {
|
||||||
|
url = `/api/webrenderer/${this.instanceId}/stream`;
|
||||||
|
}
|
||||||
|
this.playStream(url);
|
||||||
|
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) {
|
||||||
|
// URL stored in audio.src directly
|
||||||
|
this.log('stream:', url);
|
||||||
|
this.audio.src = url;
|
||||||
|
this.audio.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
playStream(url: string) {
|
||||||
|
this.stream(url);
|
||||||
|
this.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
this.log('play()');
|
||||||
|
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.stop();
|
||||||
|
this.stopPositionReporting();
|
||||||
|
if (this.commandInterval !== null) {
|
||||||
|
clearInterval(this.commandInterval);
|
||||||
|
this.commandInterval = null;
|
||||||
|
}
|
||||||
|
if (this.unlockListener) {
|
||||||
|
document.removeEventListener('click', this.unlockListener);
|
||||||
|
this.unlockListener = null;
|
||||||
|
}
|
||||||
|
this.audio.remove();
|
||||||
|
this.ac?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -442,30 +573,33 @@ impl StreamingOggFlacSink {
|
|||||||
|
|
||||||
let logic = StreamingOggFlacSinkLogic {
|
let logic = StreamingOggFlacSinkLogic {
|
||||||
ctx: SharedSinkContext {
|
ctx: SharedSinkContext {
|
||||||
encoder_options,
|
encoder_options,
|
||||||
bits_per_sample,
|
bits_per_sample,
|
||||||
enable_total_samples: options.enable_total_samples,
|
enable_total_samples: options.enable_total_samples,
|
||||||
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
|
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
|
||||||
default_title: options.default_title.clone(),
|
default_title: options.default_title.clone(),
|
||||||
default_artist: options.default_artist.clone(),
|
default_artist: options.default_artist.clone(),
|
||||||
use_only_default_metadata: options.use_only_default_metadata,
|
use_only_default_metadata: options.use_only_default_metadata,
|
||||||
pcm_tx: Some(pcm_tx),
|
pcm_tx: Some(pcm_tx),
|
||||||
pcm_rx: Some(pcm_rx),
|
pcm_rx: Some(pcm_rx),
|
||||||
metadata,
|
metadata,
|
||||||
broadcast,
|
broadcast,
|
||||||
header,
|
header,
|
||||||
encoder_state: None,
|
encoder_state: None,
|
||||||
sample_rate: None,
|
sample_rate: None,
|
||||||
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
|
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
|
||||||
first_chunk_timestamp_checked: false,
|
first_chunk_timestamp_checked: false,
|
||||||
timestamp_offset_sec: 0.0,
|
timestamp_offset_sec: 0.0,
|
||||||
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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ 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"] }
|
reqwest = { workspace = true, features = ["stream"] }
|
||||||
bytes = "1.0"
|
bytes = "1.0"
|
||||||
|
|
||||||
|
# OpenAPI
|
||||||
|
utoipa = { version = "5.3", features = ["axum_extras"] }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ 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,
|
||||||
|
extract::{Path, State},
|
||||||
|
routing::{delete, get, post},
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use pmocontrol::ControlPoint;
|
use pmocontrol::ControlPoint;
|
||||||
@@ -15,7 +19,10 @@ use pmocontrol::ControlPoint;
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::error::WebRendererError;
|
use crate::error::WebRendererError;
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::register::{position_update_handler, register_handler, unregister_handler};
|
use crate::register::{
|
||||||
|
pause_handler, play_handler, position_update_handler, register_handler,
|
||||||
|
report_handler, set_uri_handler, unregister_handler,
|
||||||
|
};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::registry::RendererRegistry;
|
use crate::registry::RendererRegistry;
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
@@ -48,18 +55,25 @@ 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))
|
||||||
.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}}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,30 @@ pub fn play_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandl
|
|||||||
let pipeline = pipeline.clone();
|
let pipeline = pipeline.clone();
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
tracing::info!("[WebRenderer] UPnP Play action invoked");
|
||||||
// Ne pas écrire Playing ici : c'est stream_source qui le fera
|
// Ne pas écrire Playing ici : c'est stream_source qui le fera
|
||||||
// une fois que les premiers bytes FLAC ont été produits.
|
// une fois que les premiers bytes FLAC ont été produits.
|
||||||
// Écrire Transitioning pour signaler que la lecture va démarrer.
|
// Écrire Transitioning pour signaler que la lecture va démarrer.
|
||||||
state.write().playback_state = PlaybackState::Transitioning;
|
|
||||||
|
// Check if URI is loaded FIRST, then write state
|
||||||
|
let has_uri = state.read().current_uri.is_some();
|
||||||
|
|
||||||
|
// Single write to update playback_state - avoid holding read lock
|
||||||
|
{
|
||||||
|
let mut s = state.write();
|
||||||
|
s.playback_state = PlaybackState::Transitioning;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell frontend to start streaming - include the stream URL
|
||||||
|
if has_uri {
|
||||||
|
// Use a single write to set player_command
|
||||||
|
state.write().player_command = Some(serde_json::json!({
|
||||||
|
"type": "stream",
|
||||||
|
"url": "/api/webrenderer/stream" // Frontend will prefix with instance ID
|
||||||
|
}));
|
||||||
|
tracing::info!("UPnP Play: stored stream command for frontend polling");
|
||||||
|
}
|
||||||
|
|
||||||
pipeline.send(PipelineControl::Play).await;
|
pipeline.send(PipelineControl::Play).await;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
})
|
})
|
||||||
@@ -95,6 +115,7 @@ pub fn set_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHa
|
|||||||
let pipeline = pipeline.clone();
|
let pipeline = pipeline.clone();
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
tracing::info!("[WebRenderer] UPnP SetAVTransportURI action invoked");
|
||||||
let uri: String = get!(&data, "CurrentURI", String);
|
let uri: String = get!(&data, "CurrentURI", String);
|
||||||
let metadata: String = get_value::<String>(&data, "CurrentURIMetaData")
|
let metadata: String = get_value::<String>(&data, "CurrentURIMetaData")
|
||||||
.or_else(|_| {
|
.or_else(|_| {
|
||||||
@@ -103,6 +124,8 @@ pub fn set_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHa
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
tracing::info!(uri = %uri, "SetAVTransportURI handler called - loading URI into pipeline");
|
||||||
|
|
||||||
// Envoyer l'URI au pipeline serveur (remplace l'envoi WebSocket)
|
// Envoyer l'URI au pipeline serveur (remplace l'envoi WebSocket)
|
||||||
pipeline.send(PipelineControl::LoadUri(uri.clone())).await;
|
pipeline.send(PipelineControl::LoadUri(uri.clone())).await;
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ pub enum TransportAction {
|
|||||||
Seek,
|
Seek,
|
||||||
SetUri,
|
SetUri,
|
||||||
SetNextUri,
|
SetNextUri,
|
||||||
|
/// Flush buffer immediatement - pour reponse rapide
|
||||||
|
Flush,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -84,6 +86,11 @@ pub enum ClientMessage {
|
|||||||
/// Envoyé quand la piste courante se termine naturellement (gapless).
|
/// Envoyé quand la piste courante se termine naturellement (gapless).
|
||||||
/// Le backend fait avancer current → next dans l'état partagé.
|
/// Le backend fait avancer current → next dans l'état partagé.
|
||||||
TrackEnded,
|
TrackEnded,
|
||||||
|
/// Ready state du player HTML5 audio
|
||||||
|
/// have_nothing, have_metadata, have_current_data, have_future_data, can_play, can_play_through
|
||||||
|
ReadyStateUpdate {
|
||||||
|
ready_state: String,
|
||||||
|
},
|
||||||
Pong,
|
Pong,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::{StatusCode, header::HeaderMap},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
@@ -24,9 +24,12 @@ pub struct RegisterRequest {
|
|||||||
pub struct RegisterResponse {
|
pub struct RegisterResponse {
|
||||||
pub stream_url: String,
|
pub stream_url: String,
|
||||||
pub udn: String,
|
pub udn: String,
|
||||||
|
/// true si le backend est déjà en lecture — le frontend doit démarrer immédiatement
|
||||||
|
pub should_play: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/webrenderer/register
|
/// 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<RendererRegistry>>,
|
||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
@@ -41,14 +44,15 @@ pub async fn register_handler(
|
|||||||
.register_or_reconnect(&req.instance_id, &req.user_agent)
|
.register_or_reconnect(&req.instance_id, &req.user_agent)
|
||||||
.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!(
|
||||||
@@ -70,6 +74,7 @@ pub struct PositionUpdateRequest {
|
|||||||
/// POST /api/webrenderer/{id}/position
|
/// POST /api/webrenderer/{id}/position
|
||||||
/// position_sec est ignoré (géré par PlayerEvent::Position côté serveur).
|
/// 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).
|
/// duration_sec est utilisé comme fallback si la source ne connaît pas la durée (flux radio).
|
||||||
|
#[axum::debug_handler]
|
||||||
pub async fn position_update_handler(
|
pub async fn position_update_handler(
|
||||||
State(registry): State<Arc<RendererRegistry>>,
|
State(registry): State<Arc<RendererRegistry>>,
|
||||||
Path(instance_id): Path<String>,
|
Path(instance_id): Path<String>,
|
||||||
@@ -80,6 +85,7 @@ pub async fn position_update_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /api/webrenderer/{id}
|
/// 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<RendererRegistry>>,
|
||||||
Path(instance_id): Path<String>,
|
Path(instance_id): Path<String>,
|
||||||
@@ -88,3 +94,106 @@ pub async fn unregister_handler(
|
|||||||
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/webrenderer/{id}/set_uri - charge une URI et joue
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn set_uri_handler(
|
||||||
|
State(registry): State<Arc<RendererRegistry>>,
|
||||||
|
Path(instance_id): Path<String>,
|
||||||
|
Json(req): Json<UriRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
tracing::info!(instance_id = %instance_id, uri = %req.uri, "WebRenderer: set_uri request");
|
||||||
|
registry.load_uri(&instance_id, req.uri).await;
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/webrenderer/{id}/pause
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn pause_handler(
|
||||||
|
State(registry): State<Arc<RendererRegistry>>,
|
||||||
|
Path(instance_id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
tracing::info!(instance_id = %instance_id, "WebRenderer: pause request");
|
||||||
|
registry.send_pause_command(&instance_id).await;
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rapports du player ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PlayerStateReport {
|
||||||
|
pub position_sec: Option<f64>,
|
||||||
|
pub duration_sec: Option<f64>,
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub ready_state: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PlayerReport {
|
||||||
|
pub instance_id: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub report: PlayerStateReport,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/webrenderer/{id}/report - recoit rapports position/state du player
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn report_handler(
|
||||||
|
State(registry): State<Arc<RendererRegistry>>,
|
||||||
|
Path(instance_id): Path<String>,
|
||||||
|
Json(report): Json<PlayerStateReport>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Registry met à jour l'état avec les rapports du player
|
||||||
|
registry.update_player_state(&instance_id, report).await;
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Commandes vers le player ─────────────────────────────────
|
||||||
|
|
||||||
|
/// GET /api/webrenderer/{id}/command - recupere commande pending pour le player
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn command_handler(
|
||||||
|
State(registry): State<Arc<RendererRegistry>>,
|
||||||
|
Path(instance_id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match registry.get_pending_command(&instance_id).await {
|
||||||
|
Some(cmd) => (StatusCode::OK, Json(cmd)).into_response(),
|
||||||
|
None => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/webrenderer/{id}/play - tell player to stream and play
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn play_handler(
|
||||||
|
State(registry): State<Arc<RendererRegistry>>,
|
||||||
|
Path(instance_id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Check if there's a valid URI loaded - if not, ignore the play command
|
||||||
|
if !registry.has_current_uri(&instance_id) {
|
||||||
|
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");
|
||||||
|
|
||||||
|
// Get stream URL and tell player to play it
|
||||||
|
let stream_url = format!("/api/webrenderer/{}/stream", instance_id);
|
||||||
|
|
||||||
|
// Set command for player to start streaming
|
||||||
|
let command = serde_json::json!({
|
||||||
|
"type": "stream",
|
||||||
|
"url": stream_url
|
||||||
|
});
|
||||||
|
registry.set_player_command(&instance_id, command);
|
||||||
|
|
||||||
|
// Also tell pipeline to play (if not already) - use existing method
|
||||||
|
registry.send_play_command(&instance_id).await;
|
||||||
|
|
||||||
|
(StatusCode::OK, "OK").into_response()
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
//! Remplace `SessionManager` et `websocket.rs`. La session est maintenant liée
|
//! Remplace `SessionManager` et `websocket.rs`. La session est maintenant liée
|
||||||
//! au flux FLAC HTTP, pas à une connexion WebSocket.
|
//! au flux FLAC HTTP, pas à une connexion WebSocket.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -68,12 +73,13 @@ impl RendererRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Enregistre ou reconnecte une instance.
|
/// Enregistre ou reconnecte une instance.
|
||||||
/// Retourne `(stream_url, udn)`.
|
/// Retourne `(stream_url, udn, should_play)`.
|
||||||
|
/// `should_play` est true si le backend est déjà en lecture : le frontend doit démarrer immédiatement.
|
||||||
pub async fn register_or_reconnect(
|
pub async fn register_or_reconnect(
|
||||||
&self,
|
&self,
|
||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
user_agent: &str,
|
user_agent: &str,
|
||||||
) -> Result<(String, String), WebRendererError> {
|
) -> Result<(String, String, bool), WebRendererError> {
|
||||||
// Annuler tout unregister différé pour cet instance_id
|
// Annuler tout unregister différé pour cet instance_id
|
||||||
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, "WebRenderer: cancelled pending unregister (page reload)");
|
||||||
@@ -88,7 +94,14 @@ impl RendererRegistry {
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
self.register_with_control_point(&existing.device_instance)?;
|
self.register_with_control_point(&existing.device_instance)?;
|
||||||
let stream_url = format!("/api/webrenderer/{}/stream", instance_id);
|
let stream_url = format!("/api/webrenderer/{}/stream", 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +126,7 @@ impl RendererRegistry {
|
|||||||
"WebRenderer: new instance registered"
|
"WebRenderer: new instance registered"
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok((stream_url, udn))
|
Ok((stream_url, udn, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retourne un OggFlacClientStream indépendant pour l'endpoint /stream.
|
/// Retourne un OggFlacClientStream indépendant pour l'endpoint /stream.
|
||||||
@@ -122,10 +135,17 @@ impl RendererRegistry {
|
|||||||
&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)
|
/// Retourne le PipelineHandle par UDN (pour les handlers UPnP)
|
||||||
@@ -208,6 +228,97 @@ impl RendererRegistry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Met à jour l'état avec les rapports du player
|
||||||
|
pub async fn update_player_state(
|
||||||
|
&self,
|
||||||
|
instance_id: &str,
|
||||||
|
report: crate::register::PlayerStateReport,
|
||||||
|
) {
|
||||||
|
let instances = self.instances.read();
|
||||||
|
if let Some(instance) = instances.get(instance_id) {
|
||||||
|
let mut state = instance.state.write();
|
||||||
|
if let Some(pos) = report.position_sec {
|
||||||
|
state.position = Some(pos.to_string());
|
||||||
|
}
|
||||||
|
if let Some(dur) = report.duration_sec {
|
||||||
|
state.duration = Some(dur.to_string());
|
||||||
|
}
|
||||||
|
if let Some(s) = &report.state {
|
||||||
|
state.playback_state = match s.as_str() {
|
||||||
|
"playing" => crate::messages::PlaybackState::Playing,
|
||||||
|
"paused" => crate::messages::PlaybackState::Paused,
|
||||||
|
"stopped" => crate::messages::PlaybackState::Stopped,
|
||||||
|
_ => state.playback_state.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
tracing::debug!(instance_id = %instance_id, position = ?state.position, "player state updated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère et consomme la commande en attente pour le player
|
||||||
|
pub async fn get_pending_command(
|
||||||
|
&self,
|
||||||
|
instance_id: &str,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
self.instances
|
||||||
|
.read()
|
||||||
|
.get(instance_id)
|
||||||
|
.and_then(|instance| instance.state.write().player_command.take())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stocke une commande pour le player (consommée via GET /command)
|
||||||
|
pub fn set_player_command(&self, instance_id: &str, command: serde_json::Value) {
|
||||||
|
if let Some(instance) = self.instances.read().get(instance_id) {
|
||||||
|
instance.state.write().player_command = Some(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge une URI dans le pipeline
|
||||||
|
pub async fn load_uri(&self, instance_id: &str, uri: String) {
|
||||||
|
// Get pipeline handle before async call to avoid holding lock across await
|
||||||
|
let pipeline = self.instances.read().get(instance_id).map(|i| i.pipeline.clone());
|
||||||
|
if let Some(pipeline) = pipeline {
|
||||||
|
use pmoaudio_ext::PlayerCommand;
|
||||||
|
pipeline.send(PlayerCommand::LoadUri(uri.clone())).await;
|
||||||
|
pipeline.send(PlayerCommand::Play).await;
|
||||||
|
tracing::info!(instance_id = %instance_id, uri = %uri, "loaded URI");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie commande play au pipeline
|
||||||
|
pub async fn send_play_command(&self, instance_id: &str) {
|
||||||
|
tracing::info!(instance_id = %instance_id, "send_play_command called");
|
||||||
|
// Get pipeline handle before async call to avoid holding lock across await
|
||||||
|
let pipeline = self.instances.read().get(instance_id).map(|i| i.pipeline.clone());
|
||||||
|
if let Some(pipeline) = pipeline {
|
||||||
|
tracing::info!(instance_id = %instance_id, "Instance found, sending PlayerCommand::Play");
|
||||||
|
use pmoaudio_ext::PlayerCommand;
|
||||||
|
pipeline.send(PlayerCommand::Play).await;
|
||||||
|
tracing::info!(instance_id = %instance_id, "PlayerCommand::Play sent");
|
||||||
|
} else {
|
||||||
|
tracing::error!(instance_id = %instance_id, "Instance not found in send_play_command!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie commande pause au pipeline
|
||||||
|
pub async fn send_pause_command(&self, instance_id: &str) {
|
||||||
|
// Get pipeline handle before async call to avoid holding lock across await
|
||||||
|
let pipeline = self.instances.read().get(instance_id).map(|i| i.pipeline.clone());
|
||||||
|
if let Some(pipeline) = pipeline {
|
||||||
|
use pmoaudio_ext::PlayerCommand;
|
||||||
|
pipeline.send(PlayerCommand::Pause).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the instance has a current URI loaded
|
||||||
|
pub fn has_current_uri(&self, instance_id: &str) -> bool {
|
||||||
|
self.instances
|
||||||
|
.read()
|
||||||
|
.get(instance_id)
|
||||||
|
.map(|i| i.state.read().current_uri.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Création d'instance ────────────────────────────────────────────────────
|
// ── Création d'instance ────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn create_instance(
|
async fn create_instance(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! État partagé du renderer (backend ↔ pipeline)
|
//! État partagé du renderer (backend ↔ pipeline)
|
||||||
|
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::messages::PlaybackState;
|
use crate::messages::PlaybackState;
|
||||||
@@ -17,6 +18,8 @@ pub struct RendererState {
|
|||||||
pub duration: Option<String>,
|
pub duration: Option<String>,
|
||||||
pub volume: u16,
|
pub volume: u16,
|
||||||
pub mute: bool,
|
pub mute: bool,
|
||||||
|
/// Commande en attente pour le player frontend (polled via /command)
|
||||||
|
pub player_command: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RendererState {
|
impl Default for RendererState {
|
||||||
@@ -31,6 +34,7 @@ impl Default for RendererState {
|
|||||||
duration: None,
|
duration: None,
|
||||||
volume: 100,
|
volume: 100,
|
||||||
mute: false,
|
mute: false,
|
||||||
|
player_command: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ 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 crate::registry::RendererRegistry;
|
||||||
|
|
||||||
@@ -29,25 +29,26 @@ pub async fn stream_handler(
|
|||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user