🗑️ 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:
2026-04-05 10:52:54 +02:00
parent 340c69cb2b
commit 546e8a782f
21 changed files with 1479 additions and 451 deletions

View File

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

83
Cargo.lock generated
View File

@@ -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"

View File

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

View File

@@ -1,24 +1,13 @@
/**
* Composable pour gérer le WebRenderer navigateur.
*
* S'enregistre via POST /api/webrenderer/register au montage
* et diffuse l'audio via un élément <audio> pointant sur
* /api/webrenderer/{id}/stream (flux FLAC encodé côté serveur).
*
* Le navigateur est vu comme un renderer UPnP par le ControlPoint.
* Le gapless est géré côté serveur : le navigateur lit un flux continu.
*
* Cycle de vie du flux audio :
* - PLAYING/TRANSITIONING → src = stream_url + play() (nouvelle connexion HTTP)
* - PAUSED/STOPPED → pause() + src = "" (déconnexion HTTP)
* Utilise PMOPlayer pour le controle remote.
* S'enregistre via POST /api/webrenderer/register
* Utilise polling HTTP pour les commands
*/
import { ref, onMounted, onUnmounted, readonly } from "vue";
import { useSSE } from "./useSSE";
// ─── Identifiant stable de l'instance navigateur ─────────────────────────────
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
import { PMOPlayer } from "@/services/PMOPlayer";
function generateUUID(): string {
if (typeof crypto.randomUUID === "function") {
@@ -32,6 +21,18 @@ function generateUUID(): string {
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
// Module-level singleton for PMOPlayer to prevent duplicate instances
let globalPlayer: PMOPlayer | null = null;
let globalInstanceId: string | null = null;
let registering = false;
// Module-level reactive state shared across all composable invocations
const sharedConnected = ref(false);
const sharedStreamUrl = ref<string | null>(null);
const sharedRendererUdn = ref<string | null>(null);
function getOrCreateInstanceId(): string {
try {
let id = sessionStorage.getItem(INSTANCE_ID_KEY);
@@ -45,146 +46,36 @@ function getOrCreateInstanceId(): string {
}
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface RegisterRequest {
instance_id: string;
user_agent: string;
}
interface RegisterResponse {
stream_url: string;
udn: string;
}
// ─── Composable ───────────────────────────────────────────────────────────────
export function useWebRenderer() {
const connected = ref(false);
const streamUrl = ref<string | null>(null);
/** UDN du device UPnP créé côté serveur pour ce navigateur */
const rendererUdn = ref<string | null>(null);
const connected = sharedConnected;
const streamUrl = sharedStreamUrl;
const rendererUdn = sharedRendererUdn;
let audioEl: HTMLAudioElement | null = null;
let instanceId: string | null = null;
let currentStreamUrl: string | null = null;
let player: PMOPlayer | null = null;
let onConnectedCallback: (() => void) | null = null;
let sseUnsubscribe: (() => void) | null = null;
let pendingCanPlay: (() => void) | null = null;
let positionInterval: ReturnType<typeof setInterval> | null = null;
// ── Reporting de position ─────────────────────────────────────────────────
function startPositionReporting(): void {
stopPositionReporting();
positionInterval = setInterval(() => {
if (!audioEl || !instanceId) return;
const pos = audioEl.currentTime;
const dur = isFinite(audioEl.duration) ? audioEl.duration : null;
void fetch(`/api/webrenderer/${instanceId}/position`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ position_sec: pos, duration_sec: dur }),
});
}, 1000);
}
function stopPositionReporting(): void {
if (positionInterval !== null) {
clearInterval(positionInterval);
positionInterval = null;
}
}
// ── Connexion / déconnexion du flux audio ─────────────────────────────────
function startStream(): void {
console.log("[WebRenderer] startStream called, audioEl=", !!audioEl, "currentStreamUrl=", currentStreamUrl);
if (!audioEl || !currentStreamUrl) return;
const el = audioEl;
// Si le stream est en erreur (networkState=3), réinitialiser avant de réessayer
if (el.networkState === 3 /* NETWORK_NO_SOURCE */ && !pendingCanPlay) {
console.log("[WebRenderer] startStream: networkState=3, resetting before retry");
el.removeAttribute("src");
el.load();
}
// Si le stream est déjà chargé/en cours, ne pas réouvrir la connexion HTTP.
// (évite que PLAYING après TRANSITIONING ne crée un nouveau pipe)
if (el.hasAttribute("src") && (el.readyState > 0 || pendingCanPlay)) {
console.log("[WebRenderer] startStream: stream already open, ignoring (readyState=", el.readyState, ")");
return;
}
// Sur Safari, play() échoue avec NotSupportedError si appelé avant que
// l'élément audio ait reçu assez de données (readyState < HAVE_FUTURE_DATA).
// On attend canplay avant d'appeler play().
if (pendingCanPlay) {
el.removeEventListener("canplay", pendingCanPlay);
}
el.src = currentStreamUrl;
console.log("[WebRenderer] src set, readyState=", el.readyState, "networkState=", el.networkState);
el.addEventListener("error", () => {
console.error("[WebRenderer] event:error code=", el.error?.code, el.error?.message,
"readyState=", el.readyState, "networkState=", el.networkState);
// Nettoyer pendingCanPlay pour permettre un retry au prochain PLAYING/TRANSITIONING
if (pendingCanPlay) {
el.removeEventListener("canplay", pendingCanPlay);
pendingCanPlay = null;
}
}, { once: true });
el.addEventListener("loadstart", () => console.debug("[WebRenderer] event:loadstart readyState=", el.readyState), { once: true });
el.addEventListener("loadedmetadata", () => console.debug("[WebRenderer] event:loadedmetadata readyState=", el.readyState), { once: true });
el.addEventListener("loadeddata", () => console.debug("[WebRenderer] event:loadeddata readyState=", el.readyState), { once: true });
el.addEventListener("progress", () => console.debug("[WebRenderer] event:progress readyState=", el.readyState), { once: true });
el.addEventListener("stalled", () => console.warn("[WebRenderer] event:stalled readyState=", el.readyState, "networkState=", el.networkState));
el.addEventListener("waiting", () => console.warn("[WebRenderer] event:waiting readyState=", el.readyState));
el.addEventListener("suspend", () => console.debug("[WebRenderer] event:suspend readyState=", el.readyState, "networkState=", el.networkState), { once: true });
el.addEventListener("abort", () => console.warn("[WebRenderer] event:abort"), { once: true });
el.addEventListener("emptied", () => console.warn("[WebRenderer] event:emptied"), { once: true });
const onCanPlay = () => {
console.log("[WebRenderer] event:canplay readyState=", el.readyState, "calling play()");
pendingCanPlay = null;
el.removeEventListener("canplay", onCanPlay);
el.play().then(() => {
console.log("[WebRenderer] play() resolved OK");
startPositionReporting();
}).catch((e: unknown) => {
console.warn("[WebRenderer] play() rejected:", e);
});
};
pendingCanPlay = onCanPlay;
el.addEventListener("canplay", onCanPlay);
}
function stopStream(): void {
console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState);
stopPositionReporting();
if (!audioEl) return;
if (pendingCanPlay) {
audioEl.removeEventListener("canplay", pendingCanPlay);
pendingCanPlay = null;
}
audioEl.pause();
audioEl.removeAttribute("src"); // ferme la connexion HTTP (src="" résolu comme URL de page sur Safari)
audioEl.load(); // force le reset de l'état réseau
}
// ── Enregistrement ────────────────────────────────────────────────────────
async function register(): Promise<void> {
instanceId = getOrCreateInstanceId();
// Prevent concurrent registrations (race condition → double player)
if (globalPlayer || registering) {
if (globalPlayer) {
player = globalPlayer;
connected.value = true;
}
return;
}
registering = true;
const body: RegisterRequest = {
instance_id: instanceId,
user_agent: navigator.userAgent,
};
const instanceId = getOrCreateInstanceId();
console.log('[WebRenderer] registering with instanceId:', instanceId);
try {
const resp = await fetch("/api/webrenderer/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify({
instance_id: instanceId,
user_agent: navigator.userAgent,
}),
});
if (!resp.ok) {
@@ -192,98 +83,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,
};
}
}

View 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();
}
}

View File

@@ -533,6 +533,9 @@ impl StreamingFlacSink {
current_timestamp: Arc::new(RwLock::new(0.0)),
pending_track_duration: None,
pending_total_samples: None,
is_paused: Arc::new(AtomicBool::new(false)),
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
last_track_metadata: Arc::new(RwLock::new(None)),
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }

View File

@@ -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(())
}

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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()
}

View File

@@ -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(

View File

@@ -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,
}
}
}

View File

@@ -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)