🗑️ 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)
|
||||
- Tu as un prototype de Média Renderer dans: [@pmomediarenderer](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmomediarenderer)
|
||||
- Le contrôle point est dans : [@pmocontrol](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol)
|
||||
- Tu implémenteras ce nouveau système de Média Renderer dans la CRATe pmowebrenderer
|
||||
## Vision
|
||||
|
||||
Tu mettras une version du plan en Markdown dans le répertoire [@Architecture](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Architecture) .
|
||||
Système de Media Renderer pilotable à distance via UPnP, exposant un flux audio vers différents types de lecteurs physiques.
|
||||
|
||||
---
|
||||
## Architecture globale en 4 parties
|
||||
|
||||
## 1. Flow général
|
||||
|
||||
```
|
||||
Browser (Vue.js Control Point)
|
||||
┌───────────────┐
|
||||
│ UI / audio │
|
||||
│ WebSocket │
|
||||
└───────▲───────┘
|
||||
│ token
|
||||
│
|
||||
▼
|
||||
Rust backend (UPnP MediaRenderer)
|
||||
┌───────────────────────────┐
|
||||
│ Token → Renderer mapping │
|
||||
│ Device XML / SOAP endpoints│
|
||||
│ Play/Pause/Stop → WS → Browser │
|
||||
└───────────────────────────┘
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Media Server] -->|flux audio| B[Control Point]
|
||||
B -->|commandes| C[Web Media Renderer]
|
||||
C -->|flux + contrôles| D[Device physique]
|
||||
|
||||
subgraph Devices physiques
|
||||
D1[Browser]
|
||||
D2[Android Auto]
|
||||
D3[Apple CarPlay]
|
||||
D4[Sonos multipoint]
|
||||
D5[Chromecast]
|
||||
end
|
||||
|
||||
D --> D1
|
||||
D --> D2
|
||||
D --> D3
|
||||
D --> D4
|
||||
D --> D5
|
||||
```
|
||||
|
||||
---
|
||||
### Rôles
|
||||
|
||||
## 2. Étapes détaillées
|
||||
1. **Media Server** - Source audio (le flux OGG-FLAC existant)
|
||||
2. **Control Point** - Interface UI qui envoie les commandes (pause, play, seek, next, prev)
|
||||
3. **Web Media Renderer** - Hub qui expose le flux et traduit les commandes selon le device
|
||||
4. **Physical Device** - Lecteur final (browser, voiture, Sonos, Chromecast...)
|
||||
|
||||
### a) Création du renderer
|
||||
## Web Media Renderer - Rôle central
|
||||
|
||||
1. Le navigateur se connecte via WebSocket ou HTTP.
|
||||
2. Rust génère un token unique pour ce client :
|
||||
|
||||
```rust
|
||||
use uuid::Uuid;
|
||||
let token = Uuid::new_v4().to_string();
|
||||
```
|
||||
3. Rust crée une instance MediaRenderer **privée**, associée à ce token :
|
||||
|
||||
* Device description XML : `/renderer/<token>/desc.xml`
|
||||
* AVTransport SOAP : `/renderer/<token>/avtransport`
|
||||
* RenderingControl SOAP : `/renderer/<token>/renderingcontrol`
|
||||
|
||||
---
|
||||
|
||||
### b) Control Point
|
||||
|
||||
* La webapp Vue.js reçoit le token et la “déclare” au Control Point :
|
||||
|
||||
```js
|
||||
const renderer = {
|
||||
token: "abcd-1234-efgh",
|
||||
name: "Browser Renderer"
|
||||
};
|
||||
|
||||
// Ajout au control point local
|
||||
controlPoint.addRenderer(renderer);
|
||||
```
|
||||
|
||||
* Toutes les commandes Play/Pause/Stop incluent ce token :
|
||||
|
||||
```js
|
||||
ws.send(JSON.stringify({
|
||||
token: renderer.token,
|
||||
action: "play",
|
||||
uri: "http://localhost:8080/media.mp3"
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### c) Backend Rust : dispatcher les commandes
|
||||
|
||||
* Rust reçoit le JSON avec le token.
|
||||
* Vérifie que le token correspond à un renderer actif.
|
||||
* Transmet la commande au navigateur via WebSocket (ou HTTP push) :
|
||||
|
||||
```rust
|
||||
match msg.action.as_str() {
|
||||
"play" => send_ws_to_browser(&token, format!("play:{}", msg.uri)),
|
||||
"pause" => send_ws_to_browser(&token, "pause".to_string()),
|
||||
"stop" => send_ws_to_browser(&token, "stop".to_string()),
|
||||
_ => (),
|
||||
```mermaid
|
||||
blockdiag
|
||||
{
|
||||
block = Commandes UPnP
|
||||
block -> "Web Media Renderer" -> Adaptation selon device
|
||||
"Web Media Renderer" -> Device-specific protocols
|
||||
}
|
||||
```
|
||||
|
||||
* Rust met à jour l’état du renderer (AVTransport/RenderingControl) pour le Control Point.
|
||||
### Rôle central: Adaptateur
|
||||
|
||||
---
|
||||
Le Web Media Renderer est un **adaptateur** qui:
|
||||
- **Reçoit le flux** du Media Server (OGG-FLAC)
|
||||
- **Reçoit les commandes** du Control Point (UPnP)
|
||||
- **Les traduit** vers les devices physiques
|
||||
- **Expose une API de contrôle** commune
|
||||
|
||||
### d) Lecture côté navigateur
|
||||
### Ce qui est COMMUN (factorisé)
|
||||
|
||||
* Le navigateur reçoit la commande via WebSocket et pilote `<audio>` :
|
||||
| Layer | Description |
|
||||
|-------|-------------|
|
||||
| **API contrôle** | pause, resume, seek, next, prev, flush, stop |
|
||||
| **Métadonnées** | /nowplaying, /metadata, /state |
|
||||
| **Flux audio** | OGG-FLAC (identique pour tous) |
|
||||
| **StreamType** | Continuous vs Finite |
|
||||
|
||||
```js
|
||||
ws.onmessage = (evt) => {
|
||||
const msg = evt.data;
|
||||
if(msg.startsWith("play:")) {
|
||||
audio.src = msg.split(":")[1];
|
||||
audio.play();
|
||||
} else if(msg === "pause") {
|
||||
audio.pause();
|
||||
} else if(msg === "stop") {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
};
|
||||
### Ce qui est SPÉCIFIQUE (par device)
|
||||
|
||||
| Device | Transport | Buffer Management | Sync |
|
||||
|--------|-----------|-------------------|------|
|
||||
| Browser | HTTP/WebSocket | JS flush | N/A |
|
||||
| Android Auto | AA API | native | varies |
|
||||
| CarPlay | CP API | native | varies |
|
||||
| Sonos | UPnP | none | UPnP |
|
||||
| Chromecast | Cast API | none | Cast |
|
||||
|
||||
### Problème du buffer (Browser)
|
||||
|
||||
Le browser buffer cause des delais de reaction:
|
||||
- **Pause**: delai de 5 secondes
|
||||
- **Seek**: cherche dans le buffer, pas dans le nouveau flux
|
||||
- **Next/Prev**: changement reporte
|
||||
|
||||
**Solutions**:
|
||||
1. Web Audio API - `audioContext.suspend()/resume()` - plus petit buffer (~50ms)
|
||||
2. Frontend flush buffer
|
||||
3. Chaque client manage son propre buffer, Web Media Renderer juste expose API
|
||||
|
||||
## Implémentation actuelle
|
||||
|
||||
### Faits
|
||||
|
||||
- Flux audio OGG-FLAC ✓
|
||||
- Pause/Resume ✓ (via `OggFlacStreamHandle`)
|
||||
- TrackBoundary pour OGG segments
|
||||
- StreamType (Continuous vs Finite)
|
||||
|
||||
### À faire
|
||||
|
||||
- seek/next/prev API
|
||||
- WebSocket pour temps réel
|
||||
- Metadata endpoint (/nowplaying JSON)
|
||||
- MPV integration (multi-point/multi-room)
|
||||
|
||||
## Code actuel - Pause/Resume
|
||||
|
||||
```rust
|
||||
// OggFlacStreamHandle - méthodes de contrôle
|
||||
pub fn pause(&self) {
|
||||
self.inner.is_paused.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn resume(&self) {
|
||||
self.inner.is_paused.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.inner.is_paused.load(Ordering::SeqCst)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### Différences Continuous vs Finite
|
||||
|
||||
### e) Fermeture / cleanup
|
||||
- **Continuous** (radio): pause -> sends silence, drops incoming chunks
|
||||
- **Finite** (tracks): pause -> don't receive chunks (backpressure), loops sending silence
|
||||
|
||||
* Quand le navigateur se déconnecte :
|
||||
## Schéma d'intégration
|
||||
|
||||
* Rust supprime le renderer associé au token
|
||||
* Émet un **byebye virtuel** pour le Control Point (si nécessaire)
|
||||
* Libère toutes les ressources
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CP as Control Point
|
||||
participant WMR as Web Media Renderer
|
||||
participant FS as Flux Server
|
||||
participant D as Device
|
||||
|
||||
CP->>WMR: pause()
|
||||
WMR->>FS: commande pause
|
||||
FS->>FS: pause state change
|
||||
FS->>WMR: silence (continuous) / blocked (finite)
|
||||
WMR->>D: flux avec silence
|
||||
D-->>CP: audio joué (avec delay si buffer)
|
||||
```
|
||||
|
||||
---
|
||||
## Le Web Media Renderer - Adaptateur
|
||||
|
||||
## 3. Points clés
|
||||
Le rôle central du Web Media Renderer est de **convertir des ordres UPnP en actions spécifiques** selon le device cible:
|
||||
|
||||
1. **Token unique** = session privée + sécurité
|
||||
2. **Pas besoin de SSDP / annonce** : le renderer est dédié à un navigateur connu
|
||||
3. **Control Point Vue.js** sait exactement quel renderer utiliser
|
||||
4. **Rust backend** reste seul responsable de l’implémentation UPnP
|
||||
5. **Lecture réelle** = navigateur via `<audio>` ou `<video>`
|
||||
```
|
||||
UPnP orders → [Web Media Renderer] → Device-specific actions
|
||||
```
|
||||
|
||||
---
|
||||
## Browser Player - Composant web invisible
|
||||
|
||||
💡 Ce modèle est très proche de ce que font **Mopidy avec Iris**, **Kodi Remote**, ou **Chromecast / local cast** : le device est connu et dédié, pas besoin de découverte réseau.
|
||||
Pour s'entraîner, on peut se focaliser sur un composant web qui:
|
||||
- Est **complètement invisible** (pas de UI)
|
||||
- Est **télécommandable** par le Web Media Renderer
|
||||
- Joue la musique dans le navigateur
|
||||
|
||||
### Specifications
|
||||
|
||||
| Requirement | Description |
|
||||
|-------------|-------------|
|
||||
| Invisible | Pas de UI, pas de controls, pas de visuel |
|
||||
| Remote control | Reçoit commandes via WebSocket/HTTP |
|
||||
| Auto-reconnect | Reconnection si stream coupé |
|
||||
| Buffer management | Flush commandée |
|
||||
| Audio format | OGG-FLAC stream |
|
||||
|
||||
### Architecture en 2 parties
|
||||
|
||||
| Partie | Langage | Rôle |
|
||||
|--------|---------|------|
|
||||
| Backend | Rust (pmoaudio-ext) | Contrôle, flux OGG-FLAC |
|
||||
| Frontend | JavaScript | Player invisible dans le browser |
|
||||
|
||||
#### Backend (Rust)
|
||||
|
||||
- Expose le flux audio (`/stream`)
|
||||
- API contrôle (`/pause`, `/resume`, `/seek`, `/flush`, `/stop`)
|
||||
- WebSocket pour temps réel (`/ws`)
|
||||
- **Reçoit les rapports de position/state**
|
||||
|
||||
```rust
|
||||
// Endpoints existants
|
||||
POST /pause
|
||||
POST /resume
|
||||
POST /seek?t={timestamp}
|
||||
POST /flush
|
||||
POST /stop
|
||||
|
||||
// Stream
|
||||
GET /stream
|
||||
|
||||
// WebSocket messages REÇUS du player:
|
||||
{
|
||||
"type": "position",
|
||||
"position_sec": 125.5,
|
||||
"duration_sec": 240.0,
|
||||
"state": "playing"
|
||||
}
|
||||
{
|
||||
"type": "track",
|
||||
"id": "...",
|
||||
"title": "...",
|
||||
"artist": "..."
|
||||
}
|
||||
{
|
||||
"type": "ready_state",
|
||||
"ready_state": "canplay"
|
||||
}
|
||||
```
|
||||
|
||||
#### Frontend (JavaScript)
|
||||
|
||||
Composant minimal (~100 lignes):
|
||||
|
||||
```javascript
|
||||
class RemotePlayer {
|
||||
constructor(wsUrl) {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.audio = new Audio();
|
||||
this.ac = new AudioContext();
|
||||
|
||||
this.ws.onmessage = (e) => this.handle(e.data);
|
||||
}
|
||||
|
||||
handle(msg) {
|
||||
switch(msg.type) {
|
||||
case 'stream': this.load(msg.url); break;
|
||||
case 'play': this.play(); break;
|
||||
case 'pause': this.pause(); break;
|
||||
case 'seek': this.seek(msg.timestamp); break;
|
||||
case 'flush': this.flush(); break;
|
||||
case 'stop': this.stop(); break;
|
||||
}
|
||||
}
|
||||
|
||||
load(url) {
|
||||
this.audio.src = url;
|
||||
}
|
||||
|
||||
play() {
|
||||
this.audio.play();
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
seek(ts) {
|
||||
this.audio.currentTime = ts;
|
||||
}
|
||||
|
||||
flush() {
|
||||
// Flush buffer immediatement
|
||||
this.audio.pause();
|
||||
this.audio.currentTime = 0;
|
||||
this.audio.src = '';
|
||||
this.ac.suspend();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```html
|
||||
<script src="pmo-player.js"></script>
|
||||
<script>
|
||||
const player = new PMOPlayer('ws://localhost:8080/ws');
|
||||
</script>
|
||||
```
|
||||
|
||||
### Fichier à créer
|
||||
|
||||
`pmoapp/webapp/src/services/PMOPlayer.ts`
|
||||
|
||||
### Endpoints HTTP
|
||||
|
||||
| Endpoint | Methode | Description |
|
||||
|----------|---------|-------------|
|
||||
| `/api/webrenderer/register` | POST | Enregistre instance |
|
||||
| `/api/webrenderer/{id}/stream` | GET | Flux audio OGG-FLAC |
|
||||
| `/api/webrenderer/{id}/position` | POST | Rapporte position |
|
||||
| `/api/webrenderer/{id}/report` | POST | Rapporte etat player |
|
||||
| `/api/webrenderer/{id}/command` | GET | Recupere commande pending |
|
||||
| `/api/webrenderer/{id}` | DELETE | Desenregistre |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Player (Browser) <--HTTP--> Backend
|
||||
- Report: position/state via POST /report
|
||||
- Poll: command via GET /command (500ms)
|
||||
- Stream: GET /stream
|
||||
```
|
||||
|
||||
### Réactivité
|
||||
|
||||
Pour maximiser la réactivité:
|
||||
|
||||
| Technique | Impact |
|
||||
|-----------|--------|
|
||||
| WebSocket | Temps réel vs HTTP polling |
|
||||
| AudioContext.suspend() | Buffer ~50ms au lieu de ~5s |
|
||||
| Flush command | Vide le buffer immediatement |
|
||||
| Native HTML5 audio | Le plus simple = le plus stable |
|
||||
|
||||
### Schéma
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant WMR as Web Media Renderer
|
||||
participant BP as Browser Player
|
||||
|
||||
WMR->>BP: stream(url)
|
||||
BP->>BP: audio.src = url; play()
|
||||
|
||||
WMR->>BP: pause()
|
||||
BP->>BP: audio.pause()
|
||||
|
||||
WMR->>BP: seek(timestamp)
|
||||
BP->>BP: audio.currentTime = timestamp
|
||||
|
||||
WMR->>BP: flush()
|
||||
BP->>BP: audioContext.suspend()
|
||||
```
|
||||
|
||||
### Mapping orders → actions par device
|
||||
|
||||
| UPnP order | Browser | Android Auto | CarPlay | Sonos | Chromecast |
|
||||
|-----------|---------|-------------|--------|------|-------------|
|
||||
| Play | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
|
||||
| Pause | `audio.pause()` | AA pause | CP pause | UPnP Pause | Cast pause |
|
||||
| Resume | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
|
||||
| Seek | `audio.currentTime=t` | AA seek | CP seek | UPnP Seek | Cast seek |
|
||||
| Next | fetch new stream | AA next | CP next | UPnP Next | Cast next |
|
||||
| Prev | fetch new stream | AA prev | CP prev | UPnP Prev | Cast prev |
|
||||
| Flush | JS `audioContext.suspend()` | AA flush | CP flush | N/A | Cast load |
|
||||
| Stop | `audio.stop()` | AA stop | CP stop | UPnP Stop | Cast stop |
|
||||
|
||||
### Protocole de contrôle
|
||||
|
||||
Le Web Media Renderer expose une API de contrôle uniforme qui est traduite selon le device:
|
||||
|
||||
### Commandes
|
||||
|
||||
| Commande | Description |
|
||||
|---------|-------------|
|
||||
| `play` | Lecture |
|
||||
| `pause` | Pause (silence ou backpressure) |
|
||||
| `resume` | Reprise |
|
||||
| `seek(t)` | Seek vers timestamp t |
|
||||
| `next` | Track suivante |
|
||||
| `prev` | Track précédente |
|
||||
| `flush` | **Flush buffer** - ordre critique pour reponse rapide |
|
||||
| `stop` | Arrêt total |
|
||||
|
||||
### Métadonnées
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `/nowplaying` | Track actuelle, timestamp, is_paused |
|
||||
| `/metadata` | TITLE, ARTIST, ALBUM, COVER |
|
||||
| `/state` | État complet (position, duration, volume...) |
|
||||
|
||||
### Ordres spéciaux pour devices avec buffer
|
||||
|
||||
Pour les devices типа Android Auto, CarPlay, Browser:
|
||||
- `flush` = vide le buffer immédiatement
|
||||
- `stop` = arrête + flush
|
||||
- Ces ordres doivent être traités en priorité
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CP as Control Point
|
||||
participant WMR as Web Media Renderer
|
||||
participant D as Device (Android Auto, Browser...)
|
||||
|
||||
CP->>WMR: flush()
|
||||
Note over WMR: Priorité haute - immédiat
|
||||
WMR->>D: FLUSH order
|
||||
D-->>WMR: ack
|
||||
WMR-->>CP: flushed
|
||||
|
||||
CP->>WMR: pause()
|
||||
Note over WMR: Standard
|
||||
WMR->>D: flux with silence
|
||||
```
|
||||
|
||||
## multipoint/multi-room
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
WMR[Web Media Renderer] -->|flux| R1[Renderer 1]
|
||||
WMR -->|flux| R2[Renderer 2]
|
||||
WMR -->|flux| R3[Renderer N]
|
||||
|
||||
R1 -->|sync| R2
|
||||
R2 -->|sync| R3
|
||||
```
|
||||
|
||||
Possibilités:
|
||||
- UPnP pour renderers UPnP
|
||||
- Cast API pour Chromecast
|
||||
- Serveur temps réel pour sync
|
||||
|
||||
## Notes techniques
|
||||
|
||||
### AudioChunk::silence()
|
||||
|
||||
```rust
|
||||
impl AudioChunk {
|
||||
pub fn silence(frames: usize, sample_rate: u32) -> Self {
|
||||
AudioChunk::I32(AudioChunkData::<i32>::silence(frames, sample_rate))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Sample> AudioChunkData<T> {
|
||||
pub fn silence(frames: usize, sample_rate: u32) -> Arc<Self> {
|
||||
Self::new(vec![[T::ZERO; 2]; frames], sample_rate, 0.0)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### StreamType
|
||||
|
||||
```rust
|
||||
pub enum StreamType {
|
||||
Continuous, // radio - silence pendant pause
|
||||
Finite, // tracks - backpressure pendant pause
|
||||
}
|
||||
```
|
||||
|
||||
### TrackBoundary avec StreamType
|
||||
|
||||
```rust
|
||||
SyncMarker::TrackBoundary {
|
||||
metadata: ...,
|
||||
stream_type: StreamType
|
||||
}
|
||||
```
|
||||
83
Cargo.lock
generated
83
Cargo.lock
generated
@@ -517,7 +517,6 @@ dependencies = [
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -575,7 +574,6 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -597,42 +595,25 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-embed"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "077959a7f8cf438676af90b483304528eb7e16eadadb7f44e9ada4f9dceb9e62"
|
||||
dependencies = [
|
||||
"axum-core 0.4.5",
|
||||
"chrono",
|
||||
"http",
|
||||
"mime_guess",
|
||||
"rust-embed",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.9.6"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
|
||||
checksum = "dbfe9f610fe4e99cf0cfcd03ccf8c63c28c616fe714d80475ef731f3b13dd21b"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"axum-core 0.4.5",
|
||||
"axum 0.8.7",
|
||||
"axum-core 0.5.5",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"multer",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -701,7 +682,7 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde",
|
||||
"spin 0.10.0",
|
||||
"spin",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
@@ -2137,30 +2118,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"headers-core",
|
||||
"http",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"sha1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers-core"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
|
||||
dependencies = [
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
@@ -3113,23 +3070,6 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin 0.9.8",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
@@ -4280,10 +4220,10 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-stream 0.3.6",
|
||||
"axum 0.8.7",
|
||||
"axum-embed",
|
||||
"axum-server",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"mime_guess",
|
||||
"once_cell",
|
||||
"pmoconfig",
|
||||
"rust-embed",
|
||||
@@ -4408,6 +4348,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -5525,12 +5466,6 @@ dependencies = [
|
||||
"libsoxr-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.10.0"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ArrowRightLeft,
|
||||
} from "lucide-vue-next";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useWebRenderer } from "@/composables/useWebRenderer";
|
||||
import { useRouter } from "vue-router";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import type { RendererSummary } from "@/services/pmocontrol/types";
|
||||
@@ -27,8 +28,21 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||
const webRenderer = useWebRenderer();
|
||||
const router = useRouter();
|
||||
|
||||
// Filtre pour n'afficher que notre WebRenderer (pas les autres onglets/navigateurs)
|
||||
const myUdn = computed(() => webRenderer.rendererUdn.value);
|
||||
|
||||
const filteredRenderers = computed(() => {
|
||||
const udn = myUdn.value;
|
||||
return allRenderers.value.filter((r: RendererSummary) => {
|
||||
if (r.model_name !== "WebRenderer") return true;
|
||||
if (udn === null) return false;
|
||||
return r.id === udn;
|
||||
});
|
||||
});
|
||||
|
||||
// Gestion du menu déroulant
|
||||
const openMenuId = ref<string | null>(null);
|
||||
|
||||
@@ -110,16 +124,16 @@ watch(
|
||||
);
|
||||
|
||||
const onlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => r.online),
|
||||
filteredRenderers.value.filter((r: RendererSummary) => r.online),
|
||||
);
|
||||
const offlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => !r.online),
|
||||
filteredRenderers.value.filter((r: RendererSummary) => !r.online),
|
||||
);
|
||||
|
||||
// Noms de renderers qui apparaissent plus d'une fois (online + offline)
|
||||
const duplicateNames = computed(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const r of allRenderers.value) {
|
||||
for (const r of filteredRenderers.value) {
|
||||
counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1);
|
||||
}
|
||||
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
/**
|
||||
* Composable pour gérer le WebRenderer navigateur.
|
||||
*
|
||||
* S'enregistre via POST /api/webrenderer/register au montage
|
||||
* et diffuse l'audio via un élément <audio> pointant sur
|
||||
* /api/webrenderer/{id}/stream (flux FLAC encodé côté serveur).
|
||||
*
|
||||
* Le navigateur est vu comme un renderer UPnP par le ControlPoint.
|
||||
* Le gapless est géré côté serveur : le navigateur lit un flux continu.
|
||||
*
|
||||
* Cycle de vie du flux audio :
|
||||
* - PLAYING/TRANSITIONING → src = stream_url + play() (nouvelle connexion HTTP)
|
||||
* - PAUSED/STOPPED → pause() + src = "" (déconnexion HTTP)
|
||||
* Utilise PMOPlayer pour le controle remote.
|
||||
* S'enregistre via POST /api/webrenderer/register
|
||||
* Utilise polling HTTP pour les commands
|
||||
*/
|
||||
|
||||
import { ref, onMounted, onUnmounted, readonly } from "vue";
|
||||
import { useSSE } from "./useSSE";
|
||||
|
||||
// ─── Identifiant stable de l'instance navigateur ─────────────────────────────
|
||||
|
||||
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
|
||||
import { PMOPlayer } from "@/services/PMOPlayer";
|
||||
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
@@ -32,6 +21,18 @@ function generateUUID(): string {
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
|
||||
|
||||
// Module-level singleton for PMOPlayer to prevent duplicate instances
|
||||
let globalPlayer: PMOPlayer | null = null;
|
||||
let globalInstanceId: string | null = null;
|
||||
let registering = false;
|
||||
|
||||
// Module-level reactive state shared across all composable invocations
|
||||
const sharedConnected = ref(false);
|
||||
const sharedStreamUrl = ref<string | null>(null);
|
||||
const sharedRendererUdn = ref<string | null>(null);
|
||||
|
||||
function getOrCreateInstanceId(): string {
|
||||
try {
|
||||
let id = sessionStorage.getItem(INSTANCE_ID_KEY);
|
||||
@@ -45,146 +46,36 @@ function getOrCreateInstanceId(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RegisterRequest {
|
||||
instance_id: string;
|
||||
user_agent: string;
|
||||
}
|
||||
|
||||
interface RegisterResponse {
|
||||
stream_url: string;
|
||||
udn: string;
|
||||
}
|
||||
|
||||
// ─── Composable ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWebRenderer() {
|
||||
const connected = ref(false);
|
||||
const streamUrl = ref<string | null>(null);
|
||||
/** UDN du device UPnP créé côté serveur pour ce navigateur */
|
||||
const rendererUdn = ref<string | null>(null);
|
||||
const connected = sharedConnected;
|
||||
const streamUrl = sharedStreamUrl;
|
||||
const rendererUdn = sharedRendererUdn;
|
||||
|
||||
let audioEl: HTMLAudioElement | null = null;
|
||||
let instanceId: string | null = null;
|
||||
let currentStreamUrl: string | null = null;
|
||||
let player: PMOPlayer | null = null;
|
||||
let onConnectedCallback: (() => void) | null = null;
|
||||
let sseUnsubscribe: (() => void) | null = null;
|
||||
let pendingCanPlay: (() => void) | null = null;
|
||||
let positionInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── Reporting de position ─────────────────────────────────────────────────
|
||||
|
||||
function startPositionReporting(): void {
|
||||
stopPositionReporting();
|
||||
positionInterval = setInterval(() => {
|
||||
if (!audioEl || !instanceId) return;
|
||||
const pos = audioEl.currentTime;
|
||||
const dur = isFinite(audioEl.duration) ? audioEl.duration : null;
|
||||
void fetch(`/api/webrenderer/${instanceId}/position`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ position_sec: pos, duration_sec: dur }),
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopPositionReporting(): void {
|
||||
if (positionInterval !== null) {
|
||||
clearInterval(positionInterval);
|
||||
positionInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connexion / déconnexion du flux audio ─────────────────────────────────
|
||||
|
||||
function startStream(): void {
|
||||
console.log("[WebRenderer] startStream called, audioEl=", !!audioEl, "currentStreamUrl=", currentStreamUrl);
|
||||
if (!audioEl || !currentStreamUrl) return;
|
||||
const el = audioEl;
|
||||
// Si le stream est en erreur (networkState=3), réinitialiser avant de réessayer
|
||||
if (el.networkState === 3 /* NETWORK_NO_SOURCE */ && !pendingCanPlay) {
|
||||
console.log("[WebRenderer] startStream: networkState=3, resetting before retry");
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
}
|
||||
// Si le stream est déjà chargé/en cours, ne pas réouvrir la connexion HTTP.
|
||||
// (évite que PLAYING après TRANSITIONING ne crée un nouveau pipe)
|
||||
if (el.hasAttribute("src") && (el.readyState > 0 || pendingCanPlay)) {
|
||||
console.log("[WebRenderer] startStream: stream already open, ignoring (readyState=", el.readyState, ")");
|
||||
return;
|
||||
}
|
||||
// Sur Safari, play() échoue avec NotSupportedError si appelé avant que
|
||||
// l'élément audio ait reçu assez de données (readyState < HAVE_FUTURE_DATA).
|
||||
// On attend canplay avant d'appeler play().
|
||||
if (pendingCanPlay) {
|
||||
el.removeEventListener("canplay", pendingCanPlay);
|
||||
}
|
||||
el.src = currentStreamUrl;
|
||||
console.log("[WebRenderer] src set, readyState=", el.readyState, "networkState=", el.networkState);
|
||||
|
||||
el.addEventListener("error", () => {
|
||||
console.error("[WebRenderer] event:error code=", el.error?.code, el.error?.message,
|
||||
"readyState=", el.readyState, "networkState=", el.networkState);
|
||||
// Nettoyer pendingCanPlay pour permettre un retry au prochain PLAYING/TRANSITIONING
|
||||
if (pendingCanPlay) {
|
||||
el.removeEventListener("canplay", pendingCanPlay);
|
||||
pendingCanPlay = null;
|
||||
}
|
||||
}, { once: true });
|
||||
el.addEventListener("loadstart", () => console.debug("[WebRenderer] event:loadstart readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("loadedmetadata", () => console.debug("[WebRenderer] event:loadedmetadata readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("loadeddata", () => console.debug("[WebRenderer] event:loadeddata readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("progress", () => console.debug("[WebRenderer] event:progress readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("stalled", () => console.warn("[WebRenderer] event:stalled readyState=", el.readyState, "networkState=", el.networkState));
|
||||
el.addEventListener("waiting", () => console.warn("[WebRenderer] event:waiting readyState=", el.readyState));
|
||||
el.addEventListener("suspend", () => console.debug("[WebRenderer] event:suspend readyState=", el.readyState, "networkState=", el.networkState), { once: true });
|
||||
el.addEventListener("abort", () => console.warn("[WebRenderer] event:abort"), { once: true });
|
||||
el.addEventListener("emptied", () => console.warn("[WebRenderer] event:emptied"), { once: true });
|
||||
|
||||
const onCanPlay = () => {
|
||||
console.log("[WebRenderer] event:canplay readyState=", el.readyState, "calling play()");
|
||||
pendingCanPlay = null;
|
||||
el.removeEventListener("canplay", onCanPlay);
|
||||
el.play().then(() => {
|
||||
console.log("[WebRenderer] play() resolved OK");
|
||||
startPositionReporting();
|
||||
}).catch((e: unknown) => {
|
||||
console.warn("[WebRenderer] play() rejected:", e);
|
||||
});
|
||||
};
|
||||
pendingCanPlay = onCanPlay;
|
||||
el.addEventListener("canplay", onCanPlay);
|
||||
}
|
||||
|
||||
function stopStream(): void {
|
||||
console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState);
|
||||
stopPositionReporting();
|
||||
if (!audioEl) return;
|
||||
if (pendingCanPlay) {
|
||||
audioEl.removeEventListener("canplay", pendingCanPlay);
|
||||
pendingCanPlay = null;
|
||||
}
|
||||
audioEl.pause();
|
||||
audioEl.removeAttribute("src"); // ferme la connexion HTTP (src="" résolu comme URL de page sur Safari)
|
||||
audioEl.load(); // force le reset de l'état réseau
|
||||
}
|
||||
|
||||
// ── Enregistrement ────────────────────────────────────────────────────────
|
||||
|
||||
async function register(): Promise<void> {
|
||||
instanceId = getOrCreateInstanceId();
|
||||
// Prevent concurrent registrations (race condition → double player)
|
||||
if (globalPlayer || registering) {
|
||||
if (globalPlayer) {
|
||||
player = globalPlayer;
|
||||
connected.value = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
registering = true;
|
||||
|
||||
const body: RegisterRequest = {
|
||||
instance_id: instanceId,
|
||||
user_agent: navigator.userAgent,
|
||||
};
|
||||
const instanceId = getOrCreateInstanceId();
|
||||
console.log('[WebRenderer] registering with instanceId:', instanceId);
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/webrenderer/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({
|
||||
instance_id: instanceId,
|
||||
user_agent: navigator.userAgent,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -192,98 +83,80 @@ export function useWebRenderer() {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as RegisterResponse;
|
||||
const data = await resp.json();
|
||||
streamUrl.value = data.stream_url;
|
||||
currentStreamUrl = data.stream_url;
|
||||
rendererUdn.value = data.udn;
|
||||
|
||||
player = new PMOPlayer(instanceId);
|
||||
globalPlayer = player;
|
||||
globalInstanceId = instanceId;
|
||||
player.setDebug(true);
|
||||
|
||||
player.on('play', () => console.log('[WebRenderer] playing'));
|
||||
player.on('pause', () => console.log('[WebRenderer] paused'));
|
||||
|
||||
// Si le backend est déjà en lecture (reconnexion après reload), démarrer immédiatement
|
||||
if (data.should_play && data.stream_url) {
|
||||
console.log('[WebRenderer] backend already playing, starting stream');
|
||||
player.playStream(data.stream_url);
|
||||
}
|
||||
|
||||
connected.value = true;
|
||||
onConnectedCallback?.();
|
||||
|
||||
// S'abonner aux événements SSE du renderer pour piloter la lecture
|
||||
const { connect, onRendererEvent } = useSSE();
|
||||
connect();
|
||||
const udn = data.udn;
|
||||
sseUnsubscribe?.();
|
||||
sseUnsubscribe = onRendererEvent((event) => {
|
||||
if (event.renderer_id !== udn) return;
|
||||
if (event.type !== "state_changed") return;
|
||||
|
||||
const state = event.state;
|
||||
console.log("[WebRenderer] SSE state_changed →", state, "| event.renderer_id=", event.renderer_id, "udn=", udn, "| audioEl.src=", audioEl?.src, "readyState=", audioEl?.readyState, "networkState=", audioEl?.networkState, "pendingCanPlay=", !!pendingCanPlay);
|
||||
if (state === "PLAYING" || state === "TRANSITIONING") {
|
||||
startStream();
|
||||
} else if (state === "PAUSED" || state === "STOPPED") {
|
||||
stopStream();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[WebRenderer] register error:", e);
|
||||
} finally {
|
||||
registering = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Désenregistrement ─────────────────────────────────────────────────────
|
||||
|
||||
async function unregister(): Promise<void> {
|
||||
if (!instanceId) return;
|
||||
// Only unregister if this is the global player - prevent duplicate unregister calls
|
||||
if (player !== globalPlayer || !player) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceId = globalInstanceId || getOrCreateInstanceId();
|
||||
|
||||
// Clear global first to prevent other components from using it
|
||||
globalPlayer = null;
|
||||
globalInstanceId = null;
|
||||
|
||||
try {
|
||||
await fetch(`/api/webrenderer/${instanceId}`, { method: "DELETE" });
|
||||
} catch {
|
||||
// Ignoré lors du déchargement de page
|
||||
// Ignored
|
||||
}
|
||||
instanceId = null;
|
||||
player?.destroy();
|
||||
player = null;
|
||||
}
|
||||
|
||||
// ── Volume / Mute ─────────────────────────────────────────────────────────
|
||||
|
||||
function setVolume(v: number): void {
|
||||
if (audioEl) audioEl.volume = v;
|
||||
function setVolume(_v: number): void {
|
||||
// PMOPlayer doesn't control volume directly - handled by backend
|
||||
}
|
||||
|
||||
function setMute(m: boolean): void {
|
||||
if (audioEl) audioEl.muted = m;
|
||||
function setMute(_m: boolean): void {
|
||||
// PMOPlayer doesn't control mute directly - handled by backend
|
||||
}
|
||||
|
||||
// ── Cycle de vie ──────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
audioEl = document.createElement("audio");
|
||||
audioEl.preload = "auto";
|
||||
document.body.appendChild(audioEl);
|
||||
|
||||
void register();
|
||||
window.addEventListener("beforeunload", () => void unregister());
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
sseUnsubscribe?.();
|
||||
sseUnsubscribe = null;
|
||||
stopPositionReporting();
|
||||
stopStream();
|
||||
void unregister();
|
||||
if (audioEl) {
|
||||
audioEl.remove();
|
||||
audioEl = null;
|
||||
}
|
||||
connected.value = false;
|
||||
streamUrl.value = null;
|
||||
rendererUdn.value = null;
|
||||
window.removeEventListener("beforeunload", () => void unregister());
|
||||
});
|
||||
|
||||
// ── API publique ──────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
/** true quand l'instance est enregistrée sur le serveur */
|
||||
connected: readonly(connected),
|
||||
/** URL du flux FLAC servi par le serveur */
|
||||
streamUrl: readonly(streamUrl),
|
||||
/** UDN du device UPnP créé pour ce navigateur (null avant enregistrement) */
|
||||
rendererUdn: readonly(rendererUdn),
|
||||
/** Callback appelé quand l'enregistrement est confirmé */
|
||||
onConnected(fn: () => void) {
|
||||
onConnectedCallback = fn;
|
||||
},
|
||||
setVolume,
|
||||
setMute,
|
||||
};
|
||||
}
|
||||
}
|
||||
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)),
|
||||
pending_track_duration: None,
|
||||
pending_total_samples: None,
|
||||
is_paused: Arc::new(AtomicBool::new(false)),
|
||||
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
|
||||
last_track_metadata: Arc::new(RwLock::new(None)),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use pmoaudio::{
|
||||
pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason},
|
||||
AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
|
||||
AudioChunk, AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
|
||||
};
|
||||
use pmoflac::{EncoderOptions, FlacEncodedStream};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
|
||||
@@ -109,6 +109,23 @@ impl OggFlacStreamHandle {
|
||||
pub fn set_auto_stop(&self, enabled: bool) {
|
||||
self.inner.auto_stop.store(enabled, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Pause playback - sends silence (zeros) to maintain client connections
|
||||
pub fn pause(&self) {
|
||||
self.inner.is_paused.store(true, Ordering::SeqCst);
|
||||
debug!("StreamingOggFlacSink paused");
|
||||
}
|
||||
|
||||
/// Resume playback - continues sending actual audio
|
||||
pub fn resume(&self) {
|
||||
self.inner.is_paused.store(false, Ordering::SeqCst);
|
||||
debug!("StreamingOggFlacSink resumed");
|
||||
}
|
||||
|
||||
/// Check if currently paused
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.inner.is_paused.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// OGG-FLAC client stream (implements AsyncRead).
|
||||
@@ -163,16 +180,98 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
||||
|
||||
debug!("StreamingOggFlacSink started");
|
||||
|
||||
// TODO: Implement OGG-FLAC encoding logic
|
||||
// For now, just process segments without encoding
|
||||
let mut was_paused = false;
|
||||
let mut silence_timestamp: f64 = 0.0;
|
||||
|
||||
loop {
|
||||
// Check pause state BEFORE select to avoid receiving chunks when Finite + paused
|
||||
{
|
||||
let is_paused = self.ctx.is_paused.load(Ordering::SeqCst);
|
||||
let stream_type = *self.ctx.stream_type.read().await;
|
||||
|
||||
// For Finite + paused: loop WITHOUT receiving chunks
|
||||
if is_paused && stream_type == pmoaudio::StreamType::Finite {
|
||||
if self.ctx.sample_rate.is_none() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let sample_rate = self.ctx.sample_rate.unwrap();
|
||||
let frames_per_50ms = (sample_rate as f64 * 0.05) as usize;
|
||||
|
||||
// Create proper silence chunk using AudioChunk::silence
|
||||
let silence_chunk = AudioChunk::silence(frames_per_50ms, sample_rate);
|
||||
let pcm_bytes = chunk_to_pcm_bytes(&silence_chunk, self.ctx.bits_per_sample)?;
|
||||
|
||||
trace!("Sending silence during pause (finite) - no recv");
|
||||
let pcm_chunk = PcmChunk {
|
||||
bytes: pcm_bytes,
|
||||
timestamp_sec: silence_timestamp,
|
||||
duration_sec: 0.05,
|
||||
};
|
||||
|
||||
if let Some(tx) = &self.ctx.pcm_tx {
|
||||
let _ = tx.send(pcm_chunk).await;
|
||||
}
|
||||
silence_timestamp += 0.05;
|
||||
|
||||
continue; // Loop again WITHOUT going to select
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
debug!("StreamingOggFlacSink stopped by cancellation");
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for pause state changes periodically
|
||||
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {
|
||||
let is_paused = self.ctx.is_paused.load(Ordering::SeqCst);
|
||||
|
||||
if is_paused != was_paused {
|
||||
if is_paused {
|
||||
// Entering pause - restart encoder to close OGG segment
|
||||
debug!("StreamingOggFlacSink: entering pause - restarting encoder");
|
||||
let metadata = self.ctx.last_track_metadata.read().await.clone();
|
||||
if let Some(ref meta) = metadata {
|
||||
if let Err(e) = self.ctx.prepare_encoder_options_for_track(meta).await {
|
||||
error!("Failed to prepare encoder options for pause: {}", e);
|
||||
}
|
||||
}
|
||||
if self.ctx.encoder_state.is_some() {
|
||||
if let Err(e) = self.ctx.restart_encoder_for_new_track(
|
||||
|flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, _sample_rate, timestamp_offset_sec| {
|
||||
broadcast_ogg_flac_stream(flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, timestamp_offset_sec)
|
||||
},
|
||||
).await {
|
||||
warn!("Failed to restart encoder on pause: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Exiting pause - restart encoder to open new OGG segment
|
||||
debug!("StreamingOggFlacSink: exiting pause - restarting encoder");
|
||||
let metadata = self.ctx.last_track_metadata.read().await.clone();
|
||||
if let Some(ref meta) = metadata {
|
||||
if let Err(e) = self.ctx.prepare_encoder_options_for_track(meta).await {
|
||||
error!("Failed to prepare encoder options for resume: {}", e);
|
||||
}
|
||||
}
|
||||
if self.ctx.encoder_state.is_some() {
|
||||
if let Err(e) = self.ctx.restart_encoder_for_new_track(
|
||||
|flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, _sample_rate, timestamp_offset_sec| {
|
||||
broadcast_ogg_flac_stream(flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, timestamp_offset_sec)
|
||||
},
|
||||
).await {
|
||||
warn!("Failed to restart encoder on resume: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
was_paused = is_paused;
|
||||
}
|
||||
continue; // Skip processing - this is just a check
|
||||
}
|
||||
|
||||
segment = input.recv() => {
|
||||
match segment {
|
||||
Some(seg) => {
|
||||
@@ -241,7 +340,33 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
||||
duration_sec
|
||||
);
|
||||
|
||||
// Send to FLAC encoder with timestamp and duration
|
||||
// Check pause state
|
||||
let was_paused = self.ctx.is_paused.load(Ordering::SeqCst);
|
||||
|
||||
// Get stream type
|
||||
let stream_type = *self.ctx.stream_type.read().await;
|
||||
|
||||
// Handle pause:
|
||||
// - Always send silence to hear something
|
||||
// - Continuous: receive chunks but drop them
|
||||
// - Finite: don't receive chunks (backpressure)
|
||||
if was_paused {
|
||||
trace!("StreamingOggFlacSink: sending silence during pause ({})",
|
||||
if stream_type == pmoaudio::StreamType::Continuous { "continuous" } else { "finite" });
|
||||
let silence_bytes = vec![0u8; pcm_bytes.len()];
|
||||
let silence_chunk = PcmChunk {
|
||||
bytes: silence_bytes,
|
||||
timestamp_sec: seg.timestamp_sec,
|
||||
duration_sec,
|
||||
};
|
||||
if let Some(tx) = &self.ctx.pcm_tx {
|
||||
let _ = tx.send(silence_chunk).await;
|
||||
}
|
||||
// Always continue (skip sending real audio)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal case: send PCM to encoder
|
||||
let pcm_chunk = PcmChunk {
|
||||
bytes: pcm_bytes,
|
||||
timestamp_sec: seg.timestamp_sec,
|
||||
@@ -265,7 +390,13 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
||||
|
||||
_AudioSegment::Sync(marker) => {
|
||||
match marker.as_ref() {
|
||||
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||
SyncMarker::TrackBoundary { metadata, stream_type } => {
|
||||
// Store the last TrackBoundary metadata for pause/resume
|
||||
*self.ctx.last_track_metadata.write().await = Some(metadata.clone());
|
||||
|
||||
// Update stream type
|
||||
*self.ctx.stream_type.write().await = *stream_type;
|
||||
|
||||
// Inject per-track metadata and duration into the next FLAC header.
|
||||
if let Err(e) =
|
||||
self.ctx.prepare_encoder_options_for_track(metadata).await
|
||||
@@ -442,30 +573,33 @@ impl StreamingOggFlacSink {
|
||||
|
||||
let logic = StreamingOggFlacSinkLogic {
|
||||
ctx: SharedSinkContext {
|
||||
encoder_options,
|
||||
bits_per_sample,
|
||||
enable_total_samples: options.enable_total_samples,
|
||||
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
|
||||
default_title: options.default_title.clone(),
|
||||
default_artist: options.default_artist.clone(),
|
||||
use_only_default_metadata: options.use_only_default_metadata,
|
||||
pcm_tx: Some(pcm_tx),
|
||||
pcm_rx: Some(pcm_rx),
|
||||
metadata,
|
||||
broadcast,
|
||||
header,
|
||||
encoder_state: None,
|
||||
sample_rate: None,
|
||||
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
|
||||
first_chunk_timestamp_checked: false,
|
||||
timestamp_offset_sec: 0.0,
|
||||
current_timestamp: Arc::new(RwLock::new(0.0)),
|
||||
pending_track_duration: None,
|
||||
pending_total_samples: None,
|
||||
},
|
||||
encoder_options,
|
||||
bits_per_sample,
|
||||
enable_total_samples: options.enable_total_samples,
|
||||
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
|
||||
default_title: options.default_title.clone(),
|
||||
default_artist: options.default_artist.clone(),
|
||||
use_only_default_metadata: options.use_only_default_metadata,
|
||||
pcm_tx: Some(pcm_tx),
|
||||
pcm_rx: Some(pcm_rx),
|
||||
metadata,
|
||||
broadcast,
|
||||
header,
|
||||
encoder_state: None,
|
||||
sample_rate: None,
|
||||
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
|
||||
first_chunk_timestamp_checked: false,
|
||||
timestamp_offset_sec: 0.0,
|
||||
current_timestamp: Arc::new(RwLock::new(0.0)),
|
||||
pending_track_duration: None,
|
||||
pending_total_samples: None,
|
||||
is_paused: Arc::new(AtomicBool::new(false)),
|
||||
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
|
||||
last_track_metadata: Arc::new(RwLock::new(None)),
|
||||
},
|
||||
};
|
||||
|
||||
let sink = Self {
|
||||
let sink = StreamingOggFlacSink {
|
||||
inner: Node::new_with_input(logic, 16),
|
||||
};
|
||||
|
||||
|
||||
@@ -118,6 +118,10 @@ pub struct SharedStreamHandleInner {
|
||||
pub stop_token: CancellationToken,
|
||||
pub header: Arc<RwLock<Option<Bytes>>>,
|
||||
pub auto_stop: Arc<AtomicBool>,
|
||||
/// Pause state - true if paused, false if playing
|
||||
pub is_paused: Arc<AtomicBool>,
|
||||
/// Stream type (Continuous for radio, Finite for tracks)
|
||||
pub stream_type: Arc<RwLock<pmoaudio::StreamType>>,
|
||||
}
|
||||
|
||||
impl SharedStreamHandleInner {
|
||||
@@ -135,6 +139,8 @@ impl SharedStreamHandleInner {
|
||||
stop_token,
|
||||
header,
|
||||
auto_stop,
|
||||
is_paused: Arc::new(AtomicBool::new(false)),
|
||||
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +300,12 @@ pub struct SharedSinkContext {
|
||||
pub current_timestamp: Arc<RwLock<f64>>,
|
||||
pub pending_track_duration: Option<Duration>,
|
||||
pub pending_total_samples: Option<u64>,
|
||||
/// Pause state - true if paused, false if playing
|
||||
pub is_paused: Arc<AtomicBool>,
|
||||
/// Stream type (Continuous for radio, Finite for tracks)
|
||||
pub stream_type: Arc<RwLock<pmoaudio::StreamType>>,
|
||||
/// Last TrackBoundary metadata received (for pause/resume)
|
||||
pub last_track_metadata: Arc<RwLock<Option<Arc<RwLock<dyn TrackMetadata>>>>>,
|
||||
}
|
||||
|
||||
impl SharedSinkContext {
|
||||
|
||||
@@ -91,6 +91,25 @@ impl<T: Sample> AudioChunkData<T> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Crée un chunk de silence
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `frames` - Nombre de frames (échantillons par canal)
|
||||
/// * `sample_rate` - Taux d'échantillonnage en Hz
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoaudio::AudioChunkData;
|
||||
///
|
||||
/// let silence = AudioChunkData::<i32>::silence(48000, 48000);
|
||||
/// assert_eq!(silence.len(), 48000);
|
||||
/// ```
|
||||
pub fn silence(frames: usize, sample_rate: u32) -> Arc<Self> {
|
||||
Self::new(vec![[T::ZERO; 2]; frames], sample_rate, 0.0)
|
||||
}
|
||||
|
||||
/// Retourne le nombre d'échantillons par canal (frames)
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
@@ -317,6 +336,11 @@ pub enum AudioChunk {
|
||||
}
|
||||
|
||||
impl AudioChunk {
|
||||
/// Crée un chunk de silence (I32 par défaut)
|
||||
pub fn silence(frames: usize, sample_rate: u32) -> Self {
|
||||
AudioChunk::I32(AudioChunkData::<i32>::silence(frames, sample_rate))
|
||||
}
|
||||
|
||||
/// Retourne le nombre de frames du chunk
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
|
||||
@@ -20,8 +20,8 @@ tracing-subscriber = { workspace = true }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3.6"
|
||||
axum-server = "0.7.2"
|
||||
axum-embed = "0.1.0"
|
||||
rust-embed = "8.7.2"
|
||||
mime_guess = "2"
|
||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
||||
once_cell = "1.19"
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
pub mod config_ext;
|
||||
pub mod logs;
|
||||
pub mod server;
|
||||
mod serve_embed;
|
||||
|
||||
pub use config_ext::ConfigExt;
|
||||
pub use logs::{
|
||||
|
||||
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::routing::{any, get, post};
|
||||
use axum::{Json, Router};
|
||||
use axum_embed::ServeEmbed;
|
||||
use crate::serve_embed::ServeEmbed;
|
||||
use pmoconfig::get_config;
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::Serialize;
|
||||
@@ -340,11 +340,7 @@ impl Server {
|
||||
where
|
||||
E: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let serve = ServeEmbed::<E>::with_parameters(
|
||||
Some("index.html".to_string()),
|
||||
axum_embed::FallbackBehavior::Ok,
|
||||
Some("index.html".to_string()),
|
||||
);
|
||||
let serve = ServeEmbed::<E>::with_spa_fallback("index.html".to_string());
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
|
||||
|
||||
@@ -22,13 +22,16 @@ tokio-util = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
# HTTP
|
||||
axum = { workspace = true }
|
||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
axum = "0.8.4"
|
||||
axum-extra = "0.12"
|
||||
tower-http = "0.6"
|
||||
futures = "0.3"
|
||||
reqwest = { workspace = true, features = ["stream"] }
|
||||
bytes = "1.0"
|
||||
|
||||
# OpenAPI
|
||||
utoipa = { version = "5.3", features = ["axum_extras"] }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -7,7 +7,11 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use axum::{Router, routing::{delete, get, post}};
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Path, State},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmocontrol::ControlPoint;
|
||||
@@ -15,7 +19,10 @@ use pmocontrol::ControlPoint;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::error::WebRendererError;
|
||||
#[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")]
|
||||
use crate::registry::RendererRegistry;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -48,18 +55,25 @@ impl WebRendererExt for pmoserver::Server {
|
||||
)
|
||||
.await;
|
||||
|
||||
// GET /api/webrenderer/{id}/stream + DELETE /api/webrenderer/{id} + POST /api/webrenderer/{id}/position
|
||||
// GET /api/webrenderer/{id}/stream + DELETE /api/webrenderer/{id}
|
||||
// POST /api/webrenderer/{id}/play -> tell player to start streaming
|
||||
// POST /api/webrenderer/{id}/pause, /set_uri, /report
|
||||
// GET /api/webrenderer/{id}/command, /position
|
||||
let dynamic_router = Router::new()
|
||||
.route("/{id}/stream", get(stream_handler))
|
||||
.route("/{id}/position", post(position_update_handler))
|
||||
.route("/{id}", delete(unregister_handler))
|
||||
.route("/{id}/play", post(play_handler))
|
||||
.route("/{id}/pause", post(pause_handler))
|
||||
.route("/{id}/set_uri", post(set_uri_handler))
|
||||
.route("/{id}/report", post(report_handler))
|
||||
.route("/{id}/command", get(crate::register::command_handler))
|
||||
.route("/{id}/position", post(position_update_handler))
|
||||
.with_state(registry.clone());
|
||||
self.add_router("/api/webrenderer", dynamic_router).await;
|
||||
|
||||
tracing::info!("WebRenderer server-side streaming endpoints registered");
|
||||
tracing::info!(" POST /api/webrenderer/register");
|
||||
tracing::info!(" GET /api/webrenderer/{{id}}/stream");
|
||||
tracing::info!(" POST /api/webrenderer/{{id}}/position");
|
||||
tracing::info!(" DELETE /api/webrenderer/{{id}}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -24,10 +24,30 @@ pub fn play_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandl
|
||||
let pipeline = pipeline.clone();
|
||||
let state = state.clone();
|
||||
Box::pin(async move {
|
||||
tracing::info!("[WebRenderer] UPnP Play action invoked");
|
||||
// Ne pas écrire Playing ici : c'est stream_source qui le fera
|
||||
// une fois que les premiers bytes FLAC ont été produits.
|
||||
// Écrire Transitioning pour signaler que la lecture va démarrer.
|
||||
state.write().playback_state = PlaybackState::Transitioning;
|
||||
|
||||
// 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;
|
||||
Ok(data)
|
||||
})
|
||||
@@ -95,6 +115,7 @@ pub fn set_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHa
|
||||
let pipeline = pipeline.clone();
|
||||
let state = state.clone();
|
||||
Box::pin(async move {
|
||||
tracing::info!("[WebRenderer] UPnP SetAVTransportURI action invoked");
|
||||
let uri: String = get!(&data, "CurrentURI", String);
|
||||
let metadata: String = get_value::<String>(&data, "CurrentURIMetaData")
|
||||
.or_else(|_| {
|
||||
@@ -103,6 +124,8 @@ pub fn set_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHa
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
tracing::info!(uri = %uri, "SetAVTransportURI handler called - loading URI into pipeline");
|
||||
|
||||
// Envoyer l'URI au pipeline serveur (remplace l'envoi WebSocket)
|
||||
pipeline.send(PipelineControl::LoadUri(uri.clone())).await;
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ pub enum TransportAction {
|
||||
Seek,
|
||||
SetUri,
|
||||
SetNextUri,
|
||||
/// Flush buffer immediatement - pour reponse rapide
|
||||
Flush,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -84,6 +86,11 @@ pub enum ClientMessage {
|
||||
/// Envoyé quand la piste courante se termine naturellement (gapless).
|
||||
/// Le backend fait avancer current → next dans l'état partagé.
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
http::{StatusCode, header::HeaderMap},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
@@ -24,9 +24,12 @@ pub struct RegisterRequest {
|
||||
pub struct RegisterResponse {
|
||||
pub stream_url: 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
|
||||
#[axum::debug_handler]
|
||||
pub async fn register_handler(
|
||||
State(registry): State<Arc<RendererRegistry>>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
@@ -41,14 +44,15 @@ pub async fn register_handler(
|
||||
.register_or_reconnect(&req.instance_id, &req.user_agent)
|
||||
.await
|
||||
{
|
||||
Ok((stream_url, udn)) => {
|
||||
Ok((stream_url, udn, should_play)) => {
|
||||
tracing::info!(
|
||||
instance_id = %req.instance_id,
|
||||
stream_url = %stream_url,
|
||||
udn = %udn,
|
||||
should_play = %should_play,
|
||||
"WebRenderer: registered"
|
||||
);
|
||||
(StatusCode::OK, Json(RegisterResponse { stream_url, udn })).into_response()
|
||||
(StatusCode::OK, Json(RegisterResponse { stream_url, udn, should_play })).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
@@ -70,6 +74,7 @@ pub struct PositionUpdateRequest {
|
||||
/// POST /api/webrenderer/{id}/position
|
||||
/// position_sec est ignoré (géré par PlayerEvent::Position côté serveur).
|
||||
/// duration_sec est utilisé comme fallback si la source ne connaît pas la durée (flux radio).
|
||||
#[axum::debug_handler]
|
||||
pub async fn position_update_handler(
|
||||
State(registry): State<Arc<RendererRegistry>>,
|
||||
Path(instance_id): Path<String>,
|
||||
@@ -80,6 +85,7 @@ pub async fn position_update_handler(
|
||||
}
|
||||
|
||||
/// DELETE /api/webrenderer/{id}
|
||||
#[axum::debug_handler]
|
||||
pub async fn unregister_handler(
|
||||
State(registry): State<Arc<RendererRegistry>>,
|
||||
Path(instance_id): Path<String>,
|
||||
@@ -88,3 +94,106 @@ pub async fn unregister_handler(
|
||||
registry.schedule_unregister(&instance_id);
|
||||
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
|
||||
//! au flux FLAC HTTP, pas à une connexion WebSocket.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -68,12 +73,13 @@ impl RendererRegistry {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
user_agent: &str,
|
||||
) -> Result<(String, String), WebRendererError> {
|
||||
) -> Result<(String, String, bool), WebRendererError> {
|
||||
// Annuler tout unregister différé pour cet 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)");
|
||||
@@ -88,7 +94,14 @@ impl RendererRegistry {
|
||||
#[cfg(feature = "pmoserver")]
|
||||
self.register_with_control_point(&existing.device_instance)?;
|
||||
let stream_url = format!("/api/webrenderer/{}/stream", instance_id);
|
||||
return Ok((stream_url, existing.udn.clone()));
|
||||
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"
|
||||
);
|
||||
|
||||
Ok((stream_url, udn))
|
||||
Ok((stream_url, udn, false))
|
||||
}
|
||||
|
||||
/// Retourne un OggFlacClientStream indépendant pour l'endpoint /stream.
|
||||
@@ -122,10 +135,17 @@ impl RendererRegistry {
|
||||
&self,
|
||||
instance_id: &str,
|
||||
) -> Option<pmoaudio_ext::sinks::OggFlacClientStream> {
|
||||
self.instances
|
||||
.read()
|
||||
.get(instance_id)
|
||||
.map(|i| i.flac_handle.subscribe())
|
||||
let instances = self.instances.read();
|
||||
match instances.get(instance_id) {
|
||||
Some(i) => {
|
||||
tracing::debug!(instance_id = %instance_id, "Found instance, getting flac_handle");
|
||||
Some(i.flac_handle.subscribe())
|
||||
}
|
||||
None => {
|
||||
tracing::error!(instance_id = %instance_id, "Instance not found in registry!");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le PipelineHandle par UDN (pour les handlers UPnP)
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
async fn create_instance(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! État partagé du renderer (backend ↔ pipeline)
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::messages::PlaybackState;
|
||||
@@ -17,6 +18,8 @@ pub struct RendererState {
|
||||
pub duration: Option<String>,
|
||||
pub volume: u16,
|
||||
pub mute: bool,
|
||||
/// Commande en attente pour le player frontend (polled via /command)
|
||||
pub player_command: Option<Value>,
|
||||
}
|
||||
|
||||
impl Default for RendererState {
|
||||
@@ -31,6 +34,7 @@ impl Default for RendererState {
|
||||
duration: None,
|
||||
volume: 100,
|
||||
mute: false,
|
||||
player_command: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ use axum::{
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::{
|
||||
HeaderMap, StatusCode,
|
||||
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE, TRANSFER_ENCODING},
|
||||
HeaderMap, StatusCode,
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tracing::info;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::registry::RendererRegistry;
|
||||
|
||||
@@ -29,25 +29,26 @@ pub async fn stream_handler(
|
||||
info!(instance_id = %instance_id, "FLAC stream client connecting");
|
||||
|
||||
// Ignorer le header Range — flux live infini, non seekable.
|
||||
// On ne répond jamais 206 ni 416 : toujours 200 chunked.
|
||||
// Safari (et d'autres clients) envoient parfois Range: bytes=0-N ;
|
||||
// répondre 416 ou 206 leur fait croire à une ressource finie.
|
||||
if let Some(range) = headers.get("range") {
|
||||
info!(instance_id = %instance_id, "Range header ignored (live stream): {:?}", range);
|
||||
info!(instance_id = %instance_id, "Range header ignored: {:?}", range);
|
||||
}
|
||||
|
||||
let stream = match registry.get_stream(&instance_id) {
|
||||
Some(s) => s,
|
||||
Some(s) => {
|
||||
info!(instance_id = %instance_id, "Found instance, getting stream");
|
||||
s
|
||||
}
|
||||
None => {
|
||||
error!(instance_id = %instance_id, "No WebRenderer instance found!");
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("No WebRenderer instance for id={}", instance_id),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
info!(instance_id = %instance_id, "FLAC stream started");
|
||||
info!(instance_id = %instance_id, "FLAC stream started - returning OGG-FLAC");
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
|
||||
Reference in New Issue
Block a user